Skip to main content

Data Models

This section defines the complete domain model for Fiscalization by Zyntem, including all entities, their relationships, validation rules, and extensibility for future features.

Design Philosophy

1. Pragmatic Extensibility

  • Core fields support MVP requirements (fiscalization compliance)
  • Optional fields enable future features (advanced digital receipts) WITHOUT schema migrations
  • JSONB catch-all fields (metadata) handle edge cases not yet imagined

2. Privacy-Conscious Design

  • Customer identifiers store tokens (card last4, loyalty ID), never raw PII
  • Audit logs track data access for 7-year compliance retention
  • Row-level security enforces multi-tenant isolation

3. Business Impact Focus

  • Every field maps to a PRD requirement or future revenue opportunity
  • State machines prevent invalid transitions (pending → failed requires error details)
  • Validation rules enforce data quality at application layer

Core Entities

1. Account (Multi-Tenant Root)

Purpose: Represents a customer organization (e.g., "Acme Corp"). Root of multi-tenant hierarchy.

Go Struct:

type Account struct {
ID string `json:"id" db:"id" validate:"required,uuid4"`
PartnerID *string `json:"partner_id,omitempty" db:"partner_id"`
Name string `json:"name" db:"name" validate:"required,min=1,max=100"`
AccountType string `json:"account_type" db:"account_type" validate:"required,oneof=direct partner"`
BillingEmail string `json:"billing_email" db:"billing_email" validate:"required,email"`
StripeCustomerID *string `json:"stripe_customer_id,omitempty" db:"stripe_customer_id"`
Status string `json:"status" db:"status" validate:"required,oneof=active suspended cancelled"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`

// Relationships (not persisted directly)
APIKeys []APIKey `json:"-"`
Locations []Location `json:"-"`
Webhooks []Webhook `json:"-"`
}

Business Rules:

  • status=suspended → All API requests return 403 Forbidden (billing failure, ToS violation)
  • status=cancelled → Read-only access for 90 days (export data), then soft-delete
  • First API key auto-created on account creation (onboarding friction reduction)
  • account_type=partner → Account belongs to an ISV partner (PartnerID references the partner's own account)
  • account_type=direct → Legacy direct customer account (no partner association)

Billing Model:

  • Consumption-based: Customers billed per successful transaction processed
  • Volume-based pricing tiers applied automatically (no plan selection required)
  • Stripe metered billing records usage via UsageMetric entity
  • Pricing details documented separately (business decision, not architectural)

Validation:

  • name: 1-100 chars (displayed in dashboard)
  • billing_email: Must be verified before payment method required

2. APIKey (Authentication)

Purpose: API authentication credentials. Each key belongs to one account.

Go Struct:

type APIKey struct {
ID string `json:"id" db:"id" validate:"required,uuid4"`
AccountID string `json:"account_id" db:"account_id" validate:"required,uuid4"`
Name string `json:"name" db:"name" validate:"required,min=1,max=50"`
KeyPrefix string `json:"key_prefix" db:"key_prefix" validate:"required"` // fsk_test_abc123 (displayed)
KeyHash string `json:"-" db:"key_hash" validate:"required"` // SHA-256 hash (never returned)
Environment string `json:"environment" db:"environment" validate:"required,oneof=test production"`
LastUsedAt *time.Time `json:"last_used_at,omitempty" db:"last_used_at"`
ExpiresAt *time.Time `json:"expires_at,omitempty" db:"expires_at"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
RevokedAt *time.Time `json:"revoked_at,omitempty" db:"revoked_at"`
}

Key Format:

  • Test: fsk_test_{32-char-random} (e.g., fsk_test_7xK9pQ2mN4vL8wR3tY6uI1oP5sA0)
  • Production: fsk_live_{32-char-random}
  • Only shown ONCE on creation (Stripe pattern)

Business Rules:

  • environment=test → Routes to sandbox (no tax authority calls, returns mock data)
  • environment=production → Routes to real tax authorities
  • revoked_at set → Key immediately invalid (dashboard "Revoke" button)
  • expires_at → Optional rotation (enterprise security requirement)

3. Location (Physical Business Location)

Purpose: Merchant's physical location requiring fiscalization (e.g., "Madrid Store #1"). Contains country-specific configuration and internal chain management state.

Go Struct:

type Location struct {
ID string `json:"id" db:"id" validate:"required,uuid4"`
AccountID string `json:"account_id" db:"account_id" validate:"required,uuid4"`
Name string `json:"name" db:"name" validate:"required,min=1,max=100"`
Country string `json:"country" db:"country" validate:"required,iso3166_1_alpha2"`
LegalName string `json:"legal_name" db:"legal_name" validate:"required"`
TaxID string `json:"tax_id" db:"tax_id" validate:"required"`
Address Address `json:"address" db:"address" validate:"required"`
CountryConfig map[string]interface{} `json:"country_config" db:"country_config"` // JSONB

// Certificate Management
CertificateID *string `json:"certificate_id,omitempty" db:"certificate_id"`
CertificateExpiresAt *time.Time `json:"certificate_expires_at,omitempty" db:"certificate_expires_at"`
CertificateIssuer *string `json:"certificate_issuer,omitempty" db:"certificate_issuer"` // "FNMT-RCM", "Izenpe", "InfoCert"

// 🔐 Invoice Chain Management (INTERNAL - Never exposed in API)
// Used by: Spain VERIFACTU, France NF525, future chaining systems
ChainState *ChainState `json:"-" db:"chain_state"` // JSONB - country-specific chain state

Status string `json:"status" db:"status" validate:"required,oneof=active inactive"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
}

type Address struct {
Street string `json:"street" validate:"required"`
City string `json:"city" validate:"required"`
PostalCode string `json:"postal_code" validate:"required"`
Region string `json:"region,omitempty"`
Country string `json:"country" validate:"required,iso3166_1_alpha2"`
}

// ChainState - Generic invoice chain management state
// CRITICAL: Never exposed in API responses (json:"-" on Location field)
// Stored as JSONB in PostgreSQL for country-specific flexibility
type ChainState struct {
// Universal Chain Fields (all countries)
Enabled bool `json:"enabled"` // Chain management active for this location
Algorithm string `json:"algorithm"` // "SHA256", "SHA512", etc.
LastHash string `json:"last_hash"` // Most recent invoice hash (used for next invoice)
LastInvoiceID string `json:"last_invoice_id"` // Transaction ID of last invoice
LastSequenceNumber int64 `json:"last_sequence_number"` // Sequential counter (no gaps allowed)
ChainSeedHash string `json:"chain_seed_hash"` // First invoice hash (initialization)

// Chain Health & Recovery
ChainBroken bool `json:"chain_broken"` // If true, block new invoices until recovery
LastValidatedAt time.Time `json:"last_validated_at"` // Last successful tax authority sync
LastRecoveryAt *time.Time `json:"last_recovery_at,omitempty"` // Last chain recovery operation
RecoveryAttempts int `json:"recovery_attempts"` // Number of recovery attempts (rate limiting)

// Country-Specific Extensions (JSONB flexibility)
CountryData map[string]interface{} `json:"country_data,omitempty"` // Spain: installation_number, France: certificate_number, etc.
}

Country-Specific Config Examples (JSONB):

// Spain - TicketBAI (Basque Country)
{
"system": "ticketbai",
"province": "Bizkaia",
"lroe_enabled": true,
"software_nif": "B12345674",
"software_name": "FiscalAPI v1.0",
"chain_required": false // TicketBAI: Optional chaining
}

// Spain - VERIFACTU (National)
{
"system": "verifactu",
"submission_mode": "real_time", // "real_time" | "batch" (based on business size)
"software_nif": "B12345674",
"software_name": "FiscalAPI v1.0",
"installation_number": "INSTALL-FiscalAPI-001",
"device_type": "SERVIDOR", // "SERVIDOR" | "TERMINAL"
"chain_required": true // VERIFACTU: Mandatory chaining
}

// Italy - SDI
{
"codice_destinatario": "0000000",
"pec_email": "invoices@pec.example.it",
"regime_fiscale": "RF01",
"chain_required": false // Italy: No chaining requirement
}

// France - NF525
{
"nf525_certified": true,
"certificate_number": "2024-FR-001",
"hash_algorithm": "SHA256",
"chain_required": true // France: Mandatory chaining per NF525
}

ChainState Examples (JSONB - Internal Only):

// Spain VERIFACTU - Active chain
{
"enabled": true,
"algorithm": "SHA256",
"last_hash": "8B4G3D9C2F1H5K7M...",
"last_invoice_id": "txn_9aB3cD4eF5gH",
"last_sequence_number": 42,
"chain_seed_hash": "7A3F2B8C9D1E4F6A...",
"chain_broken": false,
"last_validated_at": "2025-10-27T14:30:00Z",
"last_recovery_at": null,
"recovery_attempts": 0,
"country_data": {
"installation_number": "INSTALL-FiscalAPI-001",
"device_type": "SERVIDOR",
"aeat_last_sync": "2025-10-27T14:30:00Z"
}
}

// France NF525 - Active chain
{
"enabled": true,
"algorithm": "SHA256",
"last_hash": "9C5H4E3G2J8L6N1M...",
"last_invoice_id": "txn_8cD4eF5gH6iJ",
"last_sequence_number": 157,
"chain_seed_hash": "1A2B3C4D5E6F7G8H...",
"chain_broken": false,
"last_validated_at": "2025-10-27T13:45:00Z",
"last_recovery_at": null,
"recovery_attempts": 0,
"country_data": {
"certificate_number": "2024-FR-001",
"total_vat_lines": 3
}
}

// Location without chaining (Italy, TicketBAI)
{
"enabled": false
}

Business Rules:

  • status=inactive → Transactions rejected with 400 Bad Request
  • country=ES + country_config.system=ticketbai → Requires certificate
  • Certificate expiration monitored (Cloud Function sends alert 30 days before)
  • Chain Management:
    • ChainState.Enabled=true → Invoice chaining active (VERIFACTU, NF525)
    • ChainState.ChainBroken=true → New transactions blocked until recovery
    • ChainState.LastHash → Automatically used for next invoice (customer never sees this)
    • Chain recovery triggered automatically on validation errors or manually via support endpoint

4. Transaction (Fiscalization Request)

Purpose: Represents a single fiscalization request. Contains transaction details, items, and tax authority response.

Go Struct:

type Transaction struct {
ID string `json:"id" db:"id" validate:"required,uuid4"`
AccountID string `json:"account_id" db:"account_id" validate:"required,uuid4"`
LocationID string `json:"location_id" db:"location_id" validate:"required,uuid4"`
IdempotencyKey *string `json:"idempotency_key,omitempty" db:"idempotency_key"`

// Transaction Details
Timestamp time.Time `json:"timestamp" db:"timestamp" validate:"required"`
Items []Item `json:"items" db:"items" validate:"required,min=1,dive"`
PretaxAmount float64 `json:"pretax_amount" db:"pretax_amount" validate:"required,gte=0"`
TaxAmount float64 `json:"tax_amount" db:"tax_amount" validate:"required,gte=0"`
TotalAmount float64 `json:"total_amount" db:"total_amount" validate:"required,gte=0"`
Currency string `json:"currency" db:"currency" validate:"required,iso4217"`
PaymentMethod string `json:"payment_method" db:"payment_method" validate:"required,oneof=cash card transfer other"`

// Customer Context (Optional - Future Digital Receipts)
CustomerContext *CustomerContext `json:"customer_context,omitempty" db:"customer_context"`

// Fiscalization Results
Status string `json:"status" db:"status" validate:"required,oneof=pending processing success failed"`
FiscalID *string `json:"fiscal_id,omitempty" db:"fiscal_id"`

// 🔐 Invoice Chain Audit Trail (INTERNAL - Never in API responses)
// Used by: Spain VERIFACTU, France NF525, future chaining systems
// Stored for audit trail and recovery purposes only
ChainPreviousHash *string `json:"-" db:"chain_previous_hash"` // Hash used when submitting this invoice
ChainThisHash *string `json:"-" db:"chain_this_hash"` // Hash generated for this invoice
ChainSequenceNumber *int64 `json:"-" db:"chain_sequence_number"` // Sequential number (no gaps)
ChainValidated bool `json:"-" db:"chain_validated"` // Tax authority confirmed chain integrity

// Receipt Integration
FiscalData *FiscalData `json:"fiscal_data,omitempty" db:"fiscal_data"` // ALL compliance data
BasketReceiptURL *string `json:"basket_receipt_url,omitempty" db:"basket_receipt_url"` // Optional convenience PDF

TaxAuthorityResponse map[string]interface{} `json:"tax_authority_response,omitempty" db:"tax_authority_response"`
ErrorCode *string `json:"error_code,omitempty" db:"error_code"`
ErrorMessage *string `json:"error_message,omitempty" db:"error_message"`

// Metadata
ProcessedAt *time.Time `json:"processed_at,omitempty" db:"processed_at"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
}

State Machine:

pending → processing → success
→ failed

Validation:

  • PretaxAmount + TaxAmount = TotalAmount (enforced at application layer)
  • items must have at least 1 item
  • timestamp cannot be >24 hours in past (tax authority requirement)
  • idempotency_key → Duplicate requests within 24 hours return cached response

4a. FiscalData (Complete Compliance Data for Cash Registers)

Purpose: Contains ALL data required for cash registers to generate compliant fiscal receipts. This is the source of truth for what must be printed.

Design Philosophy:

  • Cash registers handle ALL receipt formatting and compliance
  • Fiscalization returns EVERYTHING needed (QR code, signatures, identifiers, legal text)
  • No prescriptive formats - different payment providers handled by customer

Go Struct:

type FiscalData struct {
// Core Fiscal Identifiers (Always Present)
FiscalID string `json:"fiscal_id" validate:"required"`
SequenceNumber *int64 `json:"sequence_number,omitempty"` // Invoice sequence (Spain/Italy)
FiscalTimestamp time.Time `json:"fiscal_timestamp" validate:"required"` // Tax authority timestamp

// QR Code (Multiple Formats)
QRCodeData string `json:"qr_code_data" validate:"required"` // Base64 PNG (200x200px)
QRCodeURL string `json:"qr_code_url" validate:"required"` // Cloud Storage URL
QRCodeRaw string `json:"qr_code_raw,omitempty"` // Raw string (for custom rendering)

// Required Display Text (Country-Specific)
RequiredText []string `json:"required_text" validate:"required"` // MUST print above/below QR
HeaderText []string `json:"header_text,omitempty"` // Print at receipt header
FooterText []string `json:"footer_text,omitempty"` // Print at receipt footer

// Digital Signatures & Hashes
Signature string `json:"signature,omitempty"` // Hex-encoded digital signature
SignatureAlgorithm string `json:"signature_algorithm,omitempty"` // e.g., "SHA256withRSA"
ChainedHash string `json:"chained_hash,omitempty"` // For NF525 hash chains (France)
PreviousHash string `json:"previous_hash,omitempty"` // Previous receipt hash

// Tax Authority Metadata
VerificationURL string `json:"verification_url,omitempty"` // Customer verification link
TaxAuthorityName string `json:"tax_authority_name,omitempty"` // e.g., "AEAT", "AdE", "DGFiP"
ComplianceSystem string `json:"compliance_system,omitempty"` // e.g., "TicketBAI", "SDI", "NF525"

// Spain-Specific (TicketBAI / Verifactu)
Spain *SpainFiscalData `json:"spain,omitempty"`

// Italy-Specific (SDI)
Italy *ItalyFiscalData `json:"italy,omitempty"`

// France-Specific (NF525)
France *FranceFiscalData `json:"france,omitempty"`

// Raw XML/JSON (Full Audit Trail)
RequestXML string `json:"request_xml,omitempty"` // Signed XML sent to authority
ResponseXML string `json:"response_xml,omitempty"` // XML response from authority
RawResponse map[string]interface{} `json:"raw_response,omitempty"` // Unparsed authority response
}

// Spain-Specific Data
type SpainFiscalData struct {
// TicketBAI Fields (Basque Country)
TBAIIdentifier string `json:"tbai_identifier,omitempty"` // TBAI-{NIF}-{Year}-{Sequence}
TBAISignature string `json:"tbai_signature,omitempty"` // Digital signature (hex)
TBAIVersion string `json:"tbai_version,omitempty"` // e.g., "1.2"
Province string `json:"province,omitempty"` // e.g., "Bizkaia", "Gipuzkoa"
LROEBatchID string `json:"lroe_batch_id,omitempty"` // LROE submission batch ID

// VERIFACTU Fields (National)
VerifactuCode string `json:"verifactu_code,omitempty"` // CSV code (e.g., VF-ES-2025-001234567)
InstallationNumber string `json:"installation_number,omitempty"` // Software installation ID
DeviceType string `json:"device_type,omitempty"` // "SERVIDOR" | "TERMINAL"

// Common Software Info
SoftwareNIF string `json:"software_nif,omitempty"` // Software vendor NIF
SoftwareName string `json:"software_name,omitempty"` // Software name/version

// NOTE: Invoice chain hashes (previous_hash, this_hash) are INTERNAL ONLY
// They are stored in Transaction.ChainPreviousHash and Transaction.ChainThisHash
// Customer never needs to manage these - Fiscalization handles chain automatically
}

// Italy-Specific Data
type ItalyFiscalData struct {
SDIIdentifier string `json:"sdi_identifier,omitempty"` // SDI transmission ID
SDIProtocolNumber string `json:"sdi_protocol_number,omitempty"` // Protocol number
CodiceDestinatario string `json:"codice_destinatario,omitempty"` // Recipient code
PECEmail string `json:"pec_email,omitempty"` // PEC email used
ProgressivoInvio int `json:"progressivo_invio,omitempty"` // Transmission progressive number
TipoDocumento string `json:"tipo_documento,omitempty"` // Document type (e.g., "TD01")

// NOTE: Italy does NOT require invoice chaining
}

// France-Specific Data (NF525 Compliance)
type FranceFiscalData struct {
NF525CertificateNumber string `json:"nf525_certificate_number,omitempty"` // Certification number
SignatureFormat string `json:"signature_format,omitempty"` // e.g., "PKCS#7"
TotalVATLines int `json:"total_vat_lines,omitempty"` // Number of VAT lines

// NOTE: Invoice chain hashes and SequenceNumber are INTERNAL ONLY
// They are stored in Transaction.ChainPreviousHash, ChainThisHash, ChainSequenceNumber
// Customer never needs to manage these - Fiscalization handles NF525 chaining automatically
}

Design Decisions:

1. Everything Needed for Compliance

  • QR code in 3 formats (Base64 PNG, URL, raw string)
  • All required display text (header, footer, near QR)
  • Digital signatures (multiple algorithms)
  • Raw XML/JSON for full audit trail

2. Country-Specific Nested Objects

  • Spain: TicketBAI signature, LROE batch ID, Verifactu code
  • Italy: SDI protocol, codice destinatario
  • France: NF525 certificate, hash chain

3. No Payment Provider Assumptions

  • Payment data NOT in FiscalData
  • Customer sends payment receipt data directly to their printer
  • Fiscalization handles fiscalization, not payment compliance

Example Response (Spain):

{
"fiscal_id": "TBAI-B12345674-2024-001",
"sequence_number": 1,
"fiscal_timestamp": "2024-10-27T14:30:00Z",
"qr_code_data": "iVBORw0KGgoAAAANSUhEUg...",
"qr_code_url": "https://storage.googleapis.com/.../qr.png",
"qr_code_raw": "TBAI-B12345674-2024-001-4A3B5C...",
"required_text": [
"TicketBAI",
"Factura Simplificada",
"TBAI-B12345674-2024-001"
],
"signature": "4A3B5C9D2E8F1A0B...",
"signature_algorithm": "SHA256withRSA",
"verification_url": "https://batuz.eus/QRTBAI/?id=TBAI-B12345674-2024-001",
"tax_authority_name": "AEAT",
"compliance_system": "TicketBAI",
"spain": {
"tbai_identifier": "TBAI-B12345674-2024-001",
"tbai_signature": "4A3B5C9D2E8F1A0B...",
"tbai_version": "1.2",
"province": "Bizkaia",
"software_nif": "B98765432",
"software_name": "FiscalAPI v1.0"
},
"request_xml": "<?xml version=\"1.0\"?>...",
"response_xml": "<?xml version=\"1.0\"?>..."
}

---

### 5. Item (Transaction Line Item)

**Purpose**: Represents a single item in a transaction. Includes extensibility for advanced digital receipt features.

**Go Struct:**
```go
type Item struct {
// Core Fields (MVP)
Description string `json:"description" validate:"required,min=1,max=200"`
Quantity float64 `json:"quantity" validate:"required,gt=0"`
UnitPrice float64 `json:"unit_price" validate:"required,gte=0"`
TaxRate float64 `json:"tax_rate" validate:"required,gte=0,lte=1"`
TaxAmount float64 `json:"tax_amount" validate:"required,gte=0"`
TotalAmount float64 `json:"total_amount" validate:"required,gte=0"`

// Optional: Product Identifiers (Future Digital Receipts)
Identifiers *ItemIdentifiers `json:"identifiers,omitempty"`

// Optional: Catch-All for Edge Cases
Metadata map[string]string `json:"metadata,omitempty"`
}

type ItemIdentifiers struct {
EAN string `json:"ean,omitempty" validate:"omitempty,numeric,len=13"` // EAN-13 barcode (retail)
UPC string `json:"upc,omitempty" validate:"omitempty,numeric,len=12"` // UPC-A barcode (North America)
GTIN string `json:"gtin,omitempty" validate:"omitempty,numeric,len=14"` // Global Trade Item Number
SKU string `json:"sku,omitempty" validate:"omitempty,max=50"` // Store-level SKU
ISBN string `json:"isbn,omitempty" validate:"omitempty,isbn"` // ISBN for books
PLU string `json:"plu,omitempty" validate:"omitempty,numeric,max=5"` // Price Look-Up (produce)
}

Design Rationale:

  • Core fields: Support all MVP fiscalization requirements
  • ItemIdentifiers: Enable future features (receipt search by SKU, product analytics) WITHOUT schema changes
  • Zero storage cost: JSONB only stores non-null fields (unused identifiers = 0 bytes)
  • Market validation: Track usage analytics to prioritize feature development

Validation:

  • Quantity * UnitPrice * (1 + TaxRate) = TotalAmount (enforced)
  • Identifiers optional → No validation in MVP, accept any string
  • Metadata → Arbitrary key-value pairs (max 10 keys, 100 chars each)

6. CustomerContext (Optional - Future Digital Receipts)

Purpose: Contextual information about the customer for advanced receipt features. Privacy-conscious design (tokens, not PII).

Go Struct:

type CustomerContext struct {
// Payment Context
CardToken string `json:"card_token,omitempty" validate:"omitempty,max=20"` // Last4 + BIN (e.g., "visa_4242")

// Loyalty & CRM
LoyaltyID string `json:"loyalty_id,omitempty" validate:"omitempty,max=50"` // Loyalty program ID
CustomerRef string `json:"customer_ref,omitempty" validate:"omitempty,max=50"` // Merchant's customer ID

// Receipt Delivery (Opt-In)
Email string `json:"email,omitempty" validate:"omitempty,email"` // Email receipt delivery
PhoneNumber string `json:"phone,omitempty" validate:"omitempty,e164"` // SMS receipt delivery

// Privacy Notes (NOT persisted, documentation only)
// - CardToken: Only last4 digits + brand (PCI-compliant)
// - LoyaltyID: Program-specific identifier (not customer name)
// - Email/Phone: Only stored if customer explicitly opts in
}

Design Rationale:

  • Privacy-first: No raw PAN, no customer names, no unnecessary PII
  • PCI compliance: Card tokens follow Stripe pattern (last4 + brand)
  • Opt-in only: Email/phone only stored if customer consents
  • Future features enabled: Digital receipt lookup, loyalty integration, personalized receipts

Business Rules:

  • email or phone → Enables "Email me receipt" feature (Phase 2)
  • loyalty_id → Merchant can link transactions to their CRM
  • card_token → Enables "View receipts by card" (customer self-service portal)

7. FiscalReceipt (Generated Receipt)

Purpose: Represents the fiscal receipt generated after successful fiscalization. References Cloud Storage URLs.

Go Struct:

type FiscalReceipt struct {
ID string `json:"id" db:"id" validate:"required,uuid4"`
TransactionID string `json:"transaction_id" db:"transaction_id" validate:"required,uuid4"`
AccountID string `json:"account_id" db:"account_id" validate:"required,uuid4"`
LocationID string `json:"location_id" db:"location_id" validate:"required,uuid4"`

// Receipt Identifiers
FiscalID string `json:"fiscal_id" db:"fiscal_id" validate:"required"`
SequenceNumber *int64 `json:"sequence_number,omitempty" db:"sequence_number"`

// Receipt Assets (Cloud Storage URLs)
PDFURL string `json:"pdf_url" db:"pdf_url" validate:"required,url"`
QRCodeURL string `json:"qr_code_url" db:"qr_code_url" validate:"required,url"`
XMLURL *string `json:"xml_url,omitempty" db:"xml_url" validate:"omitempty,url"`

// Tax Authority Response (Audit Trail)
TaxAuthorityResponse map[string]interface{} `json:"tax_authority_response" db:"tax_authority_response"`

// Metadata
GeneratedAt time.Time `json:"generated_at" db:"generated_at"`
ExpiresAt *time.Time `json:"expires_at,omitempty" db:"expires_at"` // Signed URL expiration
CreatedAt time.Time `json:"created_at" db:"created_at"`
}

Cloud Storage Paths:

gs://fiscalization-receipts-prod/{account_id}/{transaction_id}.pdf
gs://fiscalization-receipts-prod/{account_id}/{transaction_id}_qr.png
gs://fiscalization-audit-prod/{country}/{transaction_id}_request.xml

Business Rules:

  • PDFURL → Signed URL valid for 7 days (regenerated on access)
  • XMLURL → Only for Spain/Italy (audit requirement), null for France
  • tax_authority_response → Full response stored for debugging (7-year retention)

8. Webhook (Event Notification Configuration)

Purpose: Customer-configured webhook endpoint for transaction status updates.

Go Struct:

type Webhook struct {
ID string `json:"id" db:"id" validate:"required,uuid4"`
AccountID string `json:"account_id" db:"account_id" validate:"required,uuid4"`
URL string `json:"url" db:"url" validate:"required,url,https"`
Events []string `json:"events" db:"events" validate:"required,min=1,dive,oneof=transaction.success transaction.failed"`
Secret string `json:"-" db:"secret" validate:"required"` // HMAC secret (never returned)
Status string `json:"status" db:"status" validate:"required,oneof=active inactive"`
FailureCount int `json:"failure_count" db:"failure_count" validate:"gte=0"`
LastFailureAt *time.Time `json:"last_failure_at,omitempty" db:"last_failure_at"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
}

Webhook Payload Example:

{
"event": "transaction.success",
"transaction_id": "550e8400-e29b-41d4-a716-446655440000",
"fiscal_id": "TBAI-B12345674-2024-001",
"receipt_url": "https://storage.googleapis.com/.../receipt.pdf",
"timestamp": "2024-10-27T14:30:00Z"
}

Business Rules:

  • status=inactive → No webhooks sent (automatic after 10 consecutive failures)
  • failure_count >= 10 → Auto-disable + send email alert
  • HMAC-SHA256 signature in X-Fiscalization-Signature header (Stripe pattern)

9. WebhookDelivery (Delivery Attempt Record)

Purpose: Tracks webhook delivery attempts for debugging and retry logic.

Go Struct:

type WebhookDelivery struct {
ID string `json:"id" db:"id" validate:"required,uuid4"`
WebhookID string `json:"webhook_id" db:"webhook_id" validate:"required,uuid4"`
TransactionID string `json:"transaction_id" db:"transaction_id" validate:"required,uuid4"`
Event string `json:"event" db:"event" validate:"required"`
Status string `json:"status" db:"status" validate:"required,oneof=pending success failed"`
Attempt int `json:"attempt" db:"attempt" validate:"required,gte=1,lte=5"`
ResponseCode *int `json:"response_code,omitempty" db:"response_code"`
ResponseBody *string `json:"response_body,omitempty" db:"response_body"`
ErrorMessage *string `json:"error_message,omitempty" db:"error_message"`
NextRetryAt *time.Time `json:"next_retry_at,omitempty" db:"next_retry_at"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
CompletedAt *time.Time `json:"completed_at,omitempty" db:"completed_at"`
}

Retry Logic:

  • Attempt 1: Immediate
  • Attempt 2: +1 minute
  • Attempt 3: +5 minutes
  • Attempt 4: +15 minutes
  • Attempt 5: +1 hour → Dead letter queue

State Machine:

pending → success
→ failed (attempt < 5) → pending (retry)
→ failed (attempt = 5) → DLQ

Supporting Entities

10. IdempotencyKey (Duplicate Prevention)

Purpose: Prevents duplicate transactions when customer retries a failed request.

Go Struct:

type IdempotencyKey struct {
Key string `json:"key" db:"key" validate:"required,max=255"`
AccountID string `json:"account_id" db:"account_id" validate:"required,uuid4"`
TransactionID string `json:"transaction_id" db:"transaction_id" validate:"required,uuid4"`
ResponseCode int `json:"response_code" db:"response_code" validate:"required"`
ResponseBody string `json:"response_body" db:"response_body" validate:"required"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
ExpiresAt time.Time `json:"expires_at" db:"expires_at"`
}

Business Rules:

  • TTL: 24 hours (tax authority de-duplication window)
  • Duplicate request → Return cached response (200 OK, same transaction_id)
  • Database unique constraint: (key, account_id)

11. AuditLog (Compliance Trail)

Purpose: Immutable audit trail for compliance (7-year retention requirement).

Go Struct:

type AuditLog struct {
ID string `json:"id" db:"id" validate:"required,uuid4"`
AccountID string `json:"account_id" db:"account_id" validate:"required,uuid4"`
Action string `json:"action" db:"action" validate:"required"`
EntityType string `json:"entity_type" db:"entity_type" validate:"required"`
EntityID string `json:"entity_id" db:"entity_id" validate:"required,uuid4"`
ActorType string `json:"actor_type" db:"actor_type" validate:"required,oneof=user api_key system"`
ActorID string `json:"actor_id" db:"actor_id" validate:"required"`
IPAddress string `json:"ip_address" db:"ip_address" validate:"required,ip"`
UserAgent string `json:"user_agent" db:"user_agent" validate:"required"`
Changes map[string]interface{} `json:"changes,omitempty" db:"changes"`
Timestamp time.Time `json:"timestamp" db:"timestamp"`
}

Actions Logged:

  • transaction.created, transaction.fiscalized, transaction.failed
  • location.created, location.updated, certificate.uploaded
  • api_key.created, api_key.revoked
  • webhook.created, webhook.disabled

Retention:

  • 7 years (Italian compliance requirement)
  • Partitioned by month → Old partitions archived to Cloud Storage Archive class

12. UsageMetric (Billing Aggregation)

Purpose: Pre-aggregated usage metrics for consumption-based billing (Stripe metered billing).

Go Struct:

type UsageMetric struct {
ID string `json:"id" db:"id" validate:"required,uuid4"`
AccountID string `json:"account_id" db:"account_id" validate:"required,uuid4"`
MetricType string `json:"metric_type" db:"metric_type" validate:"required,oneof=transaction api_request"`
Country string `json:"country" db:"country" validate:"required,iso3166_1_alpha2"`
Count int64 `json:"count" db:"count" validate:"required,gte=0"`
PeriodStart time.Time `json:"period_start" db:"period_start"`
PeriodEnd time.Time `json:"period_end" db:"period_end"`
ReportedToStripe bool `json:"reported_to_stripe" db:"reported_to_stripe"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
}

Aggregation:

  • Hourly: Cloud Function queries transactions table (WHERE status=success), inserts usage_metrics
  • Daily: Stripe API called with aggregated counts (Stripe Metered Billing API)
  • Monthly: Invoice generated based on consumption
  • Country-specific tracking: Enables different pricing by country if needed (business decision)

Business Rules:

  • Only successful transactions billed (status=success)
  • Failed/pending transactions not counted
  • Refunds handled via Stripe credit memo

13. Certificate (X.509 for Spain/Italy)

Purpose: Manages X.509 certificates for Spain (TicketBAI) and Italy (SDI) digital signatures.

Go Struct:

type Certificate struct {
ID string `json:"id" db:"id" validate:"required,uuid4"`
LocationID string `json:"location_id" db:"location_id" validate:"required,uuid4"`
AccountID string `json:"account_id" db:"account_id" validate:"required,uuid4"`
Type string `json:"type" db:"type" validate:"required,oneof=ticketbai sdi"`
SecretPath string `json:"-" db:"secret_path" validate:"required"` // Secret Manager path
Passphrase string `json:"-" db:"passphrase" validate:"required"` // Encrypted passphrase
IssuerDN string `json:"issuer_dn" db:"issuer_dn" validate:"required"`
SubjectDN string `json:"subject_dn" db:"subject_dn" validate:"required"`
SerialNumber string `json:"serial_number" db:"serial_number" validate:"required"`
NotBefore time.Time `json:"not_before" db:"not_before"`
NotAfter time.Time `json:"not_after" db:"not_after"`
Status string `json:"status" db:"status" validate:"required,oneof=active expiring expired"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
}

Business Rules:

  • status=expiring → 30 days before not_after, email alert sent
  • status=expired → Location cannot fiscalize, returns 400 Bad Request
  • Certificate stored in Secret Manager (encrypted at rest)

Entity Relationship Diagram

erDiagram
ACCOUNT ||--o{ API_KEY : "has"
ACCOUNT ||--o{ LOCATION : "has"
ACCOUNT ||--o{ WEBHOOK : "has"
ACCOUNT ||--o{ TRANSACTION : "creates"
ACCOUNT ||--o{ AUDIT_LOG : "tracked_by"
ACCOUNT ||--o{ USAGE_METRIC : "billed_by"

LOCATION ||--o{ TRANSACTION : "fiscalizes"
LOCATION ||--o| CERTIFICATE : "uses"

TRANSACTION ||--o| FISCAL_RECEIPT : "generates"
TRANSACTION ||--o{ WEBHOOK_DELIVERY : "triggers"
TRANSACTION ||--o| IDEMPOTENCY_KEY : "prevents_duplicates"

WEBHOOK ||--o{ WEBHOOK_DELIVERY : "delivers"

ACCOUNT {
uuid id PK
string name
string billing_email
string stripe_customer_id
string plan
string status
timestamp created_at
}

API_KEY {
uuid id PK
uuid account_id FK
string name
string key_prefix
string key_hash
string environment
timestamp last_used_at
timestamp created_at
}

LOCATION {
uuid id PK
uuid account_id FK
string name
string country
string legal_name
string tax_id
jsonb address
jsonb country_config
uuid certificate_id FK
timestamp certificate_expires_at
string status
timestamp created_at
}

TRANSACTION {
uuid id PK
uuid account_id FK
uuid location_id FK
string idempotency_key
timestamp timestamp
jsonb items
decimal pretax_amount
decimal tax_amount
decimal total_amount
string currency
string payment_method
jsonb customer_context
string status
string fiscal_id
string qr_code_url
string receipt_url
jsonb tax_authority_response
timestamp created_at
}

FISCAL_RECEIPT {
uuid id PK
uuid transaction_id FK
uuid account_id FK
uuid location_id FK
string fiscal_id
int sequence_number
string pdf_url
string qr_code_url
string xml_url
jsonb tax_authority_response
timestamp generated_at
}

WEBHOOK {
uuid id PK
uuid account_id FK
string url
string[] events
string secret
string status
int failure_count
timestamp last_failure_at
timestamp created_at
}

WEBHOOK_DELIVERY {
uuid id PK
uuid webhook_id FK
uuid transaction_id FK
string event
string status
int attempt
int response_code
string error_message
timestamp next_retry_at
timestamp created_at
}

IDEMPOTENCY_KEY {
string key PK
uuid account_id PK
uuid transaction_id FK
int response_code
text response_body
timestamp created_at
timestamp expires_at
}

AUDIT_LOG {
uuid id PK
uuid account_id FK
string action
string entity_type
uuid entity_id
string actor_type
string actor_id
string ip_address
jsonb changes
timestamp timestamp
}

USAGE_METRIC {
uuid id PK
uuid account_id FK
string metric_type
string country
int count
timestamp period_start
timestamp period_end
bool reported_to_stripe
timestamp created_at
}

CERTIFICATE {
uuid id PK
uuid location_id FK
uuid account_id FK
string type
string secret_path
string issuer_dn
string subject_dn
string serial_number
timestamp not_before
timestamp not_after
string status
timestamp created_at
}

Validation Rules Summary

EntityKey Constraints
Accountstatus IN (active, suspended, cancelled)
APIKeyenvironment IN (test, production), key_hash SHA-256
Locationcountry ISO 3166-1 alpha-2, tax_id validated by country
TransactionPretaxAmount + TaxAmount = TotalAmount, items.length >= 1
ItemQuantity * UnitPrice * (1 + TaxRate) = TotalAmount
FiscalReceiptfiscal_id unique per location+country
Webhookurl HTTPS only, events non-empty
WebhookDeliveryattempt 1-5, status state machine enforced
IdempotencyKeyUnique (key, account_id), TTL 24 hours
Certificatenot_after > NOW() for status=active

State Machines

Transaction States:

pending → processing → success
→ failed
  • pending: Transaction created, queued for processing
  • processing: Adapter called, awaiting tax authority response
  • success: Fiscal receipt generated, QR code created
  • failed: Tax authority error, customer must resolve

WebhookDelivery States:

pending → success (200-299 response)
→ failed → pending (retry, attempt < 5)
→ failed → DLQ (attempt = 5)

Certificate States:

active → expiring (30 days before expiry)
→ expired (past not_after)

Extensibility Strategy (Digital Receipts)

Current State (MVP):

  • Transaction.Items[] → Basic item details (description, quantity, price, tax)
  • Transaction.CustomerContext → Null (not used in MVP)
  • Storage Cost: ~$0/month (unused JSONB fields = 0 bytes)

Future State (Phase 2 - Advanced Digital Receipts):

Use Case 1: Receipt Search by Product

  • Customer: "Show me all transactions with SKU=ABC123"
  • Implementation: Add GIN index on items JSONB → Search by identifiers.sku
  • No schema migration required (fields already exist)

Use Case 2: Loyalty Integration

  • Customer: "Send receipt to loyalty program"
  • Implementation: Read customer_context.loyalty_id → Call merchant's loyalty API
  • No schema migration required

Use Case 3: Customer Self-Service Portal

  • End User: "Show me my receipts for card ending in 4242"
  • Implementation: Query customer_context.card_token='visa_4242' → Return receipts
  • No schema migration required

Market Validation Approach:

  1. Track analytics: What % of transactions include identifiers?
  2. If >20% → Prioritize Phase 2 features (search, analytics)
  3. If <5% → Keep fields but defer feature development

Migration Cost Avoided:

  • Typical cost: 3-5 days developer time + downtime + customer communication
  • Our approach: Zero cost (fields exist, backward-compatible)

Receipt Philosophy and Responsibility Boundaries

Critical Architectural Decision: Fiscalization does NOT handle fiscal receipt compliance

Fiscalization's scope is fiscalization (tax authority communication), not receipt generation. This avoids complex, country-specific receipt formatting compliance at MVP stage.

Responsibility Boundaries

ResponsibilityFiscalizationCustomer (Cash Register)
Fiscalization✅ Tax authority communication
Digital signatures✅ Generate signatures
QR codes✅ Generate QR codes
Compliance data✅ Return ALL data needed
Receipt formatting✅ Print compliant receipt
Payment data✅ Handle payment terminal
Receipt compliance✅ Follow country receipt laws

Why This Separation:

  • Receipt compliance is HIGHLY country-specific (paper size, fonts, mandatory fields, order)
  • Payment terminal data varies by provider (Stripe, Adyen, SumUp, etc.)
  • Customers already have receipt formatting logic in their POS
  • Avoids Fiscalization becoming liable for receipt format violations

Receipt Integration Patterns

Fiscalization supports two distinct patterns:

Pattern A: Cash Register Prints Compliant Receipt (Primary Use Case)

Use Case:

  • Customer has POS system with thermal/receipt printer (90% of MVP customers)
  • Cash register handles ALL receipt formatting and compliance
  • Fiscalization provides fiscalization data to embed in receipt

Architecture:

Customer POS → Fiscalization → Tax Authority

Returns:
- fiscal_data (QR, signatures, IDs, legal text)
- basket_receipt_url: null

API Request:

POST /v1/transactions
{
"location_id": "loc_123",
"timestamp": "2024-10-27T14:30:00Z",
"items": [
{"description": "Latte", "quantity": 1, "unit_price": 3.50, "tax_rate": 0.10}
],
"total_amount": 3.85,
"payment_method": "card"
}

API Response:

{
"id": "txn_456",
"status": "success",
"fiscal_data": {
"fiscal_id": "TBAI-B12345674-2024-001",
"sequence_number": 1,
"fiscal_timestamp": "2024-10-27T14:30:00Z",
"qr_code_data": "iVBORw0KGgoAAAANSUhEUg...",
"qr_code_url": "https://storage.googleapis.com/.../qr.png",
"qr_code_raw": "TBAI-B12345674-2024-001-4A3B5C...",
"required_text": ["TicketBAI", "Factura Simplificada", "TBAI-B12345674-2024-001"],
"signature": "4A3B5C9D2E8F1A0B...",
"verification_url": "https://batuz.eus/QRTBAI/?id=TBAI-B12345674-2024-001",
"spain": {
"tbai_identifier": "TBAI-B12345674-2024-001",
"tbai_signature": "4A3B5C9D2E8F1A0B...",
"province": "Bizkaia",
"software_nif": "B98765432"
}
},
"basket_receipt_url": null
}

Customer's Thermal Printer Output (Compliant Receipt):

================================
FARMACIA EXAMPLE SL
CIF: B12345674
C/ Mayor 123, Bilbao
================================

Latte €3.50
--------
Subtotal €3.50
IVA (10%) €0.35
TOTAL €3.85

Pago: Tarjeta Visa ****4242
Autorización: 789012
Terminal: TRM-001

DCC: Cliente eligió USD
Tipo cambio: 1.18
Importe EUR: €3.85
Importe USD: $4.54

--------------------------------
TicketBAI
Factura Simplificada
TBAI-B12345674-2024-001

[QR CODE PRINTED HERE]

Verificar en:
batuz.eus/QRTBAI/?id=...

Gracias por su compra!
================================

Implementation:

  • Customer's POS receives fiscal_data from Fiscalization
  • POS adds payment terminal data (card, DCC, etc.)
  • POS formats everything according to local receipt laws
  • POS prints QR code using ESC/POS commands (qr_code_data Base64)

Critical: Customer Handles:

  • Receipt header/footer formatting
  • Payment section (Visa ****4242, auth code)
  • DCC disclosure (regulatory requirement - customer's responsibility!)
  • QR code printing
  • All country-specific receipt layout requirements

Pattern B: Basket Receipt (Convenience Feature, Non-Compliant)

Use Case:

  • Customer wants simple itemized receipt (for testing, mobile apps, backups)
  • NOT fiscally compliant (missing payment data, formatting not certified)
  • Example: Developer testing integration, customer wants backup PDF

Philosophy:

  • "Basket receipt" = simple shopping list with fiscal data appended
  • Fiscalization generates based on transaction items only
  • Receipt generator runs whenever transaction succeeds (always generates if items provided)
  • Customer still responsible for compliant receipt

Architecture:

Customer App → Fiscalization → Tax Authority

Returns:
- fiscal_data (always)
- basket_receipt_url (convenience PDF)

API Request (same as Pattern A):

POST /v1/transactions
{
"location_id": "loc_123",
"timestamp": "2024-10-27T14:30:00Z",
"items": [
{"description": "Latte", "quantity": 1, "unit_price": 3.50, "tax_rate": 0.10}
],
"total_amount": 3.85,
"payment_method": "card"
}

API Response:

{
"id": "txn_456",
"status": "success",
"fiscal_data": { /* same as Pattern A */ },
"basket_receipt_url": "https://storage.googleapis.com/.../basket-receipt.pdf"
}

Fiscalization Generated Basket Receipt (PDF):

╔════════════════════════════════╗
║ FARMACIA EXAMPLE SL ║
║ CIF: B12345674 ║
║ C/ Mayor 123, Bilbao ║
╚════════════════════════════════╝

⚠️ BASKET RECEIPT - NOT FISCALLY COMPLIANT
For compliance, use cash register receipt

Date: 27/10/2024 14:30
Transaction ID: txn_456

Items:
----------------------------------
Latte 1x €3.50 €3.50
----------------------------------
Subtotal €3.50
VAT (10%) €0.35
----------------------------------
TOTAL €3.85

Payment Method: Card

FISCAL DATA (For Reference)
----------------------------------
TicketBAI
Factura Simplificada
TBAI-B12345674-2024-001

[QR CODE EMBEDDED]

Verify at:
batuz.eus/QRTBAI/?id=...
----------------------------------

Generated by Fiscalization
This is a basket receipt for convenience.
Cash register must provide compliant receipt.

Pattern Comparison

AspectPattern A (Compliant Receipt)Pattern B (Basket Receipt)
Fiscal Compliance✅ Customer's responsibilityNOT COMPLIANT
Receipt GenerationCustomer's POSFiscalization (convenience)
QR Code PrintingCustomer (ESC/POS)Fiscalization embeds
Payment DataCustomer handlesNot included (customer handles separately)
DCC DisclosureCustomer handlesNot included
Use CaseProduction (90% of customers)Testing, backups, mobile apps (10%)
LiabilityCustomer liableCustomer still liable
Response Size~5KB JSON~50KB JSON + PDF URL
basket_receipt_urlnullPDF URL

Design Decisions

1. Always Return fiscal_data

  • Contains EVERYTHING needed for compliant receipts
  • QR code in 3 formats (Base64, URL, raw string)
  • Digital signatures, hashes, required text
  • Country-specific data (Spain, Italy, France)
  • Raw XML audit trail

2. Basket Receipt Always Generated

  • Receipt generator runs on every successful transaction
  • Simple itemized list + fiscal data appended
  • Clearly marked as non-compliant
  • Convenience feature for testing/debugging

3. No Payment Provider Assumptions

  • Fiscalization does NOT handle payment terminal data
  • Customer integrates payment provider separately
  • Customer responsible for payment receipt formatting
  • Avoids prescribing data format (Stripe, Adyen, SumUp differ)

4. Compliance Responsibility Clear

  • Cash register responsible for compliant receipt
  • Fiscalization provides all fiscalization data
  • Customer combines fiscal data + payment data + formatting
  • Avoids liability for receipt format violations

Implementation Implications

Backend (Go):

  • Adapter returns FiscalData after tax authority call (ALWAYS)
  • Receipt generator service runs in parallel:
    • Consumes: Transaction items + FiscalData
    • Outputs: Basket PDF → Cloud Storage
    • Adds warning header ("NOT FISCALLY COMPLIANT")
  • Signed URLs valid for 90 days

API Contract:

  • fiscal_data: Always present (Pattern A + B)
  • basket_receipt_url: Always present if transaction succeeds
  • Customer ignores basket_receipt_url if using cash register

Testing:

  • Mock tax authority responses include all country-specific fields
  • Basket receipt tested with 1 item, 50 items, €0.01 transactions
  • Fiscal data validated against country requirements

Future Extensibility

Phase 2: Configurable Basket Receipt

  • generate_basket_receipt=false query param to skip generation (save costs)
  • Default: Always generate (convenience)

Phase 3: Email/SMS Delivery

  • customer_context.email triggers automatic email
  • Basket receipt sent as attachment (still non-compliant)
  • Customer still prints compliant receipt at POS

API Specification

This section defines the complete REST API contract for Fiscalization by Zyntem, including all endpoints, request/response schemas, authentication, and error handling.

Design Principles

1. OpenAPI 3.0.3 Contract-First

  • OpenAPI specification is the source of truth
  • Drives SDK generation (Go, TypeScript, Python, Ruby, PHP, Java)
  • Powers interactive documentation (Swagger UI)
  • Enables contract testing with Schemathesis

2. RESTful Resource Design

  • Resources: Accounts, Locations, Transactions, Webhooks, Certificates
  • Standard HTTP verbs: GET (read), POST (create), PATCH (update), DELETE (remove)
  • Nested resources: /locations/{id}/transactions for location-specific queries

3. Idempotency & Reliability

  • POST /transactions supports Idempotency-Key header (24-hour TTL)
  • Duplicate requests return cached response (prevents double-fiscalization)
  • All mutations (POST/PATCH/DELETE) are idempotent

4. Developer Experience

  • Consistent error formats (RFC 7807 Problem Details)
  • Helpful error messages with resolution steps
  • Rate limit headers on every response
  • Pagination with Link headers (RFC 5988)

Authentication

API Key Authentication (Bearer Token)

All requests require authentication via API key passed in Authorization header:

Authorization: Bearer fsk_live_7xK9pQ2mN4vL8wR3tY6uI1oP5sA0

Key Formats:

  • Test environment: fsk_test_{32-char-random}
  • Production environment: fsk_live_{32-char-random}

Security:

  • Keys shown ONCE on creation (Stripe pattern)
  • SHA-256 hash stored in database
  • Rate limiting per key (1000 req/min test, 10000 req/min production)
  • Keys can be revoked via dashboard

Example Request:

curl -X POST https://api.zyntem.dev/fiscalization/v1/transactions \
-H "Authorization: Bearer fsk_live_7xK9pQ2mN4vL8wR3tY6uI1oP5sA0" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: a1b2c3d4-e5f6-7890-abcd-ef1234567890" \
-d '{...}'

Authentication Errors:

  • 401 Unauthorized: Missing or invalid API key
  • 403 Forbidden: Account suspended (billing failure)
  • 403 Forbidden: API key revoked

Base URL & Versioning

Production:

https://api.zyntem.dev/fiscalization/v1

Sandbox (Test Environment):

https://sandbox.zyntem.dev/fiscalization/v1

Versioning Strategy:

  • URL-based versioning (/v1, /v2)
  • Breaking changes trigger new major version
  • Non-breaking changes (new fields, endpoints) added to existing version
  • Old versions deprecated after 12 months notice

API Version Header:

X-Fiscalization-Version: 2024-10-27

Core Endpoints

1. Create Transaction (Fiscalize)

Primary endpoint for fiscalization.

Core Principle: Never Block the Cash Register

  • Always returns 201 Created (never 202 or 502)
  • Basket receipt always generated (even if tax authority down)
  • Transaction status indicates fiscalization state
  • Background retry queue handles tax authority failures gracefully
POST /v1/transactions

Request Headers:

Authorization: Bearer fsk_live_xxx
Content-Type: application/json
Idempotency-Key: uuid-v4 (optional but recommended)
X-Fiscalization-Timeout: 10 (optional, seconds to wait for tax authority, default: 10)

Request Body:

{
"location_id": "loc_2Xk9pQ1mN4vL",
"timestamp": "2024-10-27T14:30:00Z",
"items": [
{
"description": "Espresso",
"quantity": 1,
"unit_price": 2.50,
"tax_rate": 0.10,
"tax_amount": 0.25,
"total_amount": 2.75,
"identifiers": {
"sku": "COFFEE-ESP-001",
"ean": "1234567890123"
},
"metadata": {
"category": "beverages"
}
}
],
"pretax_amount": 2.50,
"tax_amount": 0.25,
"total_amount": 2.75,
"currency": "EUR",
"payment_method": "card",
"customer_context": {
"card_token": "visa_4242",
"email": "customer@example.com",
"loyalty_id": "LOYAL-12345"
}
}

Response Status Codes:

  • 201 Created: Transaction created (always returned, check status field)
  • 400 Bad Request: Validation error
  • 401 Unauthorized: Invalid API key
  • 429 Too Many Requests: Rate limit exceeded

Response Scenarios:

Scenario A: Tax Authority Responds (2-10 seconds)

Status: 201 Created

{
"id": "txn_8wR3tY6uI1oP",
"object": "transaction",
"account_id": "acc_7xK9pQ2mN4vL",
"location_id": "loc_2Xk9pQ1mN4vL",
"status": "fiscalized", // ← Real fiscalization complete
"timestamp": "2024-10-27T14:30:00Z",
"items": [...],
"pretax_amount": 2.50,
"tax_amount": 0.25,
"total_amount": 2.75,
"currency": "EUR",
"payment_method": "card",
"fiscal_id": "TBAI-B12345674-2024-001",
"fiscal_data": {
"fiscal_id": "TBAI-B12345674-2024-001",
"sequence_number": 1,
"fiscal_timestamp": "2024-10-27T14:30:01Z",
"qr_code_data": "iVBORw0KGgoAAAANSUhEUg...", // ← Real QR code
"qr_code_url": "https://storage.googleapis.com/.../qr.png",
"qr_code_raw": "TBAI-B12345674-2024-001-4A3B5C...",
"required_text": ["TicketBAI", "Factura Simplificada", "TBAI-B12345674-2024-001"],
"signature": "4A3B5C9D2E8F1A0B...",
"verification_url": "https://batuz.eus/QRTBAI/?id=TBAI-B12345674-2024-001",
"spain": {
"tbai_identifier": "TBAI-B12345674-2024-001",
"tbai_signature": "4A3B5C9D2E8F1A0B...",
"province": "Bizkaia"
}
},
"basket_receipt_url": "https://storage.googleapis.com/.../basket-receipt.pdf",
"status_page_url": "https://zyntem.dev/receipts/txn_8wR3tY6uI1oP",
"processed_at": "2024-10-27T14:30:01Z",
"created_at": "2024-10-27T14:30:00Z"
}

Scenario A2: VERIFACTU with Invoice Chaining (Spain National)

Status: 201 Created

IMPORTANT: Customer sends NO chain data - Fiscalization manages internally!

{
"id": "txn_9aB3cD4eF5gH",
"object": "transaction",
"account_id": "acc_7xK9pQ2mN4vL",
"location_id": "loc_2Xk9pQ1mN4vL", // Location has ChainState internally
"status": "fiscalized",
"timestamp": "2025-10-27T14:30:00Z",
"items": [...],
"pretax_amount": 10.00,
"tax_amount": 2.10,
"total_amount": 12.10,
"currency": "EUR",
"payment_method": "card",
"fiscal_id": "VF-ES-2025-001234567",
"fiscal_data": {
"fiscal_id": "VF-ES-2025-001234567",
"sequence_number": 42, // Sequential counter (no gaps)
"fiscal_timestamp": "2025-10-27T14:30:01Z",
"qr_code_data": "iVBORw0KGgoAAAANSUhEUg...",
"qr_code_url": "https://sede.agenciatributaria.gob.es/verifactu/qr?id=VF-ES-2025...",
"qr_code_raw": "VF-ES-2025-001234567",
"required_text": ["VERIFACTU", "Factura Simplificada", "VF-ES-2025-001234567"],
"signature": "8B4G3D9C2F1H5K7M...",
"signature_algorithm": "SHA256withRSA",
"verification_url": "https://sede.agenciatributaria.gob.es/verifactu/verificar/VF-ES-2025-001234567",
"tax_authority_name": "AEAT",
"compliance_system": "VERIFACTU",
"spain": {
"verifactu_code": "VF-ES-2025-001234567",
"installation_number": "INSTALL-FiscalAPI-001",
"device_type": "SERVIDOR",
"software_nif": "B98765432",
"software_name": "FiscalAPI v1.0"
// NOTE: NO previous_hash, this_hash, or chain_seed exposed!
// FiscalAPI manages chain internally via Location.ChainState
}
},
"basket_receipt_url": "https://storage.googleapis.com/.../basket-receipt.pdf",
"status_page_url": "https://zyntem.dev/receipts/txn_9aB3cD4eF5gH",
"processed_at": "2025-10-27T14:30:01Z",
"created_at": "2025-10-27T14:30:00Z"
}

Chain Management Notes:

  • ✅ Customer sends transaction data ONLY (no previous_hash required)
  • ✅ Fiscalization reads Location.ChainState.LastHash internally
  • ✅ Fiscalization calculates new_hash and updates Location.ChainState.LastHash
  • ✅ Chain audit trail stored in Transaction.ChainPreviousHash and Transaction.ChainThisHash (internal only)
  • ✅ If chain breaks (DB failure, AEAT error), automatic recovery syncs from tax authority
  • ✅ Customer experience identical to non-chained systems (TicketBAI, Italy SDI)

Scenario B: Tax Authority Timeout / Circuit Breaker Triggered

Status: 201 Created (business continues)

{
"id": "txn_8wR3tY6uI1oP",
"object": "transaction",
"status": "pending_fiscalization", // ← Queued for background retry
"timestamp": "2024-10-27T14:30:00Z",
"items": [...],
"pretax_amount": 2.50,
"tax_amount": 0.25,
"total_amount": 2.75,
"currency": "EUR",
"payment_method": "card",
"fiscal_id": null, // ← Not yet available
"fiscal_data": null, // ← Not yet available
"basket_receipt_url": "https://storage.googleapis.com/.../basket-receipt.pdf", // ← Generated anyway!
"status_page_url": "https://zyntem.dev/receipts/txn_8wR3tY6uI1oP",
"estimated_completion": "2024-10-27T14:35:00Z",
"retry_attempt": 0,
"next_retry_at": "2024-10-27T14:31:00Z",
"created_at": "2024-10-27T14:30:00Z"
}

Scenario C: Permanent Failure (validation error)

Status: 201 Created (basket receipt still provided)

{
"id": "txn_8wR3tY6uI1oP",
"object": "transaction",
"status": "failed", // ← Permanent failure (customer config issue)
"timestamp": "2024-10-27T14:30:00Z",
"items": [...],
"total_amount": 2.75,
"fiscal_id": null,
"fiscal_data": null,
"basket_receipt_url": "https://storage.googleapis.com/.../basket-receipt.pdf",
"status_page_url": "https://zyntem.dev/receipts/txn_8wR3tY6uI1oP",
"error": {
"code": "certificate_expired",
"message": "Certificate expired on 2023-12-31",
"resolution": "Upload a valid certificate in the dashboard"
},
"created_at": "2024-10-27T14:30:00Z"
}

Validation Errors (400 Bad Request):

{
"type": "https://docs.zyntem.dev/fiscalization/errors/validation_error",
"title": "Validation Error",
"status": 400,
"detail": "Request validation failed",
"errors": [
{
"field": "items[0].total_amount",
"message": "Total amount mismatch: expected 2.75, got 2.70",
"code": "amount_mismatch"
},
{
"field": "timestamp",
"message": "Timestamp cannot be more than 24 hours in the past",
"code": "timestamp_too_old"
}
],
"request_id": "req_9zS4uB7wJ2qM"
}

Transaction Status Field:

StatusMeaningfiscal_dataAction
fiscalizedTax authority confirmed✅ PresentPrint compliant receipt
pending_fiscalizationQueued for retry❌ NullPrint temporary receipt
failedPermanent failure❌ NullFix configuration

Idempotency Response (200 OK):

  • Same Idempotency-Key within 24 hours returns cached response
  • Status code: 200 OK (not 201 Created)
  • Response body identical to original (preserves status field)
  • Header: X-Idempotent-Replayed: true

Timeout Control:

  • Header: X-Fiscalization-Timeout: 10 (seconds, default: 10)
  • If tax authority responds within timeout → status: "fiscalized"
  • If timeout expires → status: "pending_fiscalization" + background retry
  • Rush hour optimization: Set to 5s for faster fallback

2. Retrieve Transaction

GET /v1/transactions/{id}

Success Response (200 OK):

{
"id": "txn_8wR3tY6uI1oP",
"object": "transaction",
"status": "success",
"fiscal_data": {...},
"basket_receipt_url": "https://...",
"created_at": "2024-10-27T14:30:00Z"
}

Not Found (404):

{
"type": "https://docs.zyntem.dev/fiscalization/errors/resource_not_found",
"title": "Resource Not Found",
"status": 404,
"detail": "Transaction with ID 'txn_invalid' not found",
"request_id": "req_9zS4uB7wJ2qM"
}

3. List Transactions

GET /v1/transactions?location_id=loc_xxx&limit=50&starting_after=txn_xxx

Query Parameters:

  • location_id (optional): Filter by location
  • status (optional): success, failed, pending
  • limit (optional): 1-100, default 50
  • starting_after (optional): Cursor for pagination
  • ending_before (optional): Reverse pagination
  • created_after (optional): ISO 8601 timestamp
  • created_before (optional): ISO 8601 timestamp

Success Response (200 OK):

{
"object": "list",
"data": [
{
"id": "txn_8wR3tY6uI1oP",
"status": "success",
"fiscal_id": "TBAI-B12345674-2024-001",
"total_amount": 2.75,
"created_at": "2024-10-27T14:30:00Z"
}
],
"has_more": true,
"url": "/v1/transactions"
}

Pagination Link Header:

Link: <https://api.zyntem.dev/fiscalization/v1/transactions?starting_after=txn_8wR3tY6uI1oP&limit=50>; rel="next"

4. Create Location

POST /v1/locations

Request Body:

{
"name": "Madrid Store #1",
"country": "ES",
"legal_name": "Cafetería Example SL",
"tax_id": "B12345674",
"address": {
"street": "Calle Mayor 123",
"city": "Madrid",
"postal_code": "28013",
"region": "Madrid",
"country": "ES"
},
"country_config": {
"system": "ticketbai",
"province": "Bizkaia",
"lroe_enabled": true,
"software_nif": "B98765432",
"software_name": "FiscalAPI v1.0"
}
}

Success Response (201 Created):

{
"id": "loc_2Xk9pQ1mN4vL",
"object": "location",
"name": "Madrid Store #1",
"country": "ES",
"status": "active",
"certificate_id": null,
"certificate_expires_at": null,
"created_at": "2024-10-27T10:00:00Z"
}

5. Upload Certificate (Spain/Italy)

POST /v1/certificates

Request Headers:

Content-Type: multipart/form-data

Request Body (multipart/form-data):

location_id: loc_2Xk9pQ1mN4vL
type: ticketbai
certificate: [binary P12/PFX file]
passphrase: supersecret123

Success Response (201 Created):

{
"id": "cert_5tG8hK3jL9mN",
"object": "certificate",
"location_id": "loc_2Xk9pQ1mN4vL",
"type": "ticketbai",
"issuer_dn": "CN=AEAT,O=AEAT,C=ES",
"subject_dn": "CN=B12345674,O=Example SL,C=ES",
"serial_number": "1234567890ABCDEF",
"not_before": "2024-01-01T00:00:00Z",
"not_after": "2025-12-31T23:59:59Z",
"status": "active",
"created_at": "2024-10-27T10:15:00Z"
}

Certificate Validation Errors (400):

{
"type": "https://docs.zyntem.dev/fiscalization/errors/certificate_invalid",
"title": "Certificate Validation Failed",
"status": 400,
"detail": "Certificate has expired",
"errors": [
{
"field": "certificate",
"message": "Certificate expired on 2023-12-31",
"code": "certificate_expired"
}
]
}

6. Create Webhook

POST /v1/webhooks

Request Body:

{
"url": "https://example.com/webhooks/fiscalization",
"events": ["transaction.success", "transaction.failed"]
}

Success Response (201 Created):

{
"id": "hook_9pL2kR5nM8qT",
"object": "webhook",
"url": "https://example.com/webhooks/fiscalization",
"events": ["transaction.success", "transaction.failed"],
"secret": "whsec_4A3B5C9D2E8F1A0B...",
"status": "active",
"created_at": "2024-10-27T11:00:00Z"
}

Webhook Secret:

  • Used to generate HMAC-SHA256 signature
  • Format: whsec_{32-char-random}
  • Used to verify webhook authenticity

Error Response Format (RFC 7807)

All errors follow RFC 7807 Problem Details specification:

{
"type": "https://docs.zyntem.dev/fiscalization/errors/{error_type}",
"title": "Human-readable title",
"status": 400,
"detail": "Detailed explanation of what went wrong",
"errors": [
{
"field": "items[0].total_amount",
"message": "Field-specific error message",
"code": "error_code"
}
],
"request_id": "req_9zS4uB7wJ2qM"
}

Common Error Types:

TypeStatusDescription
validation_error400Request validation failed
authentication_error401Missing or invalid API key
authorization_error403Account suspended or key revoked
resource_not_found404Resource does not exist
rate_limit_exceeded429Too many requests
tax_authority_error502Tax authority returned error
internal_error500Unexpected server error

Tax Authority Errors (502 Bad Gateway):

{
"type": "https://docs.zyntem.dev/fiscalization/errors/tax_authority_error",
"title": "Tax Authority Error",
"status": 502,
"detail": "AEAT returned error: Invalid certificate signature",
"tax_authority_code": "TBAI-1001",
"tax_authority_message": "Firma digital inválida",
"translated_message": "Digital signature is invalid. Please verify certificate is valid and not expired.",
"resolution_steps": [
"Check certificate expiration date",
"Re-upload certificate if expired",
"Contact support if certificate is valid"
],
"request_id": "req_9zS4uB7wJ2qM"
}

Rate Limiting

Headers on Every Response:

X-RateLimit-Limit: 10000
X-RateLimit-Remaining: 9950
X-RateLimit-Reset: 1698413460

Rate Limit Exceeded (429):

{
"type": "https://docs.zyntem.dev/fiscalization/errors/rate_limit_exceeded",
"title": "Rate Limit Exceeded",
"status": 429,
"detail": "Rate limit of 10000 requests per minute exceeded",
"retry_after": 30,
"request_id": "req_9zS4uB7wJ2qM"
}

Retry-After Header:

Retry-After: 30

Idempotency

Idempotency-Key Header (UUID v4):

Idempotency-Key: a1b2c3d4-e5f6-7890-abcd-ef1234567890

Behavior:

  • Duplicate POST requests within 24 hours return cached response
  • Original response stored for 24 hours, then deleted
  • Only applies to POST /transactions (mutation endpoint)
  • Other endpoints (GET, POST /locations, etc.) naturally idempotent

Idempotent Replay Response:

  • Status code: 200 OK (not 201 Created)
  • Header: X-Idempotent-Replayed: true
  • Body: Identical to original response

Pagination

Cursor-Based Pagination:

  • Use starting_after cursor for forward pagination
  • Use ending_before cursor for reverse pagination
  • Max limit: 100 per page (default: 50)

Link Header (RFC 5988):

Link: <https://api.zyntem.dev/fiscalization/v1/transactions?starting_after=txn_xxx&limit=50>; rel="next",
<https://api.zyntem.dev/fiscalization/v1/transactions?ending_before=txn_yyy&limit=50>; rel="prev"

List Response Format:

{
"object": "list",
"data": [...],
"has_more": true,
"url": "/v1/transactions"
}

Webhooks

Webhook Events:

  • transaction.fiscalized: Transaction completed fiscalization (initially created as pending_fiscalization, now fiscalized)
  • transaction.failed: Transaction fiscalization permanently failed
  • certificate.expiring: Certificate expires in 30 days
  • certificate.expired: Certificate has expired

Note: transaction.fiscalized fires when:

  • Transaction initially created with status: "pending_fiscalization"
  • Background retry succeeds
  • Status changes to "fiscalized"
  • Use case: Email customer final receipt after tax authority comes back online

Webhook Payload:

{
"id": "evt_3fT8kL2mP9nQ",
"object": "event",
"type": "transaction.fiscalized",
"created": 1698413460,
"data": {
"object": {
"id": "txn_8wR3tY6uI1oP",
"status": "fiscalized",
"fiscal_id": "TBAI-B12345674-2024-001",
"fiscal_data": {...},
"basket_receipt_url": "https://...",
"fiscalized_at": "2024-10-27T14:35:00Z"
}
}
}

Signature Verification (HMAC-SHA256):

X-Fiscalization-Signature: t=1698413460,v1=4a3b5c9d2e8f1a0b...

Verification Steps:

  1. Extract timestamp t and signature v1
  2. Construct signed payload: {t}.{request_body}
  3. Compute HMAC-SHA256 with webhook secret
  4. Compare computed signature with v1
  5. Reject if timestamp > 5 minutes old (replay attack prevention)

Retry Logic:

  • Attempt 1: Immediate
  • Attempt 2: +1 minute
  • Attempt 3: +5 minutes
  • Attempt 4: +15 minutes
  • Attempt 5: +1 hour → Dead letter queue

Auto-Disable:

  • After 10 consecutive failures, webhook automatically disabled
  • Email alert sent to account owner
  • Re-enable via dashboard

OpenAPI 3.0.3 Specification

Structure:

openapi: 3.0.3
info:
title: Fiscalization API
version: 1.0.0
description: Country-agnostic fiscalization API for Europe

servers:
- url: https://api.zyntem.dev/fiscalization/v1
description: Production
- url: https://sandbox.zyntem.dev/fiscalization/v1
description: Sandbox

security:
- BearerAuth: []

paths:
/transactions:
post:
summary: Create transaction (fiscalize)
operationId: createTransaction
tags: [Transactions]
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/CreateTransactionRequest'
responses:
'201':
description: Transaction created
content:
application/json:
schema:
$ref: '#/components/schemas/Transaction'
'400':
$ref: '#/components/responses/ValidationError'
'401':
$ref: '#/components/responses/Unauthorized'
'429':
$ref: '#/components/responses/RateLimitExceeded'

components:
securitySchemes:
BearerAuth:
type: http
scheme: bearer
bearerFormat: API Key

schemas:
Transaction:
type: object
properties:
id:
type: string
example: "txn_8wR3tY6uI1oP"
status:
type: string
enum: [pending, processing, success, failed]
fiscal_data:
$ref: '#/components/schemas/FiscalData'

Error:
type: object
required: [type, title, status, detail]
properties:
type:
type: string
format: uri
title:
type: string
status:
type: integer
detail:
type: string
request_id:
type: string

SDK Generation:

  • Go: oapi-codegen (generates client + server stubs)
  • TypeScript: openapi-typescript-codegen
  • Python: openapi-generator-cli
  • Ruby, PHP, Java: openapi-generator-cli

Contract Testing:

  • Schemathesis: Automated API testing against OpenAPI spec
  • Ensures API implementation matches specification
  • Runs in CI/CD pipeline

Request/Response Examples

Full Transaction Creation Example:

Request:

curl -X POST https://api.zyntem.dev/fiscalization/v1/transactions \
-H "Authorization: Bearer fsk_live_7xK9pQ2mN4vL8wR3tY6uI1oP5sA0" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: a1b2c3d4-e5f6-7890-abcd-ef1234567890" \
-d '{
"location_id": "loc_2Xk9pQ1mN4vL",
"timestamp": "2024-10-27T14:30:00Z",
"items": [
{
"description": "Espresso",
"quantity": 1,
"unit_price": 2.50,
"tax_rate": 0.10,
"tax_amount": 0.25,
"total_amount": 2.75
}
],
"pretax_amount": 2.50,
"tax_amount": 0.25,
"total_amount": 2.75,
"currency": "EUR",
"payment_method": "card"
}'

Response Headers:

HTTP/1.1 201 Created
Content-Type: application/json
X-RateLimit-Limit: 10000
X-RateLimit-Remaining: 9950
X-RateLimit-Reset: 1698413460
X-Request-ID: req_9zS4uB7wJ2qM

Response Body:

{
"id": "txn_8wR3tY6uI1oP",
"object": "transaction",
"status": "success",
"fiscal_data": {
"fiscal_id": "TBAI-B12345674-2024-001",
"qr_code_data": "iVBORw0KGgo...",
"qr_code_url": "https://storage.googleapis.com/.../qr.png",
"required_text": ["TicketBAI", "Factura Simplificada", "TBAI-B12345674-2024-001"],
"spain": {
"tbai_identifier": "TBAI-B12345674-2024-001",
"tbai_signature": "4A3B5C9D..."
}
},
"basket_receipt_url": "https://storage.googleapis.com/.../basket-receipt.pdf",
"created_at": "2024-10-27T14:30:00Z"
}

Graceful Degradation & Business Continuity

Critical USP: Never Block the Cash Register

Fiscalization's graceful degradation ensures businesses continue operating even when tax authorities are offline or slow. This is a massive competitive advantage vs direct integration.

Problem with Direct Integration

Spain AEAT down
→ Transaction blocked
→ Cash register frozen
→ Customer waiting
→ Lost sale
→ Manual retry needed later

Fiscalization Solution

Spain AEAT down / Circuit breaker triggered
→ Generate temporary receipt
→ Return 201 Created immediately
→ Business continues
→ Auto-retry in background
→ Customer notified when complete
→ Zero manual intervention

Triggers for Graceful Degradation

1. Tax Authority Timeout

  • Spain AEAT doesn't respond within timeout (default: 10s)
  • Network issues, server overload

2. Circuit Breaker Triggered

  • Multiple consecutive failures detected (5 failures in 1 minute)
  • Circuit breaker opens to prevent cascading failures
  • Protects Fiscalization infrastructure from slowdown
  • Auto-closes after 60 seconds of no traffic

3. Tax Authority Returns 5xx Error

  • AEAT server error (500, 502, 503, 504)
  • Temporary outage, maintenance window

4. Connection Refused

  • Tax authority endpoint unreachable
  • DNS failure, firewall issues

Retry Queue Architecture

Components:

POST /transactions (AEAT timeout after 10s)

201 Created {status: "pending_fiscalization"}

Insert into retry_queue table

Background Worker (Cloud Function, every 1 min):
├─ Polls retry_queue WHERE next_retry_at <= NOW()
├─ Attempts fiscalization with tax authority
├─ Exponential backoff: 1min, 5min, 15min, 1hr, 4hr, 24hr
└─ Max attempts: 50 (covers ~7 days)

Tax authority comes back online

Fiscalization succeeds

Update transaction: status → "fiscalized"
Generate fiscal_data (QR code, signatures)

Fire webhook: transaction.fiscalized

Email customer: "Your receipt is ready"

Database Schema:

CREATE TABLE retry_queue (
transaction_id UUID PRIMARY KEY REFERENCES transactions(id),
attempt_count INT DEFAULT 0,
max_attempts INT DEFAULT 50,
next_retry_at TIMESTAMP NOT NULL,
last_error TEXT,
last_attempt_at TIMESTAMP,
created_at TIMESTAMP DEFAULT NOW(),
INDEX idx_next_retry (next_retry_at) WHERE next_retry_at IS NOT NULL
);

Retry Logic:

func calculateNextRetry(attemptCount int) time.Duration {
backoffs := []time.Duration{
1 * time.Minute, // Attempt 1
5 * time.Minute, // Attempt 2
15 * time.Minute, // Attempt 3
1 * time.Hour, // Attempt 4
4 * time.Hour, // Attempt 5-10
24 * time.Hour, // Attempt 11+
}

if attemptCount < len(backoffs) {
return backoffs[attemptCount]
}
return 24 * time.Hour // Daily retries after initial attempts
}

Retry Worker (Cloud Function):

func RetryWorker(ctx context.Context) error {
// Run every 1 minute
pending := db.Query(`
SELECT transaction_id
FROM retry_queue
WHERE next_retry_at <= NOW()
AND attempt_count < max_attempts
LIMIT 100
`)

for _, tx := range pending {
// Attempt fiscalization
result, err := adapterClient.Fiscalize(tx)

if err == nil {
// Success!
tx.Status = "fiscalized"
tx.FiscalData = result.FiscalData
db.Update(tx)
db.Delete(retryQueue, tx.ID)
webhooks.Fire("transaction.fiscalized", tx)
email.SendFinalReceipt(tx)
} else {
// Still failing, schedule next retry
retryQueue.IncrementAttempt(tx.ID)
nextRetry := calculateNextRetry(retryQueue.AttemptCount)
retryQueue.SetNextRetry(tx.ID, time.Now().Add(nextRetry))
}
}
}

Temporary Receipt Format

When status: "pending_fiscalization", basket receipt includes:

╔════════════════════════════════╗
║ CAFETERIA EXAMPLE ║
╚════════════════════════════════╝

⚠️ PENDING FISCALIZATION
Spain tax authorities temporarily unavailable.
Transaction will be fiscalized automatically.

Date: 27/10/2024 14:30
Transaction ID: txn_8wR3tY6uI1oP

Items:
----------------------------------
Espresso 1x €2.50 €2.75
----------------------------------
TOTAL €2.75

TEMPORARY RECEIPT
----------------------------------
This receipt is valid for purchase.
Final fiscal receipt will be available at:

[QR CODE → zyntem.dev/receipts/txn_xxx]

Or check status:
https://zyntem.dev/receipts/txn_8wR3tY6uI1oP

You will receive an email when fiscalization
is complete (typically within 5-30 minutes).

⚠️ TEMPORARY - AWAITING FISCALIZATION
----------------------------------

Generated by Fiscalization

For Cash Register Printing (ESC/POS):

Customer receives temporary receipt with:
✅ Proof of purchase
✅ All transaction details
✅ Status page URL (QR code)
✅ Clear message: "Temporary - awaiting fiscalization"
❌ No tax authority QR code (not yet available)

Status Page (zyntem.dev/receipts/{id})

Pending State:

╔════════════════════════════════╗
║ Fiscalization Receipt Status ║
╚════════════════════════════════╝

Transaction: txn_8wR3tY6uI1oP
Date: 27 Oct 2024, 14:30
Status: ⏳ Pending Fiscalization

Spain tax authorities are temporarily unavailable.
Your transaction will be fiscalized automatically.

Retry Progress:
├─ Attempt 1: Failed (timeout)
├─ Attempt 2: Scheduled for 14:35
└─ Estimated completion: 14:40

✅ Your purchase is valid
✅ Temporary receipt available below
✅ We will email final receipt to: customer@example.com

[Download Temporary Receipt PDF]

Items purchased:
- Espresso: €2.75
TOTAL: €2.75

Real-time updates:
This page auto-refreshes every 30 seconds.

Fiscalized State (After Background Retry Succeeds):

╔════════════════════════════════╗
║ Fiscalization Receipt Status ║
╚════════════════════════════════╝

Transaction: txn_8wR3tY6uI1oP
Date: 27 Oct 2024, 14:30
Status: ✅ Fiscalized

Fiscal ID: TBAI-B12345674-2024-001
Fiscalized at: 14:35 (5 minutes after purchase)

[Download Final Fiscal Receipt PDF]
[View QR Code - Full Size]

Verify with tax authorities:
https://batuz.eus/QRTBAI/?id=TBAI-B12345674-2024-001

✅ Official fiscal receipt
✅ Tax authority confirmed
✅ Compliant with Spanish regulations

Cash Register Integration Patterns

Pattern A: Wait for Final Receipt (Low Volume)

response := client.CreateTransaction(request)

if response.Status == "fiscalized" {
// Print compliant receipt with real QR code
printer.Print(response.FiscalData.QRCodeData)
} else if response.Status == "pending_fiscalization" {
// Print temporary receipt
printer.PrintTemporary(response.StatusPageURL)
// Optionally: Poll for 10 seconds
for i := 0; i < 10; i++ {
time.Sleep(1 * time.Second)
updated := client.GetTransaction(response.ID)
if updated.Status == "fiscalized" {
printer.Print(updated.FiscalData.QRCodeData)
break
}
}
}

Pattern B: Rush Hour Mode (High Volume)

// Set aggressive timeout
request.SetTimeout(5) // 5 seconds

response := client.CreateTransaction(request)

if response.Status == "fiscalized" {
printer.Print(response.FiscalData.QRCodeData)
} else {
// Don't wait, print temporary immediately
printer.PrintTemporary(response.StatusPageURL)
// Customer notified via email when ready
}

Competitive Advantage

vs Direct Integration:

FeatureDirect AEAT IntegrationFiscalization
AEAT Down❌ Transactions blocked✅ Business continues
Circuit Breaker❌ No protection✅ Automatic fallback
Customer UX❌ "Error, try again"✅ Temporary receipt + auto-retry
Lost Sales❌ Customers leave✅ Zero downtime
Manual Work❌ Retry all failed txs manually✅ Automatic background retry
Receipt❌ No receipt✅ Always get basket receipt
Status Tracking❌ No visibility✅ Status page + webhooks
Retry Logic❌ Build yourself✅ Built-in, battle-tested

Marketing Copy:

Never lose a sale due to tax authority downtime.

Fiscalization's graceful degradation ensures your business keeps running even when Spain's AEAT is offline. Transactions are automatically fiscalized in the background with zero manual intervention.

Our circuit breaker protects your infrastructure while our retry queue ensures 100% fiscalization without blocking your cash registers.

Direct integration = blocked transactions. Fiscalization = business continuity.


Performance Metrics

Normal Operation (95% of requests):

  • Time to fiscalization: 2-5 seconds
  • Status: fiscalized immediately
  • Customer experience: Normal receipt

Degraded Operation (4% of requests):

  • Time to temporary receipt: <1 second
  • Status: pending_fiscalization
  • Background retry: 1-30 minutes
  • Customer experience: Temporary receipt + email when ready

Complete Outage (1% of requests, AEAT down >24 hours):

  • Temporary receipts: 100% generated
  • Retry attempts: Up to 50 over 7 days
  • Manual intervention: Only if permanent validation error

Service Level Agreement:

  • Zero blocked transactions: 100%
  • Eventual fiscalization: 99.9% (assuming tax authority eventually recovers)
  • Temporary receipt generation: <1 second

Components

This section details the internal component architecture for all services in the Fiscalization platform. Each component's responsibilities, dependencies, and interaction patterns are documented to guide implementation.

Core API Components

The Core API (apps/core-api) orchestrates all fiscalization requests, manages multi-tenant data, and routes transactions to country-specific adapters.

Directory Structure

apps/core-api/
├── cmd/server/
│ └── main.go # HTTP server entry point, dependency injection
├── internal/
│ ├── handlers/ # HTTP request handlers (Gin handlers)
│ │ ├── transactions.go # POST/GET /v1/transactions
│ │ ├── locations.go # CRUD /v1/locations
│ │ ├── api_keys.go # POST /v1/api-keys (account creation)
│ │ ├── webhooks.go # CRUD /v1/webhooks
│ │ ├── health.go # GET /health, /ready
│ │ └── errors.go # Error response formatting (RFC 7807)
│ │
│ ├── services/ # Business logic layer
│ │ ├── transaction_service.go # Transaction orchestration
│ │ ├── adapter_router.go # Country adapter routing logic
│ │ ├── location_service.go # Location management
│ │ ├── webhook_service.go # Webhook delivery + retries
│ │ ├── retry_service.go # Background retry queue processor
│ │ └── idempotency_service.go # Idempotency key management
│ │
│ ├── adapters/ # Adapter HTTP clients
│ │ ├── client.go # Base HTTP client (with circuit breaker)
│ │ ├── spain_client.go # Spain adapter HTTP client
│ │ ├── italy_client.go # Italy adapter HTTP client
│ │ └── france_client.go # France adapter HTTP client
│ │
│ ├── middleware/ # Gin middleware
│ │ ├── auth.go # API key authentication
│ │ ├── rate_limit.go # Token bucket rate limiting (Redis-backed)
│ │ ├── idempotency.go # Idempotency-Key header handling
│ │ ├── logging.go # Structured request/response logging
│ │ ├── cors.go # CORS configuration
│ │ ├── recovery.go # Panic recovery
│ │ └── tracing.go # OpenTelemetry trace context injection
│ │
│ ├── repository/ # Data access layer (PostgreSQL)
│ │ ├── account_repo.go # Account CRUD
│ │ ├── location_repo.go # Location CRUD
│ │ ├── transaction_repo.go # Transaction CRUD + queries
│ │ ├── webhook_repo.go # Webhook CRUD
│ │ ├── retry_queue_repo.go # Retry queue operations
│ │ └── idempotency_repo.go # Idempotency key storage (24-hour TTL)
│ │
│ └── config/
│ └── config.go # Configuration loading (env vars + Secret Manager)

├── pkg/ # Public packages (can be imported externally)
│ ├── validation/ # Request validation helpers
│ └── errors/ # Error types + codes

├── go.mod # Dependencies
├── Dockerfile # Multi-stage build
└── .dockerignore

Component Responsibilities

1. Handlers (internal/handlers/)

Purpose: HTTP request parsing, validation, response formatting.

Key Components:

  • TransactionHandler: Handles POST /v1/transactions (submit), GET /v1/transactions/:id (retrieve), GET /v1/transactions (list with pagination)
  • LocationHandler: CRUD operations for locations, certificate upload validation
  • APIKeyHandler: Account creation + API key generation
  • WebhookHandler: Webhook endpoint configuration

Responsibilities:

  • Parse HTTP requests (JSON body, query params, headers)
  • Validate request schemas (using validator library)
  • Call service layer for business logic
  • Format responses (JSON, RFC 7807 errors)
  • Set HTTP status codes and headers

Dependencies: Services layer, validator

Example Interaction:

// handlers/transactions.go
func (h *TransactionHandler) SubmitTransaction(c *gin.Context) {
var req SubmitTransactionRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(400, formatValidationError(err))
return
}

accountID := c.GetString("account_id") // From auth middleware
idempotencyKey := c.GetHeader("Idempotency-Key")
timeout := parseTimeout(c.GetHeader("X-Fiscalization-Timeout"), 10) // Default 10s

result, err := h.transactionService.Submit(c.Request.Context(), accountID, req, idempotencyKey, timeout)
if err != nil {
c.JSON(statusCode(err), formatError(err))
return
}

c.JSON(201, result) // Always 201 Created
}

2. Services (internal/services/)

Purpose: Business logic, orchestration, adapter coordination.

Key Components:

TransactionService

  • Responsibilities:
    • Validate location exists and belongs to account
    • Check idempotency (return cached result if duplicate)
    • Determine country adapter from location
    • Call adapter with timeout (context.WithTimeout)
    • Handle timeout → trigger graceful degradation (generate basket receipt, queue for retry)
    • Handle success → store fiscal_data, generate basket receipt, return status="fiscalized"
    • Store transaction in PostgreSQL
  • Dependencies: AdapterRouter, LocationService, IdempotencyService, RetryService, TransactionRepository

AdapterRouter

  • Responsibilities:
    • Route to correct country adapter client based on location.country
    • Apply circuit breaker pattern (gobreaker library)
    • Circuit breaker state: Closed (normal), Open (5 consecutive failures), Half-Open (testing recovery)
    • Circuit breaker open → trigger graceful degradation immediately
  • Dependencies: Spain/Italy/France adapter clients, circuit breaker instances

RetryService

  • Responsibilities:
    • Insert transaction into retry_queue table
    • Background worker (runs every 1 minute via Cloud Function or goroutine)
    • Query retry_queue WHERE next_retry_at <= NOW()
    • Attempt fiscalization via AdapterRouter
    • On success: Update transaction status to "fiscalized", trigger webhook, send email
    • On failure: Increment attempt_count, calculate next_retry_at (exponential backoff), store error
    • After 50 attempts: Mark as "failed", alert operations team
  • Dependencies: AdapterRouter, TransactionRepository, RetryQueueRepository, WebhookService

WebhookService

  • Responsibilities:
    • Trigger webhook deliveries for events (transaction.fiscalized, transaction.failed)
    • Generate HMAC-SHA256 signature
    • Retry failed deliveries (exponential backoff: 1min, 5min, 15min, 1hr)
    • Store delivery logs in webhook_deliveries table
  • Dependencies: WebhookRepository, HTTP client

IdempotencyService

  • Responsibilities:
    • Check if Idempotency-Key exists in Redis/PostgreSQL (24-hour TTL)
    • If exists: Return cached transaction result
    • If not: Store key + transaction_id mapping
  • Dependencies: IdempotencyRepository (Redis or PostgreSQL)

3. Middleware (internal/middleware/)

Purpose: Cross-cutting concerns applied to all requests.

Middleware Chain (order matters):

// cmd/server/main.go
router := gin.New()
router.Use(
middleware.Recovery(), // 1. Panic recovery (topmost)
middleware.Logging(), // 2. Request/response logging
middleware.Tracing(), // 3. OpenTelemetry trace injection
middleware.CORS(), // 4. CORS headers
middleware.Auth(), // 5. API key authentication (extracts account_id)
middleware.RateLimit(), // 6. Token bucket rate limiting
middleware.Idempotency(), // 7. Idempotency-Key caching
)

Key Middleware:

auth.go

  • Extract Authorization: Bearer fsk_live_xxx header
  • Hash API key, lookup in accounts table
  • Verify account.status = "active"
  • Set account_id in Gin context for handlers
  • Return 401 if invalid/missing

rate_limit.go

  • Load account.rate_limit (e.g., 1000 req/min)
  • Token bucket algorithm (Redis-backed for distributed rate limiting)
  • Burst capacity: 2× rate_limit
  • Set headers: X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset
  • Return 429 if exceeded

idempotency.go

  • Check Idempotency-Key header
  • If present: Call IdempotencyService to check for cached result
  • If cached result exists: Return immediately (skip handler)
  • If new: Continue to handler, store result after completion

4. Adapters (internal/adapters/)

Purpose: HTTP clients for country adapter services.

Base Client (client.go):

type AdapterClient struct {
httpClient *http.Client
circuitBreaker *gobreaker.CircuitBreaker
baseURL string
timeout time.Duration
}

func (c *AdapterClient) SubmitTransaction(ctx context.Context, req *types.Transaction) (*types.FiscalReceipt, error) {
result, err := c.circuitBreaker.Execute(func() (interface{}, error) {
return c.doRequest(ctx, "POST", "/fiscalize", req)
})

if err == gobreaker.ErrOpenState {
return nil, ErrCircuitBreakerOpen // Triggers graceful degradation
}

return result.(*types.FiscalReceipt), err
}

Circuit Breaker Configuration:

  • MaxRequests: 3 (half-open state)
  • Interval: 60 seconds (resets failure count)
  • Timeout: 30 seconds (circuit breaker timeout)
  • ReadyToTrip: 5 consecutive failures → Open state
  • OnStateChange: Log state transitions, emit metrics

5. Repository (internal/repository/)

Purpose: Data access abstraction, SQL query encapsulation.

Database Connection:

  • PostgreSQL client: pgx (performance-optimized)
  • Connection pool: 15 connections per service (configurable)
  • Query timeout: 5 seconds default
  • Row-level security: All queries filter by account_id for multi-tenancy

Example Repository:

// repository/transaction_repo.go
type TransactionRepository struct {
db *pgxpool.Pool
}

func (r *TransactionRepository) Create(ctx context.Context, tx *types.Transaction) error {
query := `
INSERT INTO transactions (id, account_id, location_id, timestamp, items,
pretax_amount, tax_amount, total_amount, status,
fiscal_data, basket_receipt_url, created_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, NOW())
`
_, err := r.db.Exec(ctx, query, tx.ID, tx.AccountID, tx.LocationID, tx.Timestamp,
tx.Items, tx.PretaxAmount, tx.TaxAmount, tx.TotalAmount, tx.Status,
tx.FiscalData, tx.BasketReceiptURL)
return err
}

func (r *TransactionRepository) GetByID(ctx context.Context, accountID, txID string) (*types.Transaction, error) {
query := `
SELECT id, account_id, location_id, timestamp, items, pretax_amount, tax_amount,
total_amount, status, fiscal_data, basket_receipt_url, created_at, updated_at
FROM transactions
WHERE id = $1 AND account_id = $2 -- Row-level security
`
var tx types.Transaction
err := r.db.QueryRow(ctx, query, txID, accountID).Scan(/* ... */)
if err == pgx.ErrNoRows {
return nil, ErrNotFound
}
return &tx, err
}

Spain Adapter Components

The Spain adapter (apps/adapter-spain) handles TicketBAI and Verifactu fiscalization systems.

Directory Structure

apps/adapter-spain/
├── cmd/server/
│ └── main.go # HTTP server entry point
├── internal/
│ ├── handlers/
│ │ └── fiscalize.go # POST /fiscalize (unified endpoint)
│ │
│ ├── ticketbai/ # TicketBAI implementation
│ │ ├── xml_generator.go # Generate TicketBAI XML (TBAI schema)
│ │ ├── signer.go # XML signing with X.509 certificate
│ │ ├── qr_generator.go # TicketBAI QR code generation
│ │ ├── lroe_client.go # HTTP client for LROE submission
│ │ └── lroe_batcher.go # Batch transactions for LROE (4-day window)
│ │
│ ├── verifactu/ # Verifactu implementation
│ │ ├── xml_generator.go # Generate Verifactu XML (different schema)
│ │ ├── signer.go # Digital signature (SHA-256)
│ │ ├── qr_generator.go # Verifactu QR code format
│ │ └── aeat_client.go # HTTP client for AEAT Verifactu API
│ │
│ ├── certificates/ # Certificate management
│ │ ├── loader.go # Load X.509 certs from Secret Manager
│ │ ├── validator.go # Validate cert expiry, format
│ │ └── cache.go # In-memory cert caching (refresh every 1 hour)
│ │
│ ├── services/
│ │ ├── fiscalization_service.go # Route to TicketBAI or Verifactu
│ │ └── receipt_service.go # Generate basket receipt PDF
│ │
│ └── repository/
│ └── transaction_repo.go # Store adapter-specific metadata

├── go.mod
├── Dockerfile
└── .dockerignore

Component Responsibilities

1. Fiscalization Service (internal/services/fiscalization_service.go)

Responsibilities:

  • Read location.fiscalization_system ("ticketbai" or "verifactu")
  • Route to appropriate implementation
  • Handle tax authority errors → translate to English
  • Generate fiscal receipt (PDF with QR code)
  • Upload receipt to Cloud Storage
  • Return FiscalReceipt object

Logic Flow:

func (s *FiscalizationService) Fiscalize(ctx context.Context, tx *types.Transaction, location *types.Location) (*types.FiscalReceipt, error) {
switch location.FiscalizationSystem {
case "ticketbai":
return s.fiscalizeTicketBAI(ctx, tx, location)
case "verifactu":
return s.fiscalizeVerifactu(ctx, tx, location)
default:
return nil, ErrInvalidFiscalizationSystem
}
}

2. TicketBAI Components (internal/ticketbai/)

xml_generator.go

  • Generate XML compliant with TicketBAI schema (TBAI version 1.2)
  • Map transaction items to XML structure
  • Include required fields: NIF, date, amount, VAT breakdown

signer.go

  • Load X.509 certificate from CertificateLoader
  • Sign XML using XMLDSig (SHA-256 + RSA)
  • Embed signature in XML

qr_generator.go

  • Generate QR code data string: TBAI:{nif}:{date}:{amount}:{signature_hash}
  • Encode as base64 PNG (200x200px)
  • Upload to Cloud Storage, return URL

lroe_client.go

  • HTTP client for LROE API (Basque Country tax authority)
  • Submit signed TicketBAI XML
  • Handle responses: Success (fiscal_id returned), Error (translate code to English)

lroe_batcher.go

  • Queue transactions for batch submission (4-day window requirement)
  • Store in lroe_queue table (PostgreSQL)
  • Background worker batches transactions by location_id
  • Submit batch to LROE API

3. Verifactu Components (internal/verifactu/)

Similar structure to TicketBAI, but:

  • Different XML schema (Verifactu format)
  • Different QR code format: https://aeat.es/verifactu?id={fiscal_id}
  • Synchronous submission to AEAT (no batching)
  • Digital signature using HMAC-SHA256 (not X.509)

4. Certificate Management (internal/certificates/)

loader.go

type CertificateLoader struct {
secretManagerClient *secretmanager.Client
cache map[string]*x509.Certificate // locationID → cert
cacheMutex sync.RWMutex
}

func (l *CertificateLoader) LoadCertificate(ctx context.Context, locationID string) (*x509.Certificate, error) {
l.cacheMutex.RLock()
if cert, exists := l.cache[locationID]; exists {
l.cacheMutex.RUnlock()
return cert, nil
}
l.cacheMutex.RUnlock()

// Load from Secret Manager
secretName := fmt.Sprintf("projects/{project}/secrets/ticketbai-cert-%s/versions/latest", locationID)
result, err := l.secretManagerClient.AccessSecretVersion(ctx, &secretmanagerpb.AccessSecretVersionRequest{
Name: secretName,
})
if err != nil {
return nil, err
}

cert, err := x509.ParseCertificate(result.Payload.Data)
if err != nil {
return nil, err
}

// Cache for 1 hour
l.cacheMutex.Lock()
l.cache[locationID] = cert
l.cacheMutex.Unlock()

return cert, nil
}

Italy Adapter Components

The Italy adapter (apps/adapter-italy) handles SDI (Sistema di Interscambio) fiscalization.

Directory Structure

apps/adapter-italy/
├── cmd/server/
│ └── main.go
├── internal/
│ ├── handlers/
│ │ └── fiscalize.go
│ │
│ ├── sdi/ # SDI implementation
│ │ ├── xml_generator.go # Generate RT (Ricevuta Telematica) XML
│ │ ├── signer.go # P7M digital signature
│ │ ├── qr_generator.go # Italian QR code format
│ │ └── sdi_client.go # HTTP client for AdE SDI API
│ │
│ ├── signatures/ # Digital signature management
│ │ ├── p7m_signer.go # P7M format signing
│ │ └── cert_loader.go # Load signing certificates
│ │
│ ├── services/
│ │ ├── fiscalization_service.go
│ │ └── receipt_service.go
│ │
│ └── repository/
│ └── transaction_repo.go

├── go.mod
└── Dockerfile

Key Differences from Spain

1. XML Format: RT (Ricevuta Telematica) schema instead of TicketBAI 2. Submission: Asynchronous (SDI processes over hours/days), requires webhook for final status 3. Digital Signature: P7M format (PKCS#7) instead of XMLDSig 4. QR Code: Links to AdE verification portal


France Adapter Components

The France adapter (apps/adapter-france) handles NF525 compliance (Loi Anti-Fraude).

Directory Structure

apps/adapter-france/
├── cmd/server/
│ └── main.go
├── internal/
│ ├── handlers/
│ │ └── fiscalize.go
│ │
│ ├── nf525/ # NF525 compliance
│ │ ├── sequence_manager.go # Sequential numbering per location
│ │ ├── hash_chain.go # SHA-256 hash chain validation
│ │ └── receipt_generator.go # NF525-compliant receipt format
│ │
│ ├── hash_chain/ # Hash chain implementation
│ │ ├── hasher.go # SHA-256 hashing logic
│ │ └── validator.go # Validate chain integrity
│ │
│ ├── services/
│ │ ├── fiscalization_service.go
│ │ └── receipt_service.go
│ │
│ └── repository/
│ ├── transaction_repo.go
│ └── sequence_repo.go # Store sequence numbers + hashes

├── go.mod
└── Dockerfile

Key Characteristics

1. Local Logging: No external tax authority API (NF525 is local compliance) 2. Sequential Numbering: Transactions must have sequential numbers per location (no gaps) 3. Hash Chain: Each transaction includes hash of previous transaction (tamper-proof audit trail) 4. Certificate: NF525 certificate stored but used for local validation, not API submission

Sequence Manager Logic:

func (s *SequenceManager) GetNextSequence(ctx context.Context, locationID string) (int64, string, error) {
// 1. Get last transaction for location
lastTx, err := s.repo.GetLastTransaction(ctx, locationID)
if err != nil {
return 1, "", nil // First transaction
}

// 2. Increment sequence
nextSeq := lastTx.SequenceNumber + 1

// 3. Return sequence + previous hash for chaining
return nextSeq, lastTx.Hash, nil
}

Dashboard Components

The Dashboard (apps/dashboard) is a Next.js 14 application with App Router.

Directory Structure

apps/dashboard/
├── app/ # App Router pages
│ ├── layout.tsx # Root layout (providers, auth check)
│ ├── page.tsx # Landing page (marketing)
│ │
│ ├── (auth)/ # Auth route group (layout without sidebar)
│ │ ├── login/
│ │ │ └── page.tsx # Login form (NextAuth.js)
│ │ ├── signup/
│ │ │ └── page.tsx # Signup form (email + CAPTCHA)
│ │ └── layout.tsx # Auth layout (centered form)
│ │
│ ├── dashboard/ # Protected dashboard routes
│ │ ├── layout.tsx # Dashboard layout (sidebar, header)
│ │ ├── page.tsx # Home (metrics overview)
│ │ │
│ │ ├── transactions/
│ │ │ ├── page.tsx # Transaction list (table, filters)
│ │ │ └── [id]/
│ │ │ └── page.tsx # Transaction detail (full JSON, logs)
│ │ │
│ │ ├── locations/
│ │ │ ├── page.tsx # Location list (cards)
│ │ │ ├── new/
│ │ │ │ └── page.tsx # Create location form
│ │ │ └── [id]/
│ │ │ ├── page.tsx # Location detail (edit, cert upload)
│ │ │ └── certificates/
│ │ │ └── page.tsx # Certificate management
│ │ │
│ │ ├── api-keys/
│ │ │ └── page.tsx # API key management (generate, revoke)
│ │ │
│ │ └── settings/
│ │ ├── page.tsx # Account settings
│ │ ├── webhooks/
│ │ │ └── page.tsx # Webhook configuration
│ │ └── billing/
│ │ └── page.tsx # Stripe billing portal
│ │
│ ├── lookup/ # Mobile transaction lookup (public)
│ │ └── page.tsx # Search by transaction ID
│ │
│ └── api/ # API routes (Next.js API routes)
│ ├── auth/
│ │ └── [...nextauth]/
│ │ └── route.ts # NextAuth.js configuration
│ └── webhooks/
│ └── stripe/
│ └── route.ts # Stripe webhook handler

├── components/ # React components
│ ├── ui/ # Shadcn/ui components (Button, Card, Table, etc.)
│ │ ├── button.tsx
│ │ ├── card.tsx
│ │ ├── table.tsx
│ │ └── ...
│ │
│ ├── forms/ # Form components
│ │ ├── location-form.tsx # Location creation/edit form
│ │ ├── certificate-upload.tsx # Certificate file upload
│ │ └── webhook-form.tsx # Webhook configuration form
│ │
│ ├── charts/ # Recharts visualizations
│ │ ├── transaction-volume.tsx # Line chart (7-day volume)
│ │ └── country-breakdown.tsx # Pie chart (by country)
│ │
│ ├── layout/ # Layout components
│ │ ├── sidebar.tsx # Dashboard sidebar navigation
│ │ ├── header.tsx # Dashboard header (user menu)
│ │ └── footer.tsx # Footer
│ │
│ └── shared/ # Shared utility components
│ ├── loading-spinner.tsx
│ ├── error-boundary.tsx
│ └── pagination.tsx

├── lib/ # Utilities
│ ├── api-client.ts # Core API HTTP client
│ ├── auth.ts # NextAuth.js configuration
│ ├── utils.ts # General utilities (cn, formatters)
│ └── hooks/ # Custom React hooks
│ ├── use-transactions.ts # React Query hook for transactions
│ ├── use-locations.ts # React Query hook for locations
│ └── use-api-keys.ts # React Query hook for API keys

├── stores/ # Zustand state management
│ ├── auth-store.ts # Auth state (user, session)
│ └── ui-store.ts # UI state (sidebar open, theme)

├── styles/
│ └── globals.css # Global styles (Tailwind base)

├── package.json
├── next.config.js # Next.js configuration
├── tailwind.config.js # Tailwind CSS configuration
└── tsconfig.json # TypeScript configuration

Component Responsibilities

1. API Client (lib/api-client.ts)

Purpose: Centralized HTTP client for Core API requests.

// lib/api-client.ts
import { QueryClient } from '@tanstack/react-query';

export class APIClient {
private baseURL: string;
private getSession: () => Promise<Session | null>;

constructor(baseURL: string, getSession: () => Promise<Session | null>) {
this.baseURL = baseURL;
this.getSession = getSession;
}

private async request<T>(
method: string,
path: string,
body?: any,
headers?: Record<string, string>
): Promise<T> {
const session = await this.getSession();
if (!session) throw new Error('Unauthorized');

const response = await fetch(`${this.baseURL}${path}`, {
method,
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${session.apiKey}`,
...headers,
},
body: body ? JSON.stringify(body) : undefined,
});

if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || 'Request failed');
}

return response.json();
}

// Transaction methods
async submitTransaction(data: SubmitTransactionRequest, idempotencyKey?: string) {
return this.request<Transaction>('POST', '/v1/transactions', data, {
'Idempotency-Key': idempotencyKey || crypto.randomUUID(),
});
}

async getTransaction(id: string) {
return this.request<Transaction>('GET', `/v1/transactions/${id}`);
}

async listTransactions(params?: ListParams) {
const query = new URLSearchParams(params as any).toString();
return this.request<TransactionList>('GET', `/v1/transactions?${query}`);
}

// Location methods
async createLocation(data: CreateLocationRequest) {
return this.request<Location>('POST', '/v1/locations', data);
}

// ... other methods
}

2. React Query Hooks (lib/hooks/)

Purpose: Data fetching with caching, pagination, refetching.

// lib/hooks/use-transactions.ts
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useAPIClient } from './use-api-client';

export function useTransactions(params?: ListParams) {
const apiClient = useAPIClient();

return useQuery({
queryKey: ['transactions', params],
queryFn: () => apiClient.listTransactions(params),
refetchInterval: 10000, // Auto-refresh every 10 seconds
});
}

export function useTransaction(id: string) {
const apiClient = useAPIClient();

return useQuery({
queryKey: ['transactions', id],
queryFn: () => apiClient.getTransaction(id),
refetchInterval: 10000,
});
}

export function useSubmitTransaction() {
const apiClient = useAPIClient();
const queryClient = useQueryClient();

return useMutation({
mutationFn: (data: SubmitTransactionRequest) => apiClient.submitTransaction(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['transactions'] });
},
});
}

3. NextAuth.js Configuration (lib/auth.ts)

Purpose: Authentication provider configuration.

// lib/auth.ts
import NextAuth from 'next-auth';
import CredentialsProvider from 'next-auth/providers/credentials';
import GoogleProvider from 'next-auth/providers/google';
import GitHubProvider from 'next-auth/providers/github';

export const { handlers, auth, signIn, signOut } = NextAuth({
providers: [
CredentialsProvider({
name: 'Email',
credentials: {
email: { label: 'Email', type: 'email' },
password: { label: 'Password', type: 'password' },
},
async authorize(credentials) {
// Call Core API to create account + API key
const response = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/v1/accounts`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
email: credentials.email,
password: credentials.password,
}),
});

if (!response.ok) return null;

const { account, api_key } = await response.json();
return {
id: account.id,
email: account.billing_email,
apiKey: api_key, // Store API key in session
};
},
}),
GoogleProvider({
clientId: process.env.GOOGLE_CLIENT_ID!,
clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
}),
GitHubProvider({
clientId: process.env.GITHUB_CLIENT_ID!,
clientSecret: process.env.GITHUB_CLIENT_SECRET!,
}),
],
callbacks: {
async jwt({ token, user }) {
if (user) {
token.apiKey = user.apiKey;
}
return token;
},
async session({ session, token }) {
session.apiKey = token.apiKey;
return session;
},
},
});

Shared Components

Shared components (packages/) are reusable across services.

Directory Structure

packages/
├── types/ # Shared Go types
│ ├── domain/
│ │ ├── account.go # Account struct
│ │ ├── location.go # Location struct
│ │ ├── transaction.go # Transaction struct
│ │ ├── fiscal_receipt.go # FiscalReceipt struct
│ │ └── webhook.go # Webhook struct
│ │
│ ├── interfaces/
│ │ ├── country_adapter.go # CountryAdapter interface
│ │ └── repository.go # Repository interfaces
│ │
│ └── go.mod # fiscalization-types module

├── shared/ # Shared utilities
│ ├── go/
│ │ ├── qrcode/
│ │ │ └── generator.go # QR code generation (PNG, base64)
│ │ ├── errors/
│ │ │ ├── translator.go # Error translation (static + AI)
│ │ │ └── codes.go # Error code constants
│ │ └── validation/
│ │ └── tax_id.go # Tax ID validation (NIF, CIF, Partita IVA, SIRET)
│ │
│ └── typescript/
│ ├── format/
│ │ ├── currency.ts # Currency formatting (Euro)
│ │ └── date.ts # Date formatting (locales)
│ └── constants/
│ └── countries.ts # Country codes, error codes

├── ui/ # Shared React components
│ ├── src/
│ │ ├── components/
│ │ │ ├── certificate-upload.tsx
│ │ │ ├── country-panel.tsx
│ │ │ └── transaction-timeline.tsx
│ │ └── index.ts
│ └── package.json

└── config/ # Shared configuration
├── eslint-config/
│ └── index.js # ESLint shared config
├── typescript-config/
│ └── tsconfig.json # TypeScript base config
└── tailwind-config/
└── tailwind.config.js # Tailwind base config

Key Shared Components

1. CountryAdapter Interface (packages/types/interfaces/country_adapter.go)

Purpose: Define contract for all country adapters.

// packages/types/interfaces/country_adapter.go
package interfaces

import (
"context"
"github.com/zyntem/fiscalization-types/domain"
)

// CountryAdapter defines the interface that all country adapters must implement
type CountryAdapter interface {
// SubmitTransaction fiscalizes a transaction and returns a fiscal receipt
SubmitTransaction(ctx context.Context, tx *domain.Transaction, location *domain.Location) (*domain.FiscalReceipt, error)

// ValidateLocation validates location configuration (certificates, tax IDs, etc.)
ValidateLocation(location *domain.Location) error

// GenerateQRCode generates country-specific QR code
GenerateQRCode(receipt *domain.FiscalReceipt) (string, error)

// TranslateError translates tax authority errors to English
TranslateError(err error) (string, error)

// RefreshCertificates reloads certificates from Secret Manager
RefreshCertificates(ctx context.Context) error
}

Business Impact: This interface enables parallel development of country adapters. Spain, Italy, and France teams can work independently as long as they implement this contract.


2. Error Translator (packages/shared/go/errors/translator.go)

Purpose: Translate tax authority errors (Spanish, Italian, French) to English with resolution guidance.

// packages/shared/go/errors/translator.go
package errors

import (
"context"
"fmt"
)

type ErrorTranslator struct {
lookupTable map[string]Translation // Static lookup table
aiClient *AIClient // Claude API client
cache *Cache // Redis cache for AI translations
}

type Translation struct {
EnglishMessage string
Resolution string
ErrorCode string
}

func (t *ErrorTranslator) Translate(ctx context.Context, taxAuthorityError error, country string) (*Translation, error) {
errorCode := extractErrorCode(taxAuthorityError)

// 1. Check static lookup table
if translation, exists := t.lookupTable[country+":"+errorCode]; exists {
return &translation, nil
}

// 2. Check cache (previously AI-translated)
cacheKey := fmt.Sprintf("error_translation:%s:%s", country, errorCode)
if cached, err := t.cache.Get(ctx, cacheKey); err == nil {
return parseTranslation(cached), nil
}

// 3. Use AI to translate (Claude API)
translation, err := t.translateWithAI(ctx, taxAuthorityError, country)
if err != nil {
return nil, err
}

// 4. Cache permanently
t.cache.Set(ctx, cacheKey, serializeTranslation(translation), 0 /* no expiry */)

// 5. Log for weekly human review
t.logForReview(country, errorCode, translation)

return translation, nil
}

func (t *ErrorTranslator) translateWithAI(ctx context.Context, err error, country string) (*Translation, error) {
prompt := fmt.Sprintf(`
You are a tax compliance expert. Translate this %s tax authority error to English and provide resolution steps.

Error: %s

Format:
English Message: [translated error]
Resolution: [actionable steps to fix]
Error Code: [error code]
`, country, err.Error())

response, err := t.aiClient.Complete(ctx, prompt)
if err != nil {
return nil, err
}

return parseAIResponse(response), nil
}

Testing Implication: Error translator must have 80%+ test coverage. Mock AI client for unit tests. Integration tests validate against real tax authority error corpus.


3. QR Code Generator (packages/shared/go/qrcode/generator.go)

Purpose: Generate country-specific QR codes (TicketBAI, Verifactu, SDI, NF525).

// packages/shared/go/qrcode/generator.go
package qrcode

import (
"encoding/base64"
"github.com/skip2/go-qrcode"
)

type Generator struct{}

// GenerateTicketBAIQR generates TicketBAI format QR code
func (g *Generator) GenerateTicketBAIQR(nif, date, amount, signatureHash string) (string, error) {
data := fmt.Sprintf("TBAI:%s:%s:%s:%s", nif, date, amount, signatureHash)
return g.generateQRBase64(data, 200)
}

// GenerateVerifactuQR generates Verifactu format QR code
func (g *Generator) GenerateVerifactuQR(fiscalID string) (string, error) {
data := fmt.Sprintf("https://aeat.es/verifactu?id=%s", fiscalID)
return g.generateQRBase64(data, 200)
}

// GenerateSDIQR generates Italian SDI QR code
func (g *Generator) GenerateSDIQR(fiscalID, partitaIVA string) (string, error) {
data := fmt.Sprintf("https://sdi.agenziaentrate.gov.it/verify?id=%s&vat=%s", fiscalID, partitaIVA)
return g.generateQRBase64(data, 200)
}

// GenerateNF525QR generates French NF525 QR code (if required)
func (g *Generator) GenerateNF525QR(sequenceNumber string, hash string) (string, error) {
data := fmt.Sprintf("NF525:%s:%s", sequenceNumber, hash)
return g.generateQRBase64(data, 200)
}

func (g *Generator) generateQRBase64(data string, size int) (string, error) {
png, err := qrcode.Encode(data, qrcode.Medium, size)
if err != nil {
return "", err
}
return base64.StdEncoding.EncodeToString(png), nil
}

Component Interaction Patterns

Pattern 1: Transaction Submission Flow

1. Developer → Core API Handler (POST /v1/transactions)

2. Handler → TransactionService.Submit()

3. TransactionService → IdempotencyService.Check()
↓ (if not duplicate)
4. TransactionService → LocationService.GetLocation()

5. TransactionService → AdapterRouter.Route(location.country)

6. AdapterRouter → SpainClient.SubmitTransaction() (with circuit breaker)

7. SpainClient → Spain Adapter HTTP API (POST /fiscalize)

8a. Success (2-5 seconds):
Spain Adapter → FiscalizationService.Fiscalize()
→ TicketBAI.GenerateXML()
→ TicketBAI.SignXML()
→ TicketBAI.SubmitToAEAT()
→ ReceiptService.GeneratePDF()
→ CloudStorage.Upload()
← Return FiscalReceipt (status="fiscalized")

8b. Timeout (>10 seconds) or Circuit Breaker Open:
TransactionService → RetryService.Enqueue()
→ ReceiptService.GenerateBasketReceipt() (temporary)
→ CloudStorage.Upload()
← Return Transaction (status="pending_fiscalization", basket_receipt_url, status_page_url)

Pattern 2: Background Retry Flow

1. RetryWorker (runs every 1 minute) → RetryService.ProcessQueue()

2. RetryService → RetryQueueRepository.GetPendingRetries()

3. For each retry:
RetryService → AdapterRouter.Route(transaction.location.country)
→ SpainClient.SubmitTransaction()

4a. Success:
RetryService → TransactionRepository.UpdateStatus(status="fiscalized", fiscal_data)
→ WebhookService.Trigger(event="transaction.fiscalized")
→ EmailService.Send(subject="Your receipt is ready")
→ RetryQueueRepository.Remove(transaction_id)

4b. Failure:
RetryService → RetryQueueRepository.UpdateRetry(
attempt_count++,
next_retry_at = calculateBackoff(attempt_count),
last_error = error.message
)

4c. Max Attempts Reached (50):
RetryService → TransactionRepository.UpdateStatus(status="failed")
→ WebhookService.Trigger(event="transaction.failed")
→ AlertService.NotifyOps(transaction_id, error)

Pattern 3: Dashboard Data Fetching

1. User → Dashboard Page (app/dashboard/transactions/page.tsx)

2. Page Component → useTransactions() hook

3. useTransactions → React Query (queryFn: apiClient.listTransactions())

4. apiClient → Core API (GET /v1/transactions?limit=50&offset=0)

5. Core API → TransactionHandler.ListTransactions()
→ TransactionService.List()
→ TransactionRepository.FindByAccount(accountID, limit, offset)
← Return { transactions: [...], total: 150 }

6. React Query → Cache result, refetch every 10 seconds

7. Page Component → Render table with transaction data

Invoice Chain Manager (Generic Component)

Purpose: Provides generic invoice chaining functionality for tax systems requiring cryptographic hash chains (Spain VERIFACTU, France NF525, future systems).

Design Philosophy: Abstract and Reusable

  • Generic interface supports any country's chaining requirements
  • Country-specific adapters implement chain-specific logic
  • Customer-facing API remains unchanged (chain management is invisible)
  • Degrades gracefully: If chain breaks, automatic recovery from tax authority

Architecture

┌─────────────────────────────────────────────────────────────┐
│ Transaction Service │
│ (Customer submits transaction - NO chain data required) │
└──────────────────────┬──────────────────────────────────────┘


┌──────────────────────────────┐
│ Chain Manager (Generic) │
│ - GetPreviousHash() │
│ - CalculateNewHash() │
│ - UpdateChainState() │
│ - ValidateChain() │
│ - RecoverChain() │
└──────────┬───────────────────┘

┌──────────┴──────────┐
▼ ▼
┌───────────────┐ ┌───────────────┐
│ VERIFACTU │ │ NF525 Chain │
│ Chain Adapter │ │ Adapter │
│ (Spain) │ │ (France) │
└───────┬───────┘ └───────┬───────┘
│ │
▼ ▼
┌───────────────────────────────────┐
│ Location.ChainState (Database) │
│ - LastHash (source of truth) │
│ - LastSequenceNumber │
│ - ChainBroken flag │
└───────────────────────────────────┘

Generic Chain Manager Interface

// ChainManager - Generic interface for invoice chaining systems
type ChainManager interface {
// GetPreviousHash retrieves the hash to use for the next invoice
// Returns chain seed hash if this is the first invoice
GetPreviousHash(ctx context.Context, locationID string) (string, error)

// CalculateNewHash generates the hash for the current invoice
// Combines invoice data + previous hash using country-specific algorithm
CalculateNewHash(ctx context.Context, invoice Invoice, previousHash string) (string, error)

// UpdateChainState atomically updates location's chain state after successful submission
// CRITICAL: Must be atomic to prevent chain breaks
UpdateChainState(ctx context.Context, locationID string, update ChainStateUpdate) error

// ValidateChain verifies chain integrity with tax authority
// Used during recovery to ensure local state matches remote state
ValidateChain(ctx context.Context, locationID string) (bool, error)

// RecoverChain synchronizes chain state from tax authority
// Triggered automatically on chain validation errors
RecoverChain(ctx context.Context, locationID string) error

// IsChainHealthy checks if location can process new invoices
// Returns false if ChainBroken=true
IsChainHealthy(ctx context.Context, locationID string) (bool, error)
}

// ChainStateUpdate - Data to update after successful invoice submission
type ChainStateUpdate struct {
NewHash string
InvoiceID string
SequenceNumber int64
Validated bool
TaxAuthoritySync time.Time
}

Country-Specific Implementations

1. VERIFACTU Chain Adapter (Spain)

type VerifactuChainManager struct {
locationRepo LocationRepository
aeatClient AEATClient // Spain tax authority client
}

func (m *VerifactuChainManager) GetPreviousHash(ctx context.Context, locationID string) (string, error) {
location, err := m.locationRepo.FindByID(ctx, locationID)
if err != nil {
return "", err
}

// Check if chaining is enabled
if location.ChainState == nil || !location.ChainState.Enabled {
return "", ErrChainingNotEnabled
}

// Check chain health
if location.ChainState.ChainBroken {
return "", ErrChainBroken // Block new invoices until recovery
}

// Return last hash (or seed hash for first invoice)
if location.ChainState.LastHash != "" {
return location.ChainState.LastHash, nil
}

// First invoice: Use certificate fingerprint as seed
return m.getCertificateFingerprint(location.CertificateID), nil
}

func (m *VerifactuChainManager) CalculateNewHash(ctx context.Context, invoice Invoice, previousHash string) (string, error) {
// VERIFACTU hash format:
// SHA256(invoice_number | timestamp | total_amount | previous_hash)
data := fmt.Sprintf("%s|%s|%.2f|%s",
invoice.FiscalID,
invoice.Timestamp.Format("2006-01-02T15:04:05"),
invoice.TotalAmount,
previousHash,
)

hash := sha256.Sum256([]byte(data))
return base64.StdEncoding.EncodeToString(hash[:]), nil
}

func (m *VerifactuChainManager) UpdateChainState(ctx context.Context, locationID string, update ChainStateUpdate) error {
// CRITICAL: Atomic update to prevent race conditions
return m.locationRepo.AtomicChainUpdate(ctx, locationID, func(state *ChainState) error {
state.LastHash = update.NewHash
state.LastInvoiceID = update.InvoiceID
state.LastSequenceNumber = update.SequenceNumber
state.LastValidatedAt = update.TaxAuthoritySync
state.ChainBroken = false // Clear any previous broken state
return nil
})
}

func (m *VerifactuChainManager) RecoverChain(ctx context.Context, locationID string) error {
location, err := m.locationRepo.FindByID(ctx, locationID)
if err != nil {
return err
}

log.Info().Str("location_id", locationID).Msg("Starting VERIFACTU chain recovery")

// 1. Query AEAT for last successfully registered invoice
lastInvoice, err := m.aeatClient.GetLastInvoice(ctx, location.TaxID)
if err != nil {
// AEAT unavailable - mark chain as broken, retry later
m.locationRepo.MarkChainBroken(ctx, locationID, "AEAT unavailable during recovery")
return fmt.Errorf("AEAT query failed: %w", err)
}

// 2. Update local state to match AEAT (source of truth)
err = m.UpdateChainState(ctx, locationID, ChainStateUpdate{
NewHash: lastInvoice.Hash,
InvoiceID: lastInvoice.FiscalID,
SequenceNumber: lastInvoice.SequenceNumber,
Validated: true,
TaxAuthoritySync: time.Now(),
})
if err != nil {
return fmt.Errorf("chain state update failed: %w", err)
}

log.Info().
Str("location_id", locationID).
Str("recovered_hash", lastInvoice.Hash).
Int64("sequence", lastInvoice.SequenceNumber).
Msg("VERIFACTU chain recovered successfully")

return nil
}

2. NF525 Chain Adapter (France)

type NF525ChainManager struct {
locationRepo LocationRepository
dgfipClient DGFiPClient // France tax authority client
}

func (m *NF525ChainManager) GetPreviousHash(ctx context.Context, locationID string) (string, error) {
location, err := m.locationRepo.FindByID(ctx, locationID)
if err != nil {
return "", err
}

if location.ChainState == nil || !location.ChainState.Enabled {
return "", ErrChainingNotEnabled
}

if location.ChainState.ChainBroken {
return "", ErrChainBroken
}

// NF525: Return last hash or certificate-based seed
if location.ChainState.LastHash != "" {
return location.ChainState.LastHash, nil
}

// First invoice: Use NF525 certificate number as seed
certNumber := location.CountryConfig["certificate_number"].(string)
return m.calculateSeedHash(certNumber), nil
}

func (m *NF525ChainManager) CalculateNewHash(ctx context.Context, invoice Invoice, previousHash string) (string, error) {
// NF525 hash format (French standard):
// SHA256(sequence_number | timestamp | total_excl_vat | vat_amount | previous_hash)
data := fmt.Sprintf("%d|%s|%.2f|%.2f|%s",
invoice.ChainSequenceNumber,
invoice.Timestamp.Format("2006-01-02T15:04:05"),
invoice.PretaxAmount,
invoice.TaxAmount,
previousHash,
)

hash := sha256.Sum256([]byte(data))
return hex.EncodeToString(hash[:]), nil // NF525 uses hex encoding
}

func (m *NF525ChainManager) RecoverChain(ctx context.Context, locationID string) error {
// Similar to VERIFACTU but uses DGFiP API
// Implementation omitted for brevity (follows same pattern)
}

Transaction Service Integration

Workflow: Creating a Transaction with Chaining

func (s *TransactionService) CreateTransaction(ctx context.Context, req CreateTransactionRequest) (*Transaction, error) {
// 1. Load location
location, err := s.locationRepo.FindByID(ctx, req.LocationID)
if err != nil {
return nil, err
}

// 2. Check if chaining is required
chainRequired := location.CountryConfig["chain_required"].(bool)
if !chainRequired {
// Non-chained flow (Italy, TicketBAI without chaining)
return s.processNonChainedTransaction(ctx, req, location)
}

// 3. Get chain manager for country
chainManager, err := s.getChainManager(location.Country)
if err != nil {
return nil, err
}

// 4. Check chain health
healthy, err := chainManager.IsChainHealthy(ctx, req.LocationID)
if err != nil {
return nil, err
}
if !healthy {
// Chain broken - trigger recovery in background, block this transaction
s.chainRecoveryQueue.Enqueue(ctx, req.LocationID)
return nil, ErrChainBrokenRecoveryInProgress
}

// 5. Get previous hash (customer never sees this!)
previousHash, err := chainManager.GetPreviousHash(ctx, req.LocationID)
if err != nil {
return nil, err
}

// 6. Create transaction object
txn := &Transaction{
ID: generateID(),
LocationID: req.LocationID,
Items: req.Items,
TotalAmount: req.TotalAmount,
ChainPreviousHash: &previousHash, // Internal only (json:"-")
ChainSequenceNumber: location.ChainState.LastSequenceNumber + 1,
Status: "pending",
}

// 7. Submit to country adapter (passes previous hash internally)
result, err := s.submitToAdapter(ctx, txn, location, previousHash)
if err != nil {
// Submission failed - do NOT update chain state
return txn, err
}

// 8. Calculate new hash
newHash, err := chainManager.CalculateNewHash(ctx, *txn, previousHash)
if err != nil {
return nil, err
}

txn.ChainThisHash = &newHash
txn.ChainValidated = result.ChainValidated

// 9. Update location chain state (ATOMIC!)
err = chainManager.UpdateChainState(ctx, req.LocationID, ChainStateUpdate{
NewHash: newHash,
InvoiceID: txn.ID,
SequenceNumber: txn.ChainSequenceNumber,
Validated: result.ChainValidated,
TaxAuthoritySync: time.Now(),
})
if err != nil {
// CRITICAL: Invoice submitted but chain state not updated
// Mark chain as broken, trigger recovery
log.Error().Err(err).Str("location_id", req.LocationID).Msg("Chain state update failed")
s.chainRecoveryQueue.Enqueue(ctx, req.LocationID)

// Transaction succeeded, but chain is now broken for future invoices
// Return success to customer (don't block their business)
}

txn.Status = "fiscalized"
return txn, nil
}

Sequential Retry Processing (Critical for Chains)

Problem: If multiple transactions are pending retry, they MUST be processed in order to maintain chain integrity.

Solution: Location-specific retry queue with sequential processing

type ChainAwareRetryService struct {
txnRepo TransactionRepository
locationRepo LocationRepository
chainManager ChainManager
}

func (s *ChainAwareRetryService) ProcessPendingTransactions(ctx context.Context, locationID string) error {
// 1. Acquire distributed lock on location (prevent concurrent processing)
lock, err := s.acquireLock(ctx, locationID)
if err != nil {
return err
}
defer lock.Release()

// 2. Load location with current chain state
location, err := s.locationRepo.FindByID(ctx, locationID)
if err != nil {
return err
}

// 3. Get pending transactions IN ORDER (by created_at ASC)
pending, err := s.txnRepo.FindPending(ctx, locationID, "ORDER BY created_at ASC")
if err != nil {
return err
}

log.Info().
Str("location_id", locationID).
Int("pending_count", len(pending)).
Msg("Processing pending transactions sequentially")

// 4. Process each transaction in order
currentHash := location.ChainState.LastHash

for i, txn := range pending {
log.Info().
Str("txn_id", txn.ID).
Int("position", i+1).
Int("total", len(pending)).
Msg("Processing transaction")

// Submit with current hash
result, err := s.submitToAdapter(ctx, txn, location, currentHash)
if err != nil {
// Stop processing on first failure
// Next retry will resume from here
log.Warn().Err(err).Str("txn_id", txn.ID).Msg("Submission failed, stopping batch")
return err
}

// Calculate and update chain
newHash, _ := s.chainManager.CalculateNewHash(ctx, txn, currentHash)
err = s.chainManager.UpdateChainState(ctx, locationID, ChainStateUpdate{
NewHash: newHash,
InvoiceID: txn.ID,
SequenceNumber: txn.ChainSequenceNumber,
})
if err != nil {
// Chain update failed - mark chain as broken
s.locationRepo.MarkChainBroken(ctx, locationID, "Retry chain update failed")
return err
}

// Update transaction status
txn.Status = "fiscalized"
txn.ChainThisHash = &newHash
s.txnRepo.Update(ctx, txn)

currentHash = newHash // Continue chain
}

log.Info().
Str("location_id", locationID).
Int("processed", len(pending)).
Msg("All pending transactions processed successfully")

return nil
}

Automatic Chain Recovery

Triggers:

  1. Chain validation error from tax authority
  2. Database update failure
  3. Manual recovery API call (support/admin only)
  4. Scheduled health check (daily)

Recovery Flow:

func (s *ChainRecoveryService) AutoRecover(ctx context.Context, locationID string, trigger string) error {
log.Info().
Str("location_id", locationID).
Str("trigger", trigger).
Msg("Chain recovery triggered")

// 1. Mark chain as broken (blocks new transactions)
err := s.locationRepo.MarkChainBroken(ctx, locationID, trigger)
if err != nil {
return err
}

// 2. Get chain manager for location's country
location, _ := s.locationRepo.FindByID(ctx, locationID)
chainManager, _ := s.getChainManager(location.Country)

// 3. Perform recovery (syncs from tax authority)
err = chainManager.RecoverChain(ctx, locationID)
if err != nil {
// Recovery failed - will retry later via cron
log.Error().Err(err).Str("location_id", locationID).Msg("Chain recovery failed")
return err
}

// 4. Notify customer via webhook
s.webhookService.Trigger(ctx, location.AccountID, WebhookEvent{
Type: "location.chain_recovered",
Data: map[string]string{
"location_id": locationID,
"trigger": trigger,
},
})

// 5. Resume processing pending transactions
s.retryService.ProcessPendingTransactions(ctx, locationID)

log.Info().Str("location_id", locationID).Msg("Chain recovery completed successfully")
return nil
}

Monitoring & Alerts

Metrics:

chain_state_updates_total{country="ES|FR", status="success|failure"}
chain_integrity_errors_total{country="ES|FR", error_type="validation|update|recovery"}
chain_recovery_attempts_total{country="ES|FR", trigger="validation_error|db_failure|manual"}
chain_recovery_duration_seconds{country="ES|FR"}
chain_broken_locations_total{country="ES|FR"} // Gauge - locations with ChainBroken=true

Critical Alerts:

- alert: ChainBrokenLocation
expr: chain_broken_locations_total > 0
for: 5m
labels:
severity: critical
annotations:
summary: "Location has broken invoice chain"
description: "Location {{ $labels.location_id }} chain is broken. Automatic recovery in progress."

- alert: ChainRecoveryFailure
expr: rate(chain_integrity_errors_total{error_type="recovery"}[10m]) > 3
for: 5m
labels:
severity: critical
annotations:
summary: "Chain recovery failing repeatedly"
description: "Location {{ $labels.location_id }} recovery failed {{ $value }} times. Manual intervention may be required."

Summary: Benefits of Generic Chain Management

Customer Transparency: Customers never see or manage chain complexity ✅ Degradation Promise Kept: If chain breaks, automatic recovery handles it ✅ Reusability: Same architecture works for VERIFACTU, NF525, future systems ✅ Audit Trail: Full chain history preserved in Transaction records ✅ Safety: ChainBroken flag prevents corrupted chains from propagating ✅ Sequential Retry: Pending transactions processed in order to maintain integrity ✅ Automatic Recovery: Tax authority is source of truth for chain state


Component Testing Strategy

Unit Tests (80% coverage minimum):

  • Handlers: Mock services, test request validation, response formatting
  • Services: Mock repositories, test business logic, error handling
  • Repositories: Use testcontainers (PostgreSQL), test SQL queries
  • Adapters: Mock HTTP clients, test circuit breaker behavior
  • Shared components: Test QR generation, error translation, tax ID validation

Integration Tests:

  • Core API → Adapter communication (mock tax authorities)
  • Dashboard → Core API communication (mock Core API)
  • Retry queue processing (PostgreSQL + mock adapters)

Contract Tests (Schemathesis):

  • Validate Core API against OpenAPI spec
  • Validate adapter APIs against internal contracts

E2E Tests (Playwright):

  • Critical user flows: Signup → Create location → Submit transaction → View receipt
  • Mobile transaction lookup

Next Section: External APIs - Integration specifications for tax authority APIs, Stripe, Postmark, Claude API.


External APIs

This section documents all external API integrations that Fiscalization depends on, including tax authority systems, payment processing, email delivery, and AI services. Each integration includes authentication, error handling, rate limiting, and monitoring strategies.

Integration Overview

ServicePurposeEnvironmentSLACriticality
Spain TicketBAIBasque Country fiscal complianceProduction99.9%Critical
Spain BATUZBasque Country e-invoicingProduction99.9%Critical
Spain VERIFACTUNational Spanish anti-fraud systemProduction99.5%Critical
Italy SDIItalian e-invoicing systemProduction99.5%Critical
France DGFiPFrench tax authority APIProduction99.5%Critical
StripePayment processingProduction99.99%High
PostmarkTransactional email deliveryProduction99.9%Medium
Claude APIError translation, adapter scaffoldingProduction99.5%Low
Google Cloud StorageReceipt/QR storageProduction99.99%High

Criticality Levels:

  • Critical: Service downtime blocks customer transactions (must fail gracefully with retry)
  • High: Service downtime degrades user experience (payments, receipt storage)
  • Medium: Service downtime delays notifications (emails can be queued)
  • Low: Service downtime reduces developer productivity (AI features are enhancements)

Tax Authority APIs

1. Spain - TicketBAI (Basque Country)

Purpose: Generate digitally signed fiscal receipts for Basque Country businesses (Álava, Guipúzcoa, Vizcaya).

API Endpoints:

Production: https://ticketbai.bizkaia.eus/tbai/qrtbai/
Test: https://pruebas-ticketbai.bizkaia.eus/tbai/qrtbai/

Authentication:

  • Client X.509 certificates (stored in Secret Manager)
  • Per-location certificate management (customers upload via dashboard)
  • Certificate expiration monitoring (alert 30 days before expiry)

Request Format:

<?xml version="1.0" encoding="UTF-8"?>
<T:TicketBai xmlns:T="urn:ticketbai:emision">
<Cabecera>
<IDVersionTBAI>1.2</IDVersionTBAI>
</Cabecera>
<Sujetos>
<Emisor>
<NIF>B95983761</NIF>
<ApellidosNombreRazonSocial>Mi Restaurante SL</ApellidosNombreRazonSocial>
</Emisor>
</Sujetos>
<Factura>
<CabeceraFactura>
<SerieFactura>A</SerieFactura>
<NumFactura>12345</NumFactura>
<FechaExpedicionFactura>27-10-2025</FechaExpedicionFactura>
<HoraExpedicionFactura>14:30:00</HoraExpedicionFactura>
</CabeceraFactura>
<DatosFactura>
<ImporteTotalFactura>12.10</ImporteTotalFactura>
<DetallesFactura>
<IDDetalleFactura>
<DescripcionDetalle>Menú del día</DescripcionDetalle>
<Cantidad>1</Cantidad>
<ImporteUnitario>10.00</ImporteUnitario>
<ImporteTotal>10.00</ImporteTotal>
</IDDetalleFactura>
</DetallesFactura>
<TipoDesglose>
<DesgloseFactura>
<Sujeta>
<NoExenta>
<DetalleNoExenta>
<TipoNoExenta>S1</TipoNoExenta>
<BaseImponible>10.00</BaseImponible>
<TipoImpositivo>10.00</TipoImpositivo>
<CuotaImpuesto>1.00</CuotaImpuesto>
</DetalleNoExenta>
</NoExenta>
</Sujeta>
</DesgloseFactura>
</TipoDesglose>
</DatosFactura>
</Factura>
<HuellaTBAI>
<Software>
<LicenciaTBAI>TBAI-123456789</LicenciaTBAI>
<EntidadDesarrolladora>
<NIF>B12345674</NIF>
</EntidadDesarrolladora>
<Nombre>FiscalAPI</Nombre>
<Version>1.0</Version>
</Software>
<NumSerieDispositivo>DEVICE123</NumSerieDispositivo>
</HuellaTBAI>
</T:TicketBai>

Response Format:

<?xml version="1.0" encoding="UTF-8"?>
<respuesta>
<estado>Correcto</estado>
<identificador>TBAI-B95983761-27102025-A12345-001</identificador>
<qr>https://ticketbai.bizkaia.eus/tbai/qrtbai/?id=TBAI-B95983761...</qr>
<firma>BASE64_SIGNATURE_DATA...</firma>
</respuesta>

Error Handling:

// Spain adapter error mapping
var ticketBAIErrors = map[string]ErrorCode{
"TBAI240": ErrorInvalidCertificate, // Certificate not authorized
"TBAI340": ErrorInvalidBusinessID, // NIF/CIF validation failed
"TBAI500": ErrorInvalidInvoiceNumber, // Duplicate invoice number
"TBAI998": ErrorTaxAuthorityUnavailable, // Service temporarily unavailable
}

Rate Limits:

  • Production: 100 requests/second per certificate
  • Test: 10 requests/second per certificate
  • Burst: 150 requests (5-second window)

Retry Strategy:

  • Transient errors (TBAI998, HTTP 503): Exponential backoff (2s, 4s, 8s, 16s, 32s)
  • Client errors (TBAI240, TBAI340): No retry, return error immediately
  • Timeout: 30 seconds per request

Monitoring:

// Metrics to track
ticketbai_requests_total{country="spain", region="basque", status="success|error"}
ticketbai_request_duration_seconds{country="spain", region="basque"}
ticketbai_certificate_expiry_days{location_id="loc_123"}
ticketbai_errors_total{country="spain", error_code="TBAI240"}

2. Spain - BATUZ (Basque Country Invoicing)

Purpose: Submit e-invoices to Basque tax authority (complements TicketBAI for B2B transactions).

API Endpoints:

Production: https://batuz.eus/QRPF
Test: https://pruebas.batuz.eus/QRPF

Authentication:

  • Same X.509 certificates as TicketBAI
  • NIF/CIF validation required for B2B transactions

Request Format:

<?xml version="1.0" encoding="UTF-8"?>
<lroe:LROE xmlns:lroe="https://www.batuz.eus/lroe/modelo/140/1">
<Cabecera>
<Modelo>140</Modelo>
<Capitulo>1</Capitulo>
<Subcapitulo>1.1</Subcapitulo>
<Operacion>A00</Operacion> <!-- Alta (new invoice) -->
<Version>1.0</Version>
</Cabecera>
<Contribuyente>
<NIF>B95983761</NIF>
<ApellidosNombreRazonSocial>Mi Restaurante SL</ApellidosNombreRazonSocial>
</Contribuyente>
<RegistroFactura>
<SerieFactura>A</SerieFactura>
<NumeroFactura>12345</NumeroFactura>
<FechaExpedicion>27-10-2025</FechaExpedicion>
<ImporteTotalFactura>121.00</ImporteTotalFactura>
<Destinatarios>
<Destinatario>
<NIF>B87654321</NIF>
<ApellidosNombreRazonSocial>Cliente Empresa SL</ApellidosNombreRazonSocial>
</Destinatario>
</Destinatarios>
</RegistroFactura>
</lroe:LROE>

Response Format:

<?xml version="1.0" encoding="UTF-8"?>
<RespuestaLROE>
<Estado>AceptadoConErrores</Estado> <!-- Accepted, AceptadoConErrores, Rechazado -->
<CSV>LROE-2025-001234567</CSV>
<FechaRecepcion>27-10-2025T14:30:00</FechaRecepcion>
<Avisos>
<Aviso>
<Codigo>B4_10000013</Codigo>
<Descripcion>El destinatario no está dado de alta en el censo</Descripcion>
</Aviso>
</Avisos>
</RespuestaLROE>

Rate Limits:

  • Production: 50 requests/second per NIF
  • Test: 5 requests/second per NIF
  • Daily limit: 10,000 invoices per NIF

Retry Strategy:

  • Transient errors (HTTP 503, timeouts): Retry with exponential backoff
  • "AceptadoConErrores": Log warnings, mark transaction as success
  • "Rechazado": No retry, return error to customer

3. Spain - VERIFACTU (National Anti-Fraud System)

Purpose: Spain's national e-invoicing and anti-fraud system for all Spanish businesses (not limited to Basque Country). Mandatory rollout beginning 2025.

Coverage: All of Spain (complements TicketBAI in Basque regions, replaces it elsewhere)

API Endpoints:

Production: https://sede.agenciatributaria.gob.es/verifactu/api/v1
Test: https://prewww1.aeat.es/verifactu/api/v1

Authentication:

  • X.509 certificates issued by FNMT-RCM (Spanish government CA)
  • Certificate linked to business NIF/CIF
  • OAuth 2.0 for API access (client credentials flow)
  • Digital signature required for all invoices (SHA-256)

Rollout Timeline:

  • Phase 1 (July 2025): Businesses with revenue >€8M/year
  • Phase 2 (January 2026): Businesses with revenue >€1M/year
  • Phase 3 (July 2026): All businesses (including SMEs, self-employed)

Request Format (Verifactu XML):

<?xml version="1.0" encoding="UTF-8"?>
<sii:RegistroFactura xmlns:sii="https://www2.agenciatributaria.gob.es/verifactu/v1.0">
<Cabecera>
<IDVersion>1.0</IDVersion>
<TipoEnvio>FacturaEmitida</TipoEnvio>
</Cabecera>
<Emisor>
<NIF>B95983761</NIF>
<NombreRazonSocial>Mi Restaurante Nacional SL</NombreRazonSocial>
</Emisor>
<Factura>
<IDFactura>
<NumSerieFactura>A-2025-12345</NumSerieFactura>
<FechaExpedicion>27-10-2025</FechaExpedicion>
</IDFactura>
<Descripcion>
<TextoDescripcion>Menú del día + bebidas</TextoDescripcion>
</Descripcion>
<ImporteTotal>24.20</ImporteTotal>
<Desglose>
<BaseImponible>22.00</BaseImponible>
<TipoImpositivo>10.00</TipoImpositivo>
<CuotaImpuesto>2.20</CuotaImpuesto>
</Desglose>
<FirmaSoftware>
<NombreSoftware>FiscalAPI</NombreSoftware>
<VersionSoftware>1.0.0</VersionSoftware>
<NumeroInstalacion>INSTALL-FiscalAPI-001</NumeroInstalacion>
<TipoDispositivo>SERVIDOR</TipoDispositivo>
</FirmaSoftware>
<HuellaDigital>
<AlgoritmoFirma>SHA256withRSA</AlgoritmoFirma>
<FechaHora>2025-10-27T14:30:00+02:00</FechaHora>
<Firma>BASE64_ENCODED_SIGNATURE...</Firma>
<EncadenePrevio>HASH_FACTURA_ANTERIOR...</EncadenePrevio>
</HuellaDigital>
</Factura>
<FacturaAnterior>
<IDFacturaAnterior>
<NumSerieFactura>A-2025-12344</NumSerieFactura>
<FechaExpedicion>27-10-2025</FechaExpedicion>
</IDFacturaAnterior>
<HuellaAnterior>7A3F2B8C9D1E4F6A...</HuellaAnterior>
</FacturaAnterior>
</sii:RegistroFactura>

Response Format:

<?xml version="1.0" encoding="UTF-8"?>
<RespuestaVerifactu>
<Estado>Aceptado</Estado> <!-- Aceptado, Rechazado, Pendiente -->
<CSV>VF-ES-2025-001234567</CSV>
<CodigoQR>https://sede.agenciatributaria.gob.es/verifactu/qr?id=VF-ES-2025...</CodigoQR>
<FechaRegistro>2025-10-27T14:30:00+02:00</FechaRegistro>
<IdentificadorRegistro>REG-VF-123456789</IdentificadorRegistro>
</RespuestaVerifactu>

Key Differences from TicketBAI:

FeatureTicketBAI (Basque)VERIFACTU (National)
CoverageBasque Country only (Álava, Guipúzcoa, Vizcaya)All of Spain
AuthorityRegional tax agencies (Haciendas Forales)AEAT (National Tax Agency)
Certificate IssuerRegional CAs (Izenpe, etc.)FNMT-RCM (national)
Invoice ChainingOptionalMandatory (each invoice references previous)
QR Code FormatTicketBAI URLAEAT Verifactu URL
Software RegistrationLicense number (LicenciaTBAI)Installation number + device type
Submission TimingReal-time (within seconds)Real-time or 4-day batch (based on business size)

Invoice Chaining (Anti-Fraud Mechanism):

// Each invoice must include hash of previous invoice (prevents deletion/modification)
type InvoiceChain struct {
CurrentInvoice Invoice
PreviousInvoiceHash string // SHA-256 of previous invoice XML
}

// On first invoice: Use predefined seed value
func (a *VerifactuAdapter) GetPreviousHash(locationID string) (string, error) {
lastInvoice, err := a.repo.FindLastInvoice(locationID)
if err != nil || lastInvoice == nil {
// First invoice: Use location certificate fingerprint as seed
return a.GetCertificateFingerprint(locationID), nil
}
return lastInvoice.SignatureHash, nil
}

// Calculate hash for current invoice
func (a *VerifactuAdapter) CalculateInvoiceHash(invoice Invoice) string {
data := fmt.Sprintf("%s|%s|%.2f|%s",
invoice.Number,
invoice.Date.Format("2006-01-02T15:04:05"),
invoice.TotalAmount,
invoice.PreviousHash,
)
hash := sha256.Sum256([]byte(data))
return base64.StdEncoding.EncodeToString(hash[:])
}

Digital Signature Requirements:

// All invoices must be digitally signed using business X.509 certificate
func (a *VerifactuAdapter) SignInvoice(invoice Invoice, cert *x509.Certificate, privateKey *rsa.PrivateKey) (string, error) {
// Canonical XML representation (order matters for signature verification)
xmlBytes := invoice.ToCanonicalXML()

// SHA-256 hash of invoice content
hash := sha256.Sum256(xmlBytes)

// Sign hash with private key
signature, err := rsa.SignPKCS1v15(rand.Reader, privateKey, crypto.SHA256, hash[:])
if err != nil {
return "", err
}

// Base64 encode signature
return base64.StdEncoding.EncodeToString(signature), nil
}

Error Codes:

var verifactuErrors = map[string]ErrorCode{
"VF001": ErrorInvalidCertificate, // Certificate not issued by FNMT-RCM
"VF002": ErrorInvalidSignature, // Digital signature validation failed
"VF003": ErrorInvalidInvoiceChain, // Previous invoice hash mismatch
"VF004": ErrorDuplicateInvoice, // Invoice number already registered
"VF005": ErrorInvalidNIF, // Business NIF not found in AEAT registry
"VF006": ErrorInvalidVATCalculation, // Tax calculation error
"VF007": ErrorSoftwareNotRegistered, // FiscalAPI not registered with AEAT
"VF998": ErrorAEATUnavailable, // AEAT service temporarily down
}

Rate Limits:

  • Production (Large businesses): 500 requests/minute per NIF
  • Production (SMEs): 100 requests/minute per NIF
  • Test: 20 requests/minute per NIF
  • Max invoice size: 2 MB (XML)

Submission Modes:

  1. Real-Time (Mandatory for large businesses >€6M revenue):
// Submit invoice immediately after creation
func (a *VerifactuAdapter) SubmitInvoiceRealTime(ctx context.Context, invoice Invoice) error {
// Must submit within 4 hours of invoice creation
if time.Since(invoice.CreatedAt) > 4*time.Hour {
return ErrorSubmissionDeadlineExceeded
}

return a.submitToAEAT(ctx, invoice)
}
  1. Batch Mode (Allowed for SMEs <€6M revenue):
// Submit invoices in daily batches
func (a *VerifactuAdapter) SubmitInvoiceBatch(ctx context.Context, invoices []Invoice) error {
// Must submit within 4 business days
maxDelay := 4 * 24 * time.Hour

for _, invoice := range invoices {
if time.Since(invoice.CreatedAt) > maxDelay {
return ErrorBatchSubmissionDeadlineExceeded
}
}

return a.submitBatchToAEAT(ctx, invoices)
}

QR Code Generation:

// QR code links to AEAT verification portal (customers can verify invoice authenticity)
func (a *VerifactuAdapter) GenerateQRCode(csv string, nif string, invoiceNumber string, amount float64) ([]byte, error) {
// AEAT QR format: URL with query parameters
qrURL := fmt.Sprintf(
"https://sede.agenciatributaria.gob.es/verifactu/qr?id=%s&nif=%s&num=%s&importe=%.2f",
csv, // Verifactu registration number
nif, // Business tax ID
invoiceNumber, // Invoice number
amount, // Total amount
)

// Generate QR code (256x256 pixels, error correction level M)
qrCode, err := qrcode.Encode(qrURL, qrcode.Medium, 256)
if err != nil {
return nil, err
}

return qrCode, nil
}

Retry Strategy:

  • Transient errors (VF998, HTTP 503): Exponential backoff (2s, 4s, 8s, 16s, 32s)
  • Client errors (VF001-VF007): No retry, return error immediately
  • Timeout: 45 seconds per request (AEAT can be slow)
  • Invoice chaining failure: Critical error - block subsequent invoices until resolved

Monitoring:

verifactu_submissions_total{status="accepted|rejected|pending"}
verifactu_signature_validation_failures_total
verifactu_chain_integrity_errors_total // Critical: Indicates broken invoice chain
verifactu_request_duration_seconds
verifactu_certificate_expiry_days{location_id="loc_789"}
verifactu_errors_total{error_code="VF001|VF002|VF003|VF004|VF005|VF006|VF007|VF998"}

Critical Operational Considerations:

  1. Invoice Chaining Recovery:
// If chain breaks (system downtime, missed invoice), must recover from last known good state
func (a *VerifactuAdapter) RecoverInvoiceChain(ctx context.Context, locationID string) error {
// Query AEAT for last successfully registered invoice
lastKnownInvoice, err := a.aeatClient.GetLastInvoice(ctx, locationID)
if err != nil {
return err
}

// Update local database to match AEAT state
err = a.repo.UpdateChainSeed(locationID, lastKnownInvoice.Hash)
if err != nil {
return err
}

log.Info().Str("location_id", locationID).
Str("last_hash", lastKnownInvoice.Hash).
Msg("Invoice chain recovered from AEAT")

return nil
}
  1. Software Registration with AEAT:
// FiscalAPI must be registered with AEAT before production use
type SoftwareRegistration struct {
SoftwareName string // "FiscalAPI"
Version string // "1.0.0"
DeveloperNIF string // FiscalAPI business tax ID
InstallationNumber string // Unique per customer location
DeviceType string // "SERVIDOR" (server) vs "TERMINAL" (POS)
}

// Submit registration to AEAT (one-time per software version)
func (a *VerifactuAdapter) RegisterSoftware(ctx context.Context, reg SoftwareRegistration) error {
// AEAT validates developer NIF and issues registration certificate
return a.aeatClient.RegisterSoftware(ctx, reg)
}
  1. Certificate Management:
// VERIFACTU requires FNMT-RCM certificates (different from regional TicketBAI certs)
type CertificateRequirements struct {
Issuer: "FNMT-RCM" // Only Spanish government CA accepted
KeySize: 2048 // Minimum RSA key size
SignatureAlg: "SHA256withRSA"
ValidityPeriod: 3 * 365 * 24 * time.Hour // 3 years max
RequiresSMSAuth: true // FNMT requires SMS verification during issuance
}

// Alert 60 days before expiry (FNMT renewal takes 2-4 weeks)
func (a *VerifactuAdapter) MonitorCertificateExpiry(ctx context.Context) {
threshold := 60 * 24 * time.Hour
locations, _ := a.repo.FindLocationsWithExpiringSoon(threshold)

for _, loc := range locations {
daysRemaining := int(time.Until(loc.CertificateExpiresAt).Hours() / 24)

// Send alert to customer
a.alertService.SendCertificateExpiryWarning(ctx, loc, daysRemaining)

// CRITICAL: After expiry, invoices CANNOT be submitted (no grace period)
if daysRemaining < 7 {
a.alertService.SendUrgentCertificateAlert(ctx, loc)
}
}
}

Testing Strategy:

// AEAT provides sandbox with test certificates
func (a *VerifactuAdapter) UseTestEnvironment() {
a.baseURL = "https://prewww1.aeat.es/verifactu/api/v1"
a.testMode = true

// Test certificates have special NIF range: B00000000 - B99999999
// Test invoices don't affect production registry
}

Documentation References:


4. Italy - Sistema di Interscambio (SDI)

Purpose: Submit B2B and B2C e-invoices to Italian tax authority (mandatory for all businesses).

API Endpoints:

Production: https://sdi.fatturapa.gov.it/SdiRicezione
Test: https://testservizi.fatturapa.it/ricevi_dt

Authentication:

  • X.509 digital certificates issued by Italian CA (InfoCert, Aruba, etc.)
  • PEC (Certified Email) address required for B2B invoices
  • Codice Destinatario (7-character recipient code) for intermediary routing

Request Format (FatturaPA XML):

<?xml version="1.0" encoding="UTF-8"?>
<p:FatturaElettronica xmlns:p="http://ivaservizi.agenziaentrate.gov.it/docs/xsd/fatture/v1.2" versione="FPR12">
<FatturaElettronicaHeader>
<DatiTrasmissione>
<IdTrasmittente>
<IdPaese>IT</IdPaese>
<IdCodice>01234567890</IdCodice>
</IdTrasmittente>
<ProgressivoInvio>00001</ProgressivoInvio>
<FormatoTrasmissione>FPR12</FormatoTrasmissione>
<CodiceDestinatario>0000000</CodiceDestinatario> <!-- 0000000 = consumer invoice -->
</DatiTrasmissione>
<CedentePrestatore>
<DatiAnagrafici>
<IdFiscaleIVA>
<IdPaese>IT</IdPaese>
<IdCodice>01234567890</IdCodice>
</IdFiscaleIVA>
<Anagrafica>
<Denominazione>Ristorante Italia SRL</Denominazione>
</Anagrafica>
<RegimeFiscale>RF01</RegimeFiscale> <!-- Ordinary regime -->
</DatiAnagrafici>
<Sede>
<Indirizzo>Via Roma 123</Indirizzo>
<CAP>00100</CAP>
<Comune>Roma</Comune>
<Provincia>RM</Provincia>
<Nazione>IT</Nazione>
</Sede>
</CedentePrestatore>
<CessionarioCommittente>
<DatiAnagrafici>
<CodiceFiscale>RSSMRA85M01H501U</CodiceFiscale>
<Anagrafica>
<Nome>Mario</Nome>
<Cognome>Rossi</Cognome>
</Anagrafica>
</DatiAnagrafici>
</CessionarioCommittente>
</FatturaElettronicaHeader>
<FatturaElettronicaBody>
<DatiGenerali>
<DatiGeneraliDocumento>
<TipoDocumento>TD01</TipoDocumento> <!-- Regular invoice -->
<Divisa>EUR</Divisa>
<Data>2025-10-27</Data>
<Numero>A-12345</Numero>
<ImportoTotaleDocumento>12.10</ImportoTotaleDocumento>
</DatiGeneraliDocumento>
</DatiGenerali>
<DatiBeniServizi>
<DettaglioLinee>
<NumeroLinea>1</NumeroLinea>
<Descrizione>Menù fisso</Descrizione>
<Quantita>1.00</Quantita>
<PrezzoUnitario>10.00</PrezzoUnitario>
<PrezzoTotale>10.00</PrezzoTotale>
<AliquotaIVA>10.00</AliquotaIVA>
</DettaglioLinee>
<DatiRiepilogo>
<AliquotaIVA>10.00</AliquotaIVA>
<ImponibileImporto>10.00</ImponibileImporto>
<Imposta>1.00</Imposta>
<EsigibilitaIVA>I</EsigibilitaIVA> <!-- Immediate -->
</DatiRiepilogo>
</DatiBeniServizi>
</FatturaElettronicaBody>
</p:FatturaElettronica>

Response Format (Async via PEC or API):

<?xml version="1.0" encoding="UTF-8"?>
<ns3:RicevutaConsegna xmlns:ns3="http://www.fatturapa.gov.it/sdi/messaggi/v1.0">
<IdentificativoSdI>12345678</IdentificativoSdI>
<NomeFile>IT01234567890_00001.xml</NomeFile>
<Hash>7A3F2B8C9D1E4F6A...</Hash>
<DataOraRicezione>2025-10-27T14:30:00</DataOraRicezione>
<MessageId>123456</MessageId>
</ns3:RicevutaConsegna>

SDI Processing Workflow:

1. Fiscalization → Submit invoice XML via HTTPS POST

2. SDI → Return receipt ID (IdentificativoSdI) within 5 seconds

3. SDI → Validate invoice (schema, tax calculations, business rules)
↓ (5 minutes - 5 days)
4. SDI → Send notification via PEC/webhook:
- RicevutaConsegna (RC): Invoice accepted
- NotificaMancataConsegna (MC): Delivery failed (invalid recipient)
- NotificaScarto (NS): Invoice rejected (validation errors)
- NotificaEsito (NE): Final outcome (positive/negative)

Error Codes:

var sdiErrors = map[string]ErrorCode{
"00404": ErrorInvalidRecipient, // Codice Destinatario not found
"00300": ErrorInvalidXMLFormat, // Schema validation failed
"00423": ErrorDuplicateInvoice, // Invoice number already submitted
"00441": ErrorInvalidVATCalculation, // Tax calculation mismatch
"00200": ErrorInvalidCertificate, // Certificate not authorized
}

Rate Limits:

  • Production: 250 invoices/minute per certificate
  • Test: 10 invoices/minute
  • Max file size: 5 MB (XML)

Monitoring:

sdi_submissions_total{status="accepted|rejected|pending"}
sdi_processing_duration_seconds{outcome="RC|MC|NS|NE"}
sdi_certificate_expiry_days{location_id="loc_456"}

4. France - DGFiP (Direction Générale des Finances Publiques)

Purpose: E-invoicing and fiscal reporting for French businesses (mandatory from 2026).

API Endpoints:

Production: https://chorus-pro.gouv.fr/api/v1
Test: https://chorus-pro-sandbox.finances.gouv.fr/api/v1

Authentication:

  • OAuth 2.0 (client credentials flow)
  • SIRET number validation (French business registration)
  • API keys issued per location

Request Format (Factur-X/ZUGFeRD Hybrid PDF):

POST /invoices/submit
Authorization: Bearer {oauth_token}
Content-Type: application/json

{
"emitter": {
"siret": "12345678901234",
"name": "Restaurant Paris SARL",
"address": "123 Rue de Rivoli, 75001 Paris",
"vat_number": "FR12345678901"
},
"recipient": {
"siret": "98765432109876",
"name": "Client Entreprise SARL",
"address": "456 Avenue Champs-Élysées, 75008 Paris"
},
"invoice": {
"number": "FACT-2025-12345",
"date": "2025-10-27",
"currency": "EUR",
"total_amount": 120.00,
"total_vat": 20.00,
"total_excluding_vat": 100.00,
"payment_terms": "Net 30 days"
},
"lines": [
{
"description": "Menu déjeuner",
"quantity": 2,
"unit_price": 50.00,
"vat_rate": 20.0,
"total": 100.00
}
],
"attachments": [
{
"filename": "invoice_12345.pdf",
"content_base64": "JVBERi0xLjQKJeLjz9...",
"mime_type": "application/pdf"
}
]
}

Response Format:

{
"status": "accepted",
"invoice_id": "INV-FR-2025-001234567",
"submission_date": "2025-10-27T14:30:00Z",
"validation_status": "pending",
"tracking_url": "https://chorus-pro.gouv.fr/track/INV-FR-2025-001234567"
}

Error Codes:

var dgfipErrors = map[string]ErrorCode{
"SIRET_INVALID": ErrorInvalidBusinessID, // SIRET not registered
"VAT_MISMATCH": ErrorInvalidVATCalculation, // VAT calculation error
"PDF_CORRUPT": ErrorInvalidDocument, // Factur-X PDF malformed
"DUPLICATE": ErrorDuplicateInvoice, // Invoice already submitted
}

Rate Limits:

  • Production: 1000 requests/hour per API key
  • Test: 100 requests/hour
  • Max PDF size: 10 MB

Retry Strategy:

  • Transient errors (HTTP 503, 429): Retry with exponential backoff
  • Client errors (SIRET_INVALID, VAT_MISMATCH): No retry, return error immediately

Payment Processing - Stripe

Purpose: Handle customer subscriptions, usage-based billing, and webhook processing.

Integration Type: Official Stripe Go SDK (github.com/stripe/stripe-go/v76)

API Keys:

  • Production: sk_live_... (Secret Manager)
  • Test: sk_test_... (Secret Manager)
  • Publishable keys: Embedded in dashboard frontend

Core Operations:

1. Customer Creation

// When new account signs up
customer, err := customer.New(&stripe.CustomerParams{
Email: stripe.String(user.Email),
Name: stripe.String(user.CompanyName),
Description: stripe.String(fmt.Sprintf("Fiscalization Account: %s", accountID)),
Metadata: map[string]string{
"account_id": accountID,
"plan": "starter", // starter, professional, enterprise
},
})

2. Subscription Management

// Create subscription with usage-based billing
subscription, err := sub.New(&stripe.SubscriptionParams{
Customer: stripe.String(customer.ID),
Items: []*stripe.SubscriptionItemsParams{
{
Price: stripe.String("price_1234567890"), // Monthly base price
},
{
Price: stripe.String("price_usage_txn"), // Per-transaction metered billing
BillingThresholds: &stripe.SubscriptionItemBillingThresholdsParams{
UsageGTE: stripe.Int64(1000), // Alert at 1000 transactions
},
},
},
PaymentBehavior: stripe.String("default_incomplete"),
PaymentSettings: &stripe.SubscriptionPaymentSettingsParams{
SaveDefaultPaymentMethod: stripe.String("on_subscription"),
},
CollectionMethod: stripe.String("charge_automatically"),
})

3. Usage Reporting

// Report transaction volume for metered billing (runs daily cron job)
func ReportUsageToStripe(ctx context.Context, accountID string, transactionCount int64) error {
account, err := accountRepo.FindByID(ctx, accountID)
if err != nil {
return err
}

// Report usage to Stripe
_, err = usagerecord.New(&stripe.UsageRecordParams{
Quantity: stripe.Int64(transactionCount),
Timestamp: stripe.Int64(time.Now().Unix()),
SubscriptionItem: stripe.String(account.StripeSubscriptionItemID),
Action: stripe.String("set"), // set vs increment
})
return err
}

4. Webhook Handling

// Endpoint: POST /webhooks/stripe
// Validates signatures using webhook secret (whsec_...)

func HandleStripeWebhook(c *gin.Context) {
payload, _ := io.ReadAll(c.Request.Body)
signature := c.Request.Header.Get("Stripe-Signature")

event, err := webhook.ConstructEvent(payload, signature, webhookSecret)
if err != nil {
c.JSON(400, gin.H{"error": "Invalid signature"})
return
}

switch event.Type {
case "customer.subscription.deleted":
// Cancel account access, send notification
handleSubscriptionDeleted(event.Data.Object)

case "invoice.payment_failed":
// Alert user, grace period before suspension
handlePaymentFailed(event.Data.Object)

case "invoice.payment_succeeded":
// Extend billing cycle, send receipt email
handlePaymentSucceeded(event.Data.Object)

case "customer.subscription.trial_will_end":
// Remind user to add payment method (3 days before trial ends)
handleTrialEnding(event.Data.Object)
}

c.JSON(200, gin.H{"received": true})
}

Webhook Events Subscribed:

  • customer.subscription.created
  • customer.subscription.updated
  • customer.subscription.deleted
  • invoice.payment_succeeded
  • invoice.payment_failed
  • customer.subscription.trial_will_end
  • payment_method.attached
  • payment_method.detached

Rate Limits:

  • Production: 100 requests/second (shared across all API keys)
  • Webhook retries: 3 days with exponential backoff

Error Handling:

var stripeErrors = map[string]ErrorCode{
"card_declined": ErrorPaymentDeclined,
"insufficient_funds": ErrorInsufficientFunds,
"subscription_not_active": ErrorSubscriptionInactive,
"rate_limit": ErrorRateLimitExceeded,
}

Monitoring:

stripe_api_requests_total{operation="create_customer|create_subscription|report_usage"}
stripe_webhook_events_total{event_type="invoice.payment_succeeded"}
stripe_webhook_processing_duration_seconds
stripe_payment_failures_total{reason="card_declined|insufficient_funds"}

Email Delivery - Postmark

Purpose: Send transactional emails (receipts, alerts, password resets, trial reminders).

Integration Type: Official Postmark Go SDK (github.com/keighl/postmark)

API Keys:

  • Production Server Token: {postmark_server_token} (Secret Manager)
  • Test Server Token: {postmark_test_token} (Secret Manager)

Email Templates:

1. Transaction Receipt Email

// Template ID: receipt-confirmation (created in Postmark dashboard)
err := postmarkClient.SendTemplatedEmail(ctx, postmark.TemplatedEmail{
TemplateID: 12345678, // receipt-confirmation
TemplateModel: map[string]interface{}{
"customer_name": transaction.CustomerName,
"transaction_id": transaction.ID,
"amount": fmt.Sprintf("%.2f", transaction.Amount),
"currency": transaction.Currency,
"transaction_date": transaction.CreatedAt.Format("02 Jan 2006 15:04"),
"receipt_pdf_url": fmt.Sprintf("https://receipts.zyntem.com/%s", transaction.ID),
"qr_code_url": transaction.QRCodeURL,
"merchant_name": location.BusinessName,
"merchant_address": location.Address,
},
From: "receipts@zyntem.com",
To: transaction.CustomerEmail,
Tag: "receipt",
TrackOpens: true,
})

Template HTML (receipt-confirmation):

<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Receipt - {{merchant_name}}</title>
</head>
<body style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
<h1>Receipt from {{merchant_name}}</h1>
<p>Hi {{customer_name}},</p>
<p>Thank you for your purchase on {{transaction_date}}.</p>

<table style="width: 100%; border-collapse: collapse; margin: 20px 0;">
<tr>
<td style="padding: 10px; border: 1px solid #ddd;"><strong>Transaction ID:</strong></td>
<td style="padding: 10px; border: 1px solid #ddd;">{{transaction_id}}</td>
</tr>
<tr>
<td style="padding: 10px; border: 1px solid #ddd;"><strong>Amount:</strong></td>
<td style="padding: 10px; border: 1px solid #ddd;">{{amount}} {{currency}}</td>
</tr>
</table>

<p><a href="{{receipt_pdf_url}}" style="background: #0066cc; color: white; padding: 10px 20px; text-decoration: none; border-radius: 5px;">Download Receipt (PDF)</a></p>

<img src="{{qr_code_url}}" alt="Transaction QR Code" style="width: 200px; height: 200px; margin: 20px 0;">

<hr>
<p style="font-size: 12px; color: #666;">
{{merchant_name}}<br>
{{merchant_address}}<br>
This is an automated receipt from Fiscalization.
</p>
</body>
</html>

2. Certificate Expiry Alert

// Template ID: certificate-expiry-warning
err := postmarkClient.SendTemplatedEmail(ctx, postmark.TemplatedEmail{
TemplateID: 12345679,
TemplateModel: map[string]interface{}{
"location_name": location.Name,
"days_until_expiry": daysRemaining,
"expiry_date": location.CertificateExpiresAt.Format("02 Jan 2006"),
"renewal_url": fmt.Sprintf("https://dashboard.zyntem.com/locations/%s/certificate", location.ID),
},
From: "alerts@zyntem.com",
To: account.OwnerEmail,
Tag: "certificate-alert",
})

3. Payment Failed Notification

// Template ID: payment-failed
err := postmarkClient.SendTemplatedEmail(ctx, postmark.TemplatedEmail{
TemplateID: 12345680,
TemplateModel: map[string]interface{}{
"account_name": account.CompanyName,
"amount_due": fmt.Sprintf("%.2f", invoice.AmountDue),
"currency": invoice.Currency,
"payment_retry_url": "https://dashboard.zyntem.com/billing",
"grace_period_days": 7,
},
From: "billing@zyntem.com",
To: account.OwnerEmail,
Tag: "billing",
})

Email Tags (for analytics):

  • receipt - Transaction receipts
  • certificate-alert - Certificate expiry warnings
  • billing - Payment and subscription emails
  • auth - Password resets, login notifications
  • onboarding - Welcome emails, getting started guides

Rate Limits:

  • Production: 10,000 emails/day (upgradeable)
  • Sandbox: 500 emails/day
  • Batch sending: 500 emails per batch

Bounce/Spam Handling:

// Webhook endpoint: POST /webhooks/postmark
func HandlePostmarkWebhook(c *gin.Context) {
var event postmark.WebhookEvent
if err := c.BindJSON(&event); err != nil {
c.JSON(400, gin.H{"error": "Invalid payload"})
return
}

switch event.RecordType {
case "Bounce":
// Hard bounce: Mark email as invalid, disable future sends
if event.Type == "HardBounce" {
accountRepo.MarkEmailInvalid(ctx, event.Email, "hard_bounce")
}

case "SpamComplaint":
// User marked email as spam: Unsubscribe from marketing
accountRepo.UpdateEmailPreferences(ctx, event.Email, EmailPreferences{
Marketing: false,
Receipts: true, // Keep transactional
})

case "Delivery":
// Email successfully delivered
emailLogRepo.MarkDelivered(ctx, event.MessageID)
}

c.JSON(200, gin.H{"received": true})
}

Monitoring:

postmark_emails_sent_total{tag="receipt|certificate-alert|billing"}
postmark_delivery_rate{tag="receipt"} // % successfully delivered
postmark_bounce_rate{type="HardBounce|SoftBounce"}
postmark_spam_complaints_total
postmark_api_errors_total{error_type="rate_limit|invalid_recipient"}

AI Services - Claude API

Purpose: Error translation, adapter scaffolding, and AI-assisted operations.

Integration Type: Anthropic Go SDK (github.com/anthropics/anthropic-sdk-go)

API Keys:

  • Production: sk-ant-api03-... (Secret Manager, restricted to Core API service account)
  • Test: sk-ant-api03-... (separate test key)

Use Cases:

1. Error Translation (Spanish/Italian/French → English)

// When tax authority returns error in local language
func TranslateError(ctx context.Context, errorText string, country string) (EnglishError, error) {
prompt := fmt.Sprintf(`
You are a tax compliance expert. Translate the following error message from a %s tax authority API into English, and provide actionable resolution steps.

Original Error:
%s

Respond in JSON format:
{
"english_translation": "...",
"error_category": "certificate|validation|network|timeout",
"resolution_steps": ["Step 1", "Step 2"],
"requires_customer_action": true/false
}
`, country, errorText)

response, err := claudeClient.Messages.New(ctx, anthropic.MessageNewParams{
Model: anthropic.F(anthropic.ModelClaude_3_5_Sonnet_20241022),
MaxTokens: anthropic.Int(1024),
Messages: []anthropic.MessageParam{
anthropic.NewUserMessage(anthropic.NewTextBlock(prompt)),
},
})

if err != nil {
return EnglishError{}, err
}

var result EnglishError
json.Unmarshal([]byte(response.Content[0].Text), &result)
return result, nil
}

Example Translation:

Input (Spanish TicketBAI Error):
"TBAI240 - El certificado proporcionado no está autorizado para emitir facturas en esta jurisdicción"

Output:
{
"english_translation": "The provided certificate is not authorized to issue invoices in this jurisdiction",
"error_category": "certificate",
"resolution_steps": [
"Verify the certificate was issued by an authorized Basque Country CA",
"Check certificate expiration date in location settings",
"Ensure certificate NIF matches business registration",
"Re-upload certificate in dashboard: Settings → Locations → [Location] → Certificate"
],
"requires_customer_action": true
}

2. Adapter Scaffolding (New Country Expansion)

// When adding new country (e.g., Germany)
func GenerateAdapterScaffold(ctx context.Context, countryName string, apiDocs string) (AdapterCode, error) {
prompt := fmt.Sprintf(`
You are a fiscalization expert helping build country adapters for Fiscalization by Zyntem. Generate Go code for a %s adapter.

API Documentation:
%s

Generate:
1. adapter.go: Main adapter struct implementing FiscalizationAdapter interface
2. models.go: Request/response structs matching API format
3. errors.go: Country-specific error codes and mappings
4. adapter_test.go: Unit tests with mock API responses

Follow existing adapter patterns (Spain/Italy/France). Use Go 1.21, Gin framework, structured logging (zerolog).
`, countryName, apiDocs)

response, err := claudeClient.Messages.New(ctx, anthropic.MessageNewParams{
Model: anthropic.F(anthropic.ModelClaude_3_5_Sonnet_20241022),
MaxTokens: anthropic.Int(8192), // Large output for code generation
Messages: []anthropic.MessageParam{
anthropic.NewUserMessage(anthropic.NewTextBlock(prompt)),
},
})

// Parse response into AdapterCode struct with individual files
return parseAdapterCode(response.Content[0].Text), nil
}

3. Dashboard Query Assistant (Future Feature)

// Natural language queries for transaction data
// "Show me all failed transactions in Italy last week"
// → Converts to SQL query with safety checks

Rate Limits:

  • Claude 3.5 Sonnet: 1000 requests/minute
  • Input tokens: 200,000/minute
  • Output tokens: 40,000/minute

Cost Optimization:

// Cache expensive prompts (adapter scaffolding uses same base prompt)
cacheParams := anthropic.PromptCachingBetaMessageNewParams{
System: []anthropic.PromptCachingBetaTextBlockParam{
{
Type: anthropic.F(anthropic.PromptCachingBetaTextBlockTypeText),
Text: anthropic.String(baseSystemPrompt), // e.g., "You are a tax compliance expert..."
CacheControl: anthropic.F(anthropic.PromptCachingBetaCacheControlEphemeralParam{
Type: anthropic.F(anthropic.PromptCachingBetaCacheControlEphemeralTypeEphemeral),
}),
},
},
// ... rest of params
}
// Reduces cost by 90% for repeated adapter generation requests

Error Handling:

var claudeErrors = map[string]ErrorCode{
"rate_limit_exceeded": ErrorAIRateLimitExceeded,
"overloaded": ErrorAIServiceUnavailable,
"invalid_api_key": ErrorAIAuthenticationFailed,
}

// Graceful degradation: If Claude API fails, return untranslated error
if err != nil {
log.Warn().Err(err).Msg("Claude API unavailable, returning raw error")
return EnglishError{
EnglishTranslation: errorText, // Return original text
ErrorCategory: "unknown",
RequiresCustomerAction: true,
}, nil
}

Monitoring:

claude_api_requests_total{use_case="error_translation|adapter_scaffolding"}
claude_api_latency_seconds{model="claude-3-5-sonnet-20241022"}
claude_api_tokens_used_total{type="input|output"}
claude_api_cost_usd_total // Track monthly spend
claude_api_errors_total{error_type="rate_limit|overloaded"}

Shared External API Patterns

Circuit Breaker Implementation

// Prevents cascading failures when external APIs are down
type CircuitBreaker struct {
maxFailures int
resetTimeout time.Duration
state State // Closed, Open, HalfOpen
failureCount int
lastFailureTime time.Time
}

func (cb *CircuitBreaker) Call(ctx context.Context, fn func() error) error {
if cb.state == Open {
if time.Since(cb.lastFailureTime) > cb.resetTimeout {
cb.state = HalfOpen // Try one request
} else {
return ErrCircuitBreakerOpen
}
}

err := fn()
if err != nil {
cb.failureCount++
cb.lastFailureTime = time.Now()

if cb.failureCount >= cb.maxFailures {
cb.state = Open
log.Error().Str("service", "external_api").Msg("Circuit breaker opened")
}
return err
}

// Success: Reset counter
cb.failureCount = 0
cb.state = Closed
return nil
}

Configuration per Service:

var circuitBreakerConfig = map[string]CircuitBreakerConfig{
"ticketbai": {MaxFailures: 5, ResetTimeout: 30 * time.Second},
"sdi": {MaxFailures: 3, ResetTimeout: 60 * time.Second},
"stripe": {MaxFailures: 10, ResetTimeout: 10 * time.Second}, // Higher tolerance
"claude": {MaxFailures: 2, ResetTimeout: 120 * time.Second}, // Aggressive for non-critical
}

Request Timeout Strategy

var requestTimeouts = map[string]time.Duration{
"ticketbai": 30 * time.Second, // Spain TicketBAI
"batuz": 45 * time.Second, // Spain BATUZ (slower)
"sdi": 60 * time.Second, // Italy SDI (async processing)
"dgfip": 30 * time.Second, // France DGFiP
"stripe": 10 * time.Second, // Fast payment API
"postmark": 5 * time.Second, // Email submission
"claude": 30 * time.Second, // AI processing
}

// Enforce timeout with context
ctx, cancel := context.WithTimeout(context.Background(), requestTimeouts["ticketbai"])
defer cancel()

Retry Backoff Strategy

func ExponentialBackoff(attempt int) time.Duration {
base := 2 * time.Second
max := 32 * time.Second
duration := time.Duration(math.Pow(2, float64(attempt))) * base

if duration > max {
return max
}
return duration
}

// Usage in adapter
func (a *SpainAdapter) SubmitTransaction(ctx context.Context, tx Transaction) error {
maxAttempts := 5

for attempt := 1; attempt <= maxAttempts; attempt++ {
err := a.httpClient.Post(ctx, tx)

if err == nil {
return nil // Success
}

if !isRetryable(err) {
return err // Client error, don't retry
}

if attempt < maxAttempts {
backoff := ExponentialBackoff(attempt)
log.Warn().Int("attempt", attempt).Dur("backoff", backoff).Msg("Retrying request")
time.Sleep(backoff)
}
}

return ErrMaxRetriesExceeded
}

Health Check Endpoints

// Endpoint: GET /health/external-apis
// Returns status of all external dependencies

func HealthCheckExternalAPIs(c *gin.Context) {
checks := []HealthCheck{
checkTicketBAI(),
checkSDI(),
checkStripe(),
checkPostmark(),
checkClaude(),
}

allHealthy := true
for _, check := range checks {
if check.Status != "healthy" {
allHealthy = false
}
}

status := 200
if !allHealthy {
status = 503 // Service Unavailable
}

c.JSON(status, gin.H{
"status": ternary(allHealthy, "healthy", "degraded"),
"checks": checks,
"timestamp": time.Now().Format(time.RFC3339),
})
}

func checkTicketBAI() HealthCheck {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()

// Ping test endpoint
resp, err := http.Get("https://pruebas-ticketbai.bizkaia.eus/health")
if err != nil || resp.StatusCode != 200 {
return HealthCheck{
Service: "ticketbai",
Status: "unhealthy",
Message: "Test endpoint unreachable",
}
}

return HealthCheck{
Service: "ticketbai",
Status: "healthy",
Latency: "45ms",
}
}

External API Monitoring Dashboard

Grafana Panels:

  1. API Success Rate by Service (Last 24 hours)
sum(rate(external_api_requests_total{status="success"}[5m])) by (service)
/ sum(rate(external_api_requests_total[5m])) by (service)
* 100
  1. P95 Latency by Service
histogram_quantile(0.95,
rate(external_api_duration_seconds_bucket[5m])
) by (service)
  1. Circuit Breaker Status
circuit_breaker_state{service=~"ticketbai|sdi|stripe"} == 2  # 2 = Open
  1. Monthly API Costs
sum(increase(claude_api_cost_usd_total[30d]))  # Claude API costs
+ sum(increase(postmark_emails_sent_total[30d])) * 0.01 # Postmark $0.01/email
+ sum(increase(stripe_api_requests_total[30d])) * 0.005 # Stripe $0.005/API call

Alerts: