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
UsageMetricentity - 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 authoritiesrevoked_atset → 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 Requestcountry=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 recoveryChainState.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)itemsmust have at least 1 itemtimestampcannot 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)Identifiersoptional → No validation in MVP, accept any stringMetadata→ 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:
emailorphone→ Enables "Email me receipt" feature (Phase 2)loyalty_id→ Merchant can link transactions to their CRMcard_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 Francetax_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-Signatureheader (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.failedlocation.created,location.updated,certificate.uploadedapi_key.created,api_key.revokedwebhook.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
transactionstable (WHEREstatus=success), insertsusage_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 beforenot_after, email alert sentstatus=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
| Entity | Key Constraints |
|---|---|
| Account | status IN (active, suspended, cancelled) |
| APIKey | environment IN (test, production), key_hash SHA-256 |
| Location | country ISO 3166-1 alpha-2, tax_id validated by country |
| Transaction | PretaxAmount + TaxAmount = TotalAmount, items.length >= 1 |
| Item | Quantity * UnitPrice * (1 + TaxRate) = TotalAmount |
| FiscalReceipt | fiscal_id unique per location+country |
| Webhook | url HTTPS only, events non-empty |
| WebhookDelivery | attempt 1-5, status state machine enforced |
| IdempotencyKey | Unique (key, account_id), TTL 24 hours |
| Certificate | not_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
itemsJSONB → Search byidentifiers.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:
- Track analytics: What % of transactions include
identifiers? - If >20% → Prioritize Phase 2 features (search, analytics)
- 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
| Responsibility | Fiscalization | Customer (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_datafrom 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_dataBase64)
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
| Aspect | Pattern A (Compliant Receipt) | Pattern B (Basket Receipt) |
|---|---|---|
| Fiscal Compliance | ✅ Customer's responsibility | ❌ NOT COMPLIANT |
| Receipt Generation | Customer's POS | Fiscalization (convenience) |
| QR Code Printing | Customer (ESC/POS) | Fiscalization embeds |
| Payment Data | Customer handles | Not included (customer handles separately) |
| DCC Disclosure | Customer handles | Not included |
| Use Case | Production (90% of customers) | Testing, backups, mobile apps (10%) |
| Liability | Customer liable | Customer still liable |
| Response Size | ~5KB JSON | ~50KB JSON + PDF URL |
| basket_receipt_url | null | PDF 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
FiscalDataafter 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_urlif 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=falsequery param to skip generation (save costs)- Default: Always generate (convenience)
Phase 3: Email/SMS Delivery
customer_context.emailtriggers 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}/transactionsfor location-specific queries
3. Idempotency & Reliability
- POST
/transactionssupportsIdempotency-Keyheader (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
Linkheaders (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 key403 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, checkstatusfield)400 Bad Request: Validation error401 Unauthorized: Invalid API key429 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_hashrequired) - ✅ Fiscalization reads
Location.ChainState.LastHashinternally - ✅ Fiscalization calculates
new_hashand updatesLocation.ChainState.LastHash - ✅ Chain audit trail stored in
Transaction.ChainPreviousHashandTransaction.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:
| Status | Meaning | fiscal_data | Action |
|---|---|---|---|
fiscalized | Tax authority confirmed | ✅ Present | Print compliant receipt |
pending_fiscalization | Queued for retry | ❌ Null | Print temporary receipt |
failed | Permanent failure | ❌ Null | Fix configuration |
Idempotency Response (200 OK):
- Same
Idempotency-Keywithin 24 hours returns cached response - Status code:
200 OK(not201 Created) - Response body identical to original (preserves
statusfield) - 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 locationstatus(optional):success,failed,pendinglimit(optional): 1-100, default 50starting_after(optional): Cursor for paginationending_before(optional): Reverse paginationcreated_after(optional): ISO 8601 timestampcreated_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:
| Type | Status | Description |
|---|---|---|
validation_error | 400 | Request validation failed |
authentication_error | 401 | Missing or invalid API key |
authorization_error | 403 | Account suspended or key revoked |
resource_not_found | 404 | Resource does not exist |
rate_limit_exceeded | 429 | Too many requests |
tax_authority_error | 502 | Tax authority returned error |
internal_error | 500 | Unexpected 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(not201 Created) - Header:
X-Idempotent-Replayed: true - Body: Identical to original response
Pagination
Cursor-Based Pagination:
- Use
starting_aftercursor for forward pagination - Use
ending_beforecursor 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 aspending_fiscalization, nowfiscalized)transaction.failed: Transaction fiscalization permanently failedcertificate.expiring: Certificate expires in 30 dayscertificate.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:
- Extract timestamp
tand signaturev1 - Construct signed payload:
{t}.{request_body} - Compute HMAC-SHA256 with webhook secret
- Compare computed signature with
v1 - 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:
| Feature | Direct AEAT Integration | Fiscalization |
|---|---|---|
| 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:
fiscalizedimmediately - 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: HandlesPOST /v1/transactions(submit),GET /v1/transactions/:id(retrieve),GET /v1/transactions(list with pagination)LocationHandler: CRUD operations for locations, certificate upload validationAPIKeyHandler: Account creation + API key generationWebhookHandler: 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_xxxheader - 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-Keyheader - 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:
- Chain validation error from tax authority
- Database update failure
- Manual recovery API call (support/admin only)
- 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
| Service | Purpose | Environment | SLA | Criticality |
|---|---|---|---|---|
| Spain TicketBAI | Basque Country fiscal compliance | Production | 99.9% | Critical |
| Spain BATUZ | Basque Country e-invoicing | Production | 99.9% | Critical |
| Spain VERIFACTU | National Spanish anti-fraud system | Production | 99.5% | Critical |
| Italy SDI | Italian e-invoicing system | Production | 99.5% | Critical |
| France DGFiP | French tax authority API | Production | 99.5% | Critical |
| Stripe | Payment processing | Production | 99.99% | High |
| Postmark | Transactional email delivery | Production | 99.9% | Medium |
| Claude API | Error translation, adapter scaffolding | Production | 99.5% | Low |
| Google Cloud Storage | Receipt/QR storage | Production | 99.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:
| Feature | TicketBAI (Basque) | VERIFACTU (National) |
|---|---|---|
| Coverage | Basque Country only (Álava, Guipúzcoa, Vizcaya) | All of Spain |
| Authority | Regional tax agencies (Haciendas Forales) | AEAT (National Tax Agency) |
| Certificate Issuer | Regional CAs (Izenpe, etc.) | FNMT-RCM (national) |
| Invoice Chaining | Optional | Mandatory (each invoice references previous) |
| QR Code Format | TicketBAI URL | AEAT Verifactu URL |
| Software Registration | License number (LicenciaTBAI) | Installation number + device type |
| Submission Timing | Real-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:
- 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)
}
- 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:
- 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
}
- 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)
}
- 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:
- Official AEAT Verifactu Specification: https://sede.agenciatributaria.gob.es/verifactu/especificaciones
- FNMT-RCM Certificate Issuance: https://www.sede.fnmt.gob.es/certificados
- Developer Registration Portal: https://sede.agenciatributaria.gob.es/verifactu/desarrolladores
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.createdcustomer.subscription.updatedcustomer.subscription.deletedinvoice.payment_succeededinvoice.payment_failedcustomer.subscription.trial_will_endpayment_method.attachedpayment_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 receiptscertificate-alert- Certificate expiry warningsbilling- Payment and subscription emailsauth- Password resets, login notificationsonboarding- 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:
- 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
- P95 Latency by Service
histogram_quantile(0.95,
rate(external_api_duration_seconds_bucket[5m])
) by (service)
- Circuit Breaker Status
circuit_breaker_state{service=~"ticketbai|sdi|stripe"} == 2 # 2 = Open
- 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: