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()andsubmitToFiscalPlatform()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):
AdapterLifecycleStateenum:STUB,SANDBOX_VERIFIED,PRODUCTION(lines 22–26)EInvoiceStatusenum:PENDING,APPROVED,REJECTED,CANCELLED,ERROR(lines 32–38)InvoiceTypeCodeclass: UNTDID codes 380, 381, 383, 384 (lines 48–60)Address,PartyInfo/Partytypealias (lines 66–81)PaymentMeans:paymentMeansCode,paymentReference,iban(lines 87–93)TaxCategoryenum:S, Z, E, K, G, O, AEper 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 belowSubmitResult: platformInvoiceId, initialStatus, submittedAt, rawResponse (lines 162–167)InvoiceTotalscompute object (lines 172–195)EInvoiceAdapterinterface 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_itemstables, 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()throwsNOT_IMPLEMENTED— blocked on MC #8675 (Storecove account).pollStatus()throwsNOT_IMPLEMENTED.parseIncoming()throwsNOT_IMPLEMENTED.
Transition to SANDBOX_VERIFIED requires (HR):
- MC #8675 resolved: Storecove API key +
STORECOVE_LEGAL_ENTITY_IDin GCP Secret Manager - 5 sandbox test invoices submitted via
submit()and polling confirmed viapollStatus():- B2B outbound commercial invoice
- B2G outbound to HR government entity
- Credit note (typeCode 381)
- Cancelled invoice workflow
- Inbound invoice from Storecove webhook
- All 5 produce real Storecove submission GUIDs (not mock strings)
- Proveo evidence file with submission IDs uploaded to BookStack
Transition to PRODUCTION requires (any adapter):
- SANDBOX_VERIFIED state already achieved
- Securion audit of adapter error handling and PII sanitization
- 30 continuous days on stage Cloud Run with zero
AdapterErrorCode.PLATFORM_INTERNAL_ERRORalerts (per Prometheusbilko_integration_request_totalmetric) 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.
lifecycleStateis a first-class property. Monitoring dashboards can show "HR adapter: STUB" and alert when a market is operating in degraded lifecycle state. - Canonical model.
CanonicalInvoiceenables 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 inserialize()) 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 toStorecoveHrFiskEInvoiceAdapter - 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) |