Skip to main content

ADR-016: EInvoiceAdapter Contract

ADR-016 — EInvoiceAdapter Contract and Canonical UBL 2.1 Core

Status: Accepted Date: 2026-05-11 Author: Markos Zachariadis (Finverge — Payments & Fiscal Integration) Architecture Review: Petter Graff (CodeCraft) Decision-maker: CEO Alem Bašić Mehanik clearance: /tmp/mehanik-cleared-100362 MC Task: #100362 (Phase 0' ADR Consolidation) Cross-references:

  • ADR-015 (CountryPlugin — generateEInvoiceXml() and submitToFiscalPlatform() delegate to adapters)
  • ADR-019 (Integration Adapter Registry — adapter lifecycle, error codes, observability)
  • ADR-023 §3.3 (backend country differentiation — market selected before adapter dispatch)
  • apps/api/src/main/kotlin/no/alai/bilko/einvoice/EInvoiceTypes.kt (canonical types on disk)
  • apps/api/src/main/kotlin/no/alai/bilko/country/hr/StorecoveHrFiskEInvoiceAdapter.kt (HR reference)
  • Plan v3 §4b ADR-016 requirement — ~/system/specs/bilko-multi-market-architecture-plan-v3-2026-05-11.md

1. Context

1.1 The Four-Platform Problem

Bilko targets four tax jurisdictions with four incompatible e-invoice fiscal platforms:

Market Platform Transport Format Status
HR (Croatia) HR-FISK / FINA via Storecove Peppol AS4 UBL 2.1 + HR CIUS STUB (MC #8675)
RS (Serbia) SEF (efaktura.gov.rs) REST API SEF XML (Serbian-specific) Phase 1S
BA-FED CPF (Centralna platforma za fakture) TBD ~2027 TBD Phase 1B
BA-RS UINO (stub name) TBD TBD Phase 1B

These platforms differ in:

  • Wire format (UBL 2.1 / SEF XML / CPF JSON / UINO — each with platform-specific namespaces)
  • Transport protocol (Peppol AS4 / REST / TBD)
  • Authentication (OAuth2 / API key / certificate)
  • Submission lifecycle (synchronous response / async polling / webhook)
  • Error codes and retry semantics

Without a canonical abstraction, each new market's integration detail bleeds into the core invoice service — the same Variant B problem documented in ADR-bilko-002.

1.2 Existing Types on Disk (verified 2026-05-11)

EInvoiceTypes.kt already defines the following (lines 1–224):

  • AdapterLifecycleState enum: STUB, SANDBOX_VERIFIED, PRODUCTION (lines 22–26)
  • EInvoiceStatus enum: PENDING, APPROVED, REJECTED, CANCELLED, ERROR (lines 32–38)
  • InvoiceTypeCode class: UNTDID codes 380, 381, 383, 384 (lines 48–60)
  • Address, PartyInfo / Party typealias (lines 66–81)
  • PaymentMeans: paymentMeansCode, paymentReference, iban (lines 87–93)
  • TaxCategory enum: S, Z, E, K, G, O, AE per EN 16931 BT-118 (lines 102–111)
  • TaxBreakdown: taxableAmount, taxAmount, taxRate, taxCategory (lines 116–122)
  • InvoiceLine: lineId, description, quantity, unitPrice, lineTotal, taxRate, taxCategory (lines 127–135)
  • CanonicalInvoice: full canonical model (lines 141–156) — detailed below
  • SubmitResult: platformInvoiceId, initialStatus, submittedAt, rawResponse (lines 162–167)
  • InvoiceTotals compute object (lines 172–195)
  • EInvoiceAdapter interface with 4 methods (lines 200–224)

The EInvoiceAdapter interface exists but is not formally documented. StorecoveHrFiskEInvoiceAdapter implements it directly with serialize() fully operational (offline, no credentials). This ADR formalises the contract.


2. Decision

2.1 EInvoiceAdapter Interface — Formal Contract

The interface is defined in apps/api/src/main/kotlin/no/alai/bilko/einvoice/EInvoiceTypes.kt lines 200–224. Reproduced here in full as the normative specification:

interface EInvoiceAdapter {
    val jurisdiction: TaxJurisdiction
    val lifecycleState: AdapterLifecycleState

    /**
     * Serialize a canonical invoice to the adapter-specific wire format.
     *
     * CONTRACT:
     * - MUST be offline-capable (no network, no credentials).
     * - MUST be deterministic for the same [invoice] input.
     * - MUST NOT log raw invoice fields (OIB, IBAN, document_data) — use sanitizeForLog().
     * - Returns the full wire-format payload: UBL 2.1 XML for HR, SEF XML for RS, etc.
     * - For HR: returns Storecove JSON envelope wrapping UBL 2.1 XML.
     *
     * Throws [AdapterException] with VALIDATION_BUSINESS_RULE for constraint violations
     * (e.g., non-EUR currency for HR, invalid OIB, empty invoice lines).
     */
    fun serialize(invoice: CanonicalInvoice): ByteArray

    /**
     * Submit the serialized invoice bytes to the fiscal platform.
     *
     * CONTRACT:
     * - Requires live credentials (API key, OAuth token, certificate).
     * - MUST include idempotency key (platform-specific; see §2.3).
     * - Returns [SubmitResult] on success; throws [AdapterException] on all failures.
     * - NEVER throws platform-native exceptions (Ktor ResponseException, etc.) —
     *   map to [AdapterException] before propagating.
     * - Implementations in STUB lifecycle throw NOT_IMPLEMENTED.
     *
     * @param serializedInvoice the bytes returned by [serialize]
     * @param invoice the original canonical invoice (for idempotency key generation)
     */
    fun submit(serializedInvoice: ByteArray, invoice: CanonicalInvoice): SubmitResult

    /**
     * Poll the fiscal platform for the current status of a submitted invoice.
     *
     * CONTRACT:
     * - [submissionId] is the [SubmitResult.platformInvoiceId] from [submit].
     * - Returns current [EInvoiceStatus].
     * - Callers should implement exponential backoff; this method does not retry internally.
     * - Implementations in STUB lifecycle throw NOT_IMPLEMENTED.
     */
    fun pollStatus(submissionId: String, invoice: CanonicalInvoice): EInvoiceStatus

    /**
     * Parse an inbound invoice from a raw platform webhook payload.
     *
     * CONTRACT:
     * - [rawPayload] is the raw bytes from the fiscal platform webhook.
     * - Returns [CanonicalInvoice] with `adapterMetadata` populated for platform-specific fields.
     * - HR: extracts `hr.supplierOib`, `hr.buyerOib`, `hr.pozivNaBroj` into adapterMetadata.
     * - STUB lifecycle throws NOT_IMPLEMENTED.
     * - DO NOT log rawPayload before passing through sanitizeForLog().
     */
    fun parseIncoming(rawPayload: ByteArray): CanonicalInvoice
}

2.2 CanonicalInvoice — EN 16931 / UBL 2.1 Subset

CanonicalInvoice is the internal representation of an invoice, independent of any platform's wire format. It is an EN 16931 subset aligned with UBL 2.1 BG-* groups. Defined in EInvoiceTypes.kt lines 141–156:

data class CanonicalInvoice(
    val id: String,                          // Internal UUID — Storecove document_id (D2)
    val invoiceNumber: String,               // BT-1: human-readable invoice number
    val issueDate: LocalDate,                // BT-2
    val dueDate: LocalDate,                  // BT-9
    val typeCode: InvoiceTypeCode,           // BT-3: UNTDID 1001 (380=commercial, 381=credit)
    val currencyCode: String,                // BT-5: ISO 4217 ("EUR", "RSD", "BAM")
    val jurisdiction: TaxJurisdiction,       // Non-EN16931: routing discriminator
    val supplier: PartyInfo,                 // BG-4: name, taxId (OIB/PIB/JIB), address
    val buyer: PartyInfo,                    // BG-7: same structure
    val lines: List<InvoiceLine>,            // BG-25: quantity, unitPrice, lineTotal, taxRate
    val taxBreakdowns: List<TaxBreakdown>,   // BG-23: one entry per rate band
    val paymentMeans: PaymentMeans? = null,  // BG-16: paymentMeansCode, IBAN, reference
    val note: String? = null,                // BT-22: free text note
    val adapterMetadata: Map<String, String> = emptyMap(), // platform-specific extras
)

Field constraints (per EN 16931 and platform requirements):

Field Constraint Platform enforcement
currencyCode "EUR" for HR (HALT-3 in StorecoveHrFiskEInvoiceAdapter line 735–743) serialize() validates
supplier.taxId OIB (HR, 11-digit ISO 7064 MOD 11,10), PIB (RS, 9-digit), JIB (BA, 13-digit) serialize() validates
lines Non-empty — EN 16931 §BG-25 requires at least one line serialize() validates
taxBreakdowns Must sum to lines.sum(taxRate * lineTotal) — tolerance 0.01 Application service validates
adapterMetadata HR inbound: hr.supplierOib, hr.buyerOib, hr.pozivNaBroj parseIncoming() populates

What CanonicalInvoice is NOT:

  • Not a DB entity (stored as invoices + invoice_items tables, mapped on read)
  • Not a DTO for the REST API (API layer maps to/from its own request/response models)
  • Not versioned independently — it evolves with EN 16931 minor revisions

2.3 Adapter Lifecycle States

Defined in EInvoiceTypes.kt lines 22–26. This ADR formalises the transition criteria:

STUB  ──────────────────────►  SANDBOX_VERIFIED  ──────────────────────►  PRODUCTION
  │                                    │                                        │
  │ Compiles.                          │ 5 happy-path sandbox                   │ Securion audit passed.
  │ All methods throw                  │ test cases pass with                   │ 30d stage soak with
  │ AdapterErrorCode.                  │ real platform submission               │ zero critical errors.
  │ NOT_IMPLEMENTED.                   │ IDs (not mocked).                      │ AdapterConfig(enabled=true)
  │ AdapterConfig row                  │ Real platform response                 │ in production DB.
  │ may not exist yet.                 │ logged and archived.                   │
  └────────────────────────────────────┴────────────────────────────────────────┘

HR current state (2026-05-11): STUB

  • serialize() WORKS (offline, no credentials). Verified by unit tests.
  • submit() throws NOT_IMPLEMENTED — blocked on MC #8675 (Storecove account).
  • pollStatus() throws NOT_IMPLEMENTED.
  • parseIncoming() throws NOT_IMPLEMENTED.

Transition to SANDBOX_VERIFIED requires (HR):

  1. MC #8675 resolved: Storecove API key + STORECOVE_LEGAL_ENTITY_ID in GCP Secret Manager
  2. 5 sandbox test invoices submitted via submit() and polling confirmed via pollStatus():
    • B2B outbound commercial invoice
    • B2G outbound to HR government entity
    • Credit note (typeCode 381)
    • Cancelled invoice workflow
    • Inbound invoice from Storecove webhook
  3. All 5 produce real Storecove submission GUIDs (not mock strings)
  4. Proveo evidence file with submission IDs uploaded to BookStack

Transition to PRODUCTION requires (any adapter):

  1. SANDBOX_VERIFIED state already achieved
  2. Securion audit of adapter error handling and PII sanitization
  3. 30 continuous days on stage Cloud Run with zero AdapterErrorCode.PLATFORM_INTERNAL_ERROR alerts (per Prometheus bilko_integration_request_total metric)
  4. AdapterConfig(market=HR, adapter_type=EINVOICE, enabled=true) row in production DB

2.4 Per-Platform XML Namespace Mapping

The canonical CanonicalInvoice fields map to platform-specific XML/JSON elements:

CanonicalInvoice field HR (UBL 2.1 / Peppol) RS (SEF XML) BA-FED (CPF stub) BA-RS (UINO stub)
supplier.taxId AccountingSupplierParty/Party/PartyTaxScheme/CompanyID @schemeID="9934" (OIB) /Invoice/Seller/TaxId (PIB) TBD TBD
buyer.taxId AccountingCustomerParty/Party/PartyTaxScheme/CompanyID @schemeID="9934" (OIB) /Invoice/Buyer/TaxId (PIB) TBD TBD
invoiceNumber cbc:ID /Invoice/InvoiceNumber TBD TBD
issueDate cbc:IssueDate (ISO 8601) /Invoice/IssueDate TBD TBD
typeCode.untdidCode cbc:InvoiceTypeCode (380/381/384) /Invoice/InvoiceType TBD TBD
currencyCode cbc:DocumentCurrencyCode + @currencyID on amounts /Invoice/Currency TBD TBD
taxBreakdowns[].taxRate TaxSubtotal/TaxCategory/Percent /Invoice/TaxTotal/TaxRate TBD TBD
taxBreakdowns[].taxCategory TaxSubtotal/TaxCategory/ID (S/Z/E/K) Serbian code set TBD TBD
paymentMeans.paymentReference PaymentMeans/PaymentID ("Poziv na broj" HR) /Invoice/PaymentReference TBD TBD
paymentMeans.iban PayeeFinancialAccount/ID /Invoice/BankAccount/IBAN TBD TBD

HR CustomizationID note (StorecoveHrFiskEInvoiceAdapter.kt lines 247–252): Two candidates — default is CUSTOMIZATION_ID_PEPPOL_BIS3. Verify with Storecove support before MC #8675 activation which to use for HR-FISK submission.

SEF XML note (RS): SEF does not use UBL 2.1. It uses a Serbian-specific XML schema published by the Ministry of Finance. The SEF adapter (Phase 1S) must map from CanonicalInvoice to SEF schema — NOT through UBL. The EInvoiceAdapter.serialize() contract requires only that the output is the platform's native wire format.

2.5 HR Reference Implementation

StorecoveHrFiskEInvoiceAdapter at apps/api/src/main/kotlin/no/alai/bilko/country/hr/StorecoveHrFiskEInvoiceAdapter.kt is the reference implementation for all EInvoiceAdapter decisions in this ADR. Key design choices to replicate in future adapters:

Decision Location in reference impl Rationale
PII field redaction before logging Lines 24–59 (REDACT_PII_FIELDS, sanitizeForLog) GDPR / HR-FISK audit rules
Offline serialization (no credentials) Lines 567–571 (serialize()) Allows testing and invoice preview without platform account
Idempotency key (SHA-256 of invoice.id + invoiceNumber) Lines 591–600 (stub comment) Storecove 409 deduplication (D5)
Credential validation on startup flag Lines 83–138 (validateOnStartup, validate()) Fail-fast in production, permissive in test
Error code mapping to AdapterException Lines 469–515 (StorecoveErrorMapper) NEVER propagate platform-native exceptions
Structured metrics recording Lines 537–540 (StorecoveMetrics) Per-adapter Prometheus counters
ISO 7064 MOD 11,10 OIB validation Lines 194–225 (StorecoveOibValidator) HR-FISK rejects invalid OIB with no detailed error

3. Adapter Lifecycle Governance

3.1 AdapterConfig Feature Flag

All adapters are gated by an AdapterConfig row in the database (ADR-019 §2.4):

-- Table defined in ADR-019; referenced here for completeness
CREATE TABLE adapter_config (
    id           UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    market       VARCHAR(8) NOT NULL,   -- TaxJurisdiction enum value
    adapter_type VARCHAR(32) NOT NULL,  -- 'EINVOICE', 'BANK_STATEMENT', etc.
    enabled      BOOLEAN NOT NULL DEFAULT FALSE,
    reason       TEXT,                  -- Why disabled (e.g., "MC #8675 pending")
    updated_at   TIMESTAMPTZ NOT NULL DEFAULT now(),
    UNIQUE (market, adapter_type)
);

No EInvoiceAdapter submit path executes unless adapter_config.enabled = true for (market, 'EINVOICE'). This allows disabling a broken adapter without a redeploy.

3.2 Versioning

Adapters are versioned independently of the CountryPlugin version. Each adapter implementation exposes a val adapterVersion: String property (e.g., "1.0.0"). The CountryPlugin implementation declares a minimum adapter version it is compatible with. Incompatibility detected at startup → application fails fast with a clear error.


4. Consequences

4.1 Positive

  • Offline serialization. serialize() contract requires no network. Enables invoice PDF preview, offline testing, and regression test suites without live platform credentials.
  • Uniform error handling. AdapterException (defined in ADR-019) is the only exception type that crosses the adapter boundary. Callers implement one error handler, not four platform-specific ones.
  • Lifecycle visibility. lifecycleState is a first-class property. Monitoring dashboards can show "HR adapter: STUB" and alert when a market is operating in degraded lifecycle state.
  • Canonical model. CanonicalInvoice enables cross-market reporting, invoice history normalisation, and future features (e.g., group invoice analytics across HR + RS).

4.2 Negative

  • SEF XML schema maintenance. RS's SEF format is published by a government ministry and changes without semantic versioning guarantees. The adapter must track schema version changes proactively.
  • BA adapters are TBD. CPF and UINO platforms do not have published APIs as of 2026-05-11. Phase 1B adapter work cannot begin until the regulatory mandates define the technical specification (~2027 per plan v3 §4 context).

4.3 Risks

  • CanonicalInvoice field gap. A platform-specific field required by one market (e.g., HR's "Poziv na broj" payment reference) may not have a canonical counterpart. Resolution: use adapterMetadata: Map<String, String> for platform-specific extras until they are common enough to merit a first-class field.
  • OIB/PIB/JIB validation at serialize() vs submit(). Validation at serialize() time gives earlier errors (no network needed) but requires the adapter to know tax ID rules for each market — coupling format and validation. Current approach (HR validates in serialize()) is pragmatic; RS and BA adapters should follow the same pattern.

5. References

Reference Path Lines
EInvoiceAdapter interface (canonical) apps/api/src/main/kotlin/no/alai/bilko/einvoice/EInvoiceTypes.kt 200–224
CanonicalInvoice definition apps/api/src/main/kotlin/no/alai/bilko/einvoice/EInvoiceTypes.kt 141–156
AdapterLifecycleState enum apps/api/src/main/kotlin/no/alai/bilko/einvoice/EInvoiceTypes.kt 22–26
TaxCategory enum (EN 16931 BT-118) apps/api/src/main/kotlin/no/alai/bilko/einvoice/EInvoiceTypes.kt 102–111
HR reference impl — full file apps/api/src/main/kotlin/no/alai/bilko/country/hr/StorecoveHrFiskEInvoiceAdapter.kt 1–777
HR CustomizationID constants StorecoveHrFiskEInvoiceAdapter.kt 247–252
OIB validator (ISO 7064) StorecoveHrFiskEInvoiceAdapter.kt 194–225
PII sanitize helper StorecoveHrFiskEInvoiceAdapter.kt 24–59
Error mapper (HTTP → AdapterErrorCode) StorecoveHrFiskEInvoiceAdapter.kt 469–515
ADR-bilko-003 §Layer 2 (EInvoice serialization layer) ~/system/specs/bilko-multi-market-architecture-plan/ADR-bilko-003-market-abstraction-layers.md 103–117
Plan v3 §4b ADR-016 requirement ~/system/specs/bilko-multi-market-architecture-plan-v3-2026-05-11.md 125–126
Plan v3 §4d HR Critical Path (sandbox verification) ~/system/specs/bilko-multi-market-architecture-plan-v3-2026-05-11.md 147–176

6. Approval

Status: Accepted Unblocks:

  • Phase 1H Task 1H.2: PluginHR.generateEInvoiceXml() delegation to StorecoveHrFiskEInvoiceAdapter
  • Phase 1H Task 0'3 (ADR-019): Adapter Registry formalises lifecycle governance
  • MC #8675: Storecove account activation → SANDBOX_VERIFIED transition
Role Sign Date
Finverge — Markos Zachariadis Signed 2026-05-11
Architecture Lead (Petter Graff) Signed 2026-05-11
CEO (Alem Bašić) Not required for contract ADR

7. Document History

Date Author Change
2026-05-11 Markos Zachariadis / Petter Graff Initial — Phase 0' ADR consolidation (MC #100362)