Skip to main content

ADR-016: EInvoice Adapter Lifecycle and Contract

# ADR-016 — EInvoiceAdapter ContractLifecycle and CanonicalContract

UBL 2.1 Core

**Status:** Accepted **Date:** 2026-05-1113 **Author:** Petter Graff (CodeCraft — Architecture Lead) **Finverge Co-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#100585 (Phase 0' ADR Consolidation)Consolidation — EInvoiceAdapter lifecycle) **Supersedes:** ADR-016 v1 (2026-05-11, MC #100362) — this is the authoritative version **Cross-references:

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

1. Context

### 1.1 The Four-Platform Problem

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

|Market|Platform|||------||||||||||
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'platform's integration detail bleeds into the core invoice service — reproducing the same Variant B coupling problem documented in (ADR-bilko-002.

002

§3). ### 1.2 Existing Types on Disk (verified 2026-05-11)

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

    -
  • AdapterLifecycleState`AdapterLifecycleState` enum: STUB`STUB`, SANDBOX_VERIFIED`SANDBOX_VERIFIED`, PRODUCTION`PRODUCTION` (lines- 22–26)
  • EInvoiceStatus`EInvoiceStatus` enum: PENDING`PENDING`, APPROVED`APPROVED`, REJECTED`REJECTED`, CANCELLED`CANCELLED`, ERROR`ERROR` (lines- 32–38)
  • InvoiceTypeCode class:`InvoiceTypeCode`: UNTDID codes 380, 381, 383, 384 (lines- 48–60)
  • Address`Address`, PartyInfo`PartyInfo` / Party`Party` typealias (lines- 66–81)
  • PaymentMeans`PaymentMeans`: paymentMeansCode`paymentMeansCode`, paymentReference`paymentReference`, iban`iban` (lines- 87–93)
  • TaxCategory`TaxCategory` enum: `S, Z, E, K, G, O, AEAE` per EN 16931 BT-118 (lines- 102–111)
  • `TaxBreakdown`,
  • TaxBreakdown:`InvoiceLine`, taxableAmount,`CanonicalInvoice`, taxAmount,`SubmitResult`, taxRate,`InvoiceTotals` 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`EInvoiceAdapter` interface with 4 methods + 2 properties `AdapterTypes.kt` (linesin 200–224)
  • `no.alai.bilko.adapter`)
defines:

- `AdapterErrorCode` enum with 10 codes including `NOT_IMPLEMENTED` - `AdapterException(code, market, retryable, rawPayload, message, cause)` The EInvoiceAdapter`EInvoiceAdapter` interface existsand lifecycle states exist but isare not formally documented. StorecoveHrFiskEInvoiceAdapter`StorecoveHrFiskEInvoiceAdapter` implements itthe directlyinterface with `serialize()` is fully operational (offline,offline; noall credentials)other methods throw `NOT_IMPLEMENTED`. This ADR formalises the contract.

contract
and

lifecycle governance. --- ## 2. Decision

### 2.1 EInvoiceAdapter Interface — Formal Contract

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

specification
with full contract annotations:

```kotlin
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).credentials required.
     * - MUST be deterministic for thedeterministic: same [invoice] input.input produces identical bytes.
     * - MUST NOT log raw invoicePII fields (OIB, IBAN, document_data) — usecall sanitizeForLog().
     * - Returns the full wire-format payload: UBL 2.1 XMLpayload for HR,the SEF XML for RS, etc.platform:
     *     - For HR: returns Storecove JSON envelope wrapping UBL 2.1 XML.XML
     *     RS: SEF XML (Serbian Ministry of Finance schema)
     *     BA: CPF/UINO platform format (TBD)
     * - Throws [AdapterException] with VALIDATION_BUSINESS_RULEAdapterException(VALIDATION_BUSINESS_RULE) for constraint violations
     *   (e.g., non-EUR currency for HR, invalid OIB, empty invoicelines, lines).etc.)
     * - AdapterConfig.enabled check is NOT performed here — callers check before invoking.
     * - This method is ALWAYS available, even in STUB lifecycle.
     */
    fun serialize(invoice: CanonicalInvoice): ByteArray

    /**
     * Submit the serialized invoice bytes to the fiscal platform.
     *
     * CONTRACT:
     * - Requires live credentials (API key, OAuth token, or certificate).
     * - MUST include an idempotency key (platform-specific;specific — see §2.3).
     * - Returns [SubmitResult]SubmitResult on success; throws [AdapterException]AdapterException on allALL failures.
     * - NEVER throwspropagates platform-native exceptions (Ktor ResponseException, etc.) —
     *   map every platform exception to [AdapterException]AdapterException before propagating.
     * - Implementations in STUB lifecycle MUST throw NOT_IMPLEMENTED.NOT_IMPLEMENTED (see §2.5).
     * - Idempotency: platforms may return 409 DUPLICATE on re-submission.
     *   Caller should treat 409 as success — extract submission ID from error body.
     *
     * @param serializedInvoice the bytes returnedfrom by [serialize]serialize()
     * @param invoice the original canonical invoiceCanonicalInvoice (needed 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]platformInvoiceId from [submit]submit().
     * - Returns current [EInvoiceStatus].EInvoiceStatus.
     * - This method is IDEMPOTENT — safe to call multiple times with the same submissionId.
     * - Callers should implement exponential backoff; this method does notNOT retry internally.
     * - Implementations in STUB lifecycle MUST throw NOT_IMPLEMENTED.NOT_IMPLEMENTED (see §2.5).
     * - NEVER log rawPayload without sanitizeForLog().
     */
    fun pollStatus(submissionId: String, invoice: CanonicalInvoice): EInvoiceStatus

    /**
     * Parse an inbound invoice from a raw fiscal platform webhook payload.
     *
     * CONTRACT:
     * - [rawPayload] is the raw bytes from the fiscal platform webhook.webhook (Storecove POST, SEF callback).
     * - Returns [CanonicalInvoice]CanonicalInvoice with `adapterMetadata`adapterMetadata populated for platform-specific fields.fields:
     *     HR: "hr.supplierOib", "hr.buyerOib", "hr.pozivNaBroj"
     *     RS: "rs.supplierPib", "rs.buyerPib", "rs.sefId"
     * - HR:Implementations extractsin `hr.supplierOib`,STUB `hr.buyerOib`,lifecycle `hr.pozivNaBroj`MUST intothrow adapterMetadata.NOT_IMPLEMENTED (see §2.5).
     * - STUB lifecycle throws NOT_IMPLEMENTED.
     * - DO NOTNEVER log rawPayload before passing through sanitizeForLog().
     * - parseIncoming() is deferred for HR: not required for v1 HR GA (Phase 1H.6 scope).
     *   Implement 90 days post-GA (see Plan v3 §4d).
     */
    fun parseIncoming(rawPayload: ByteArray): CanonicalInvoice
}
```

### 2.2 CanonicalInvoice — EN 16931 /Subset UBL 2.1 Subset

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

```kotlin
data class CanonicalInvoice(
    val id: String,                          // Internal UUID — Storecove document_id (D2)D2 dedup)
    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)380/381/383/384)
    val currencyCode: String,                // BT-5: ISO 4217 ("EUR", "RSD", "BAM")
    val jurisdiction: TaxJurisdiction,       // Non-EN16931: routingRouting discriminator (non-EN16931)
    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 constraintsconstraints:** (per| ENField 16931| andConstraint platform| requirements):

Enforced in||-----------------||----------------------||`currencyCode`|EURHR||taxId`permarket||||`taxBreakdowns`|||`adapterMetadata`|pozivNaBroj`|
Field Constraint Platform------------------------------------------------------------------------------ enforcement
currencyCode "EUR" for HR (HALT-3 in StorecoveHrFiskEInvoiceAdapterCroatia lineadopted 735–743) 2023-01-01) | serialize() validates
`supplier.taxId | OIB (HR, 11-digit ISO 7064 MOD 11,10), / PIB (RS, 9-digit), / JIB (BA, 13-digit) | serialize() validates
lines `lines` | Non-empty — EN 16931 §BG-25 requires at leastminimum one line | serialize() validates
taxBreakdowns Must sum to lines.sum((taxRate \* lineTotal) — tolerance 0.01 Application| serviceInvoiceService validates
adapterMetadata HR inbound: `hr.supplierOibsupplierOib`, `hr.buyerOibbuyerOib`, `hr.pozivNaBroj | parseIncoming() populates

**What CanonicalInvoice is NOT:

**
    -
  • Not a DB entity (storedmapped asfrom invoices`invoices` + invoice_items`invoice_items` tables, mappedtables on read)
  • - Not a DTO for the REST API DTO (API layer maps to/fromseparately) its- own request/response models)
  • Not versioned independently — it evolves with EN 16931 minor revisions
###

2.3 Adapter Lifecycle States

State

Machine Defined in `EInvoiceTypes.ktkt` lines 22–26. ThisTransition ADRcriteria formalisesformalised thehere: transition``` criteria:

STUB  ──────────────────────►  SANDBOX_VERIFIED  ──────────────────────►  PRODUCTION
  │                                    │                                        │
  │ Compiles. All 53 happy-path sandbox                   │ Securion audit passed.
  │ Allnetwork methods throw │ test cases pass with                   │ 30d stage soak with
  │ AdapterErrorCode.                  │ real platform submission               │ zero critical errors.
  │ NOT_IMPLEMENTED.
  │ IDsserialize() MAY be operational (notHR: mocked)already works offline).                      │ AdapterConfig(enabled=true)
  │ AdapterConfig row not Real platform responserequired.in productionTransition DB.criteria → SANDBOX_VERIFIED:may1. notProvider existaccount yet.                 │ logged and archived.                   │
  └────────────────────────────────────┴────────────────────────────────────────┘

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

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

Transition to SANDBOX_VERIFIED requires (HR):

  1. MC #8675 resolved: Storecove API key + STORECOVE_LEGAL_ENTITY_IDloaded in GCP Secret Manager
  2. (see §2.6 secret taxonomy) │ 3. 5 sandbox test invoicesinvoice types pass with REAL platform submission IDs (§2.4) │ 4. pollStatus() confirmed for each submitted viainvoice submit() and5. pollingProveo confirmedevidence viafile pollStatus():with
      submission
    • B2BIDs outbound commercial invoice
    • B2G outbounduploaded to BookStack │ 6. lifecycleState field updated to SANDBOX_VERIFIED in adapter source │ ▼ SANDBOX_VERIFIED │ All 4 methods operational against provider sandbox. │ AdapterConfig(market, EINVOICE, enabled=true) in STAGE DB. │ │ Transition criteria → PRODUCTION: │ 1. Securion audit: adapter error handling + PII sanitization (see §2.7) │ 2. 30 continuous days on STAGE Cloud Run with zero │ AdapterErrorCode.PLATFORM_INTERNAL_ERROR alerts │ (Prometheus metric: bilko_integration_request_total) │ 3. AdapterConfig(market, EINVOICE, enabled=true) in PRODUCTION DB │ 4. CEO sign-off (this is the go-live gate) │ ▼ PRODUCTION │ Live. All 4 methods operational against production platform. │ Incident response: if critical error rate > 5% over 15min window, │ automated alert → Slack #bilko-incidents → human decision to flip │ AdapterConfig.enabled = false (no redeploy needed). ``` **Current HR government entity
    • Credit notestate (typeCode2026-05-13):** 381)
    • STUB
    • Cancelled- `serialize()`: WORKS (offline). Unit-tested. - `submit()`: throws NOT_IMPLEMENTED — MC #8675 pending - `pollStatus()`: throws NOT_IMPLEMENTED - `parseIncoming()`: throws NOT_IMPLEMENTED (deferred post-GA) ### 2.4 HR-FISK Storecove Sandbox Validation Matrix 5 invoice workflow
    • types
    • Inboundrequired invoicefor fromSANDBOX_VERIFIED Storecovetransition. webhook
  3. All 5must produce real Storecove submission GUIDs (not mock strings)
  4. .
  5. Proveo (Angie Jones) runs these tests. | # | Invoice Type | UNTDID Code | Scenario | Expected Storecove Response | Evidence Required | | --- | ----------------------- | ------------- | ------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------- | -------------------------------------------------------- | | 1 | B2B outbound commercial | 380 | Supplier OIB + Buyer OIB both valid. EUR. 25% PDV. Standard commercial transaction. | HTTP 200 + `{"id": "<guid>", "status": "pending"}` | Storecove submission GUID in evidence file with| submission| IDs2 uploaded| B2G outbound | 380 | Buyer is HR government entity (OIB format same). `PaymentMeans.paymentMeansCode=30`. | HTTP 200 + GUID | GUID + Storecove routing.peppol.id verified as buyer OIB | | 3 | Credit note | 381 | References original invoice number in `note` field. Negative line totals. | HTTP 200 + GUID | GUID + typeCode=381 confirmed in Storecove portal | | 4 | Cancelled invoice | 384 | CORRECTIVE_INVOICE type. Status flow: submit → pollStatus until APPROVED or REJECTED | HTTP 200 + GUID, then pollStatus APPROVED/REJECTED | GUID + final status confirmed | | 5 | Inbound received | 380 (inbound) | Storecove sends test webhook to BookStack
  6. Bilko's
webhook

Transitionendpoint. `parseIncoming()` invoked. | Webhook received. CanonicalInvoice returned. `hr.supplierOib` in adapterMetadata. | Log entry showing successful parse + extracted OIB value | **HR-specific validation rules verified in each test case:** - `currencyCode = "EUR"` (HALT-3) - Supplier OIB: ISO 7064 MOD 11,10 checksum valid - Buyer OIB: ISO 7064 MOD 11,10 checksum valid - CustomizationID: verify with Storecove support which to PRODUCTIONuse (PEPPOL_BIS3 or HR_CIUS — TODO MC #8675 D3) - `routing.peppol.scheme = "9934"` and `routing.peppol.id = <buyerOIB>` **Storecove-specific notes:** - Sandbox URL is the same as production (`api.storecove.com/api/v2`) — sandbox mode is a payload flag, not a different host. Set `STORECOVE_ENV=sandbox` env var. - Idempotency key: SHA-256(`invoice.id` + `invoice.invoiceNumber`) → sent as `Idempotency-Key` header. Platform returns HTTP 409 on duplicate — treat as success (re-fetch GUID from error body). - `document_id` field in Storecove payload = `CanonicalInvoice.id` (Bilko UUID) — Storecove dedup key, prevents double-billing on retry (D2 in StorecoveHrFiskEInvoiceAdapter). ### 2.5 NOT_IMPLEMENTED Transition Rules `AdapterErrorCode.NOT_IMPLEMENTED` is the canonical error code for STUB lifecycle methods. Rules for callers and implementers: **Implementer rules:** 1. Any STUB lifecycle method that is not yet operational MUST throw: ```kotlin throw AdapterException( code = AdapterErrorCode.NOT_IMPLEMENTED, market = jurisdiction, retryable = false, rawPayload = "", message = "<Platform> <method> requires account — MC #<id>" ) ``` 2. `serialize()` is EXEMPT from the NOT_IMPLEMENTED requirement — it SHOULD be operational even in STUB lifecycle because it needs no credentials (anyoffline adapter):

contract).
    3.
  1. Once an implementation moves to SANDBOX_VERIFIED, no method may throw NOT_IMPLEMENTED for the sandbox environment. If a method is genuinely deferred (e.g., `parseIncoming()` for HR v1), the lifecycle state must remain STUB until all 4 methods are operational. Exception: `parseIncoming()` for HR is formally deferred to 90 days post-GA per Plan v3 §4d. The HR adapter will hold a partial SANDBOX_VERIFIED state alreadytracked achieved
  2. by
  3. Securionthe audit`AdapterConfig` offeature flag with `reason = "parseIncoming deferred — Phase 1H.6"`. **Caller rules:** 1. Before calling `submit()` or `pollStatus()`, callers MUST check: ```kotlin val config = adapterConfigRepo.find(jurisdiction, "EINVOICE") ?: throw AdapterException(NOT_IMPLEMENTED, ...) if (!config.enabled) throw AdapterException(NOT_IMPLEMENTED, ..., message="Adapter disabled: ${config.reason}") ``` 2. `NOT_IMPLEMENTED` caught at the route handler level maps to HTTP 503 (Service Unavailable) with body `{"error": "ADAPTER_NOT_AVAILABLE", "market": "<jurisdiction>"}`, NOT HTTP 500. This is the stub plugin HTTP 500 risk mitigation from ADR-015 §5.3. 3. `serialize()` callers do NOT need to check AdapterConfig — serialize is always available. **Error code precedence when multiple codes could apply:** ``` NOT_IMPLEMENTED > AUTH_INVALID_CREDENTIALS > VALIDATION_BUSINESS_RULE > NETWORK_TIMEOUT ``` If a STUB adapter erroris handlingalso andmissing PIIcredentials, sanitization
  4. `NOT_IMPLEMENTED`
  5. 30takes continuousprecedence. daysLifecycle onstate stagecheck happens before credential check. ### 2.6 Secret Management — GCP Secret Manager Taxonomy All adapter credentials follow the taxonomy defined in ADR-019 §2.5: ``` Bilko/{env}/{market}/{secret-name} ``` - `{env}`: `dev`, `stage`, `prod` - `{market}`: `HR`, `RS`, `BA_FED`, `BA_RS` - `{secret-name}`: platform-specific identifier (kebab-case) **HR Storecove secrets (provision after MC #8675):** | GCP Secret Manager path | Content | Access binding | | ------------------------------------------ | ----------------------------------- | ----------------------------- | | `Bilko/stage/HR/storecove-api-key` | Storecove sandbox API key | Cloud Run withSA zero`bilko-stage-sa` AdapterErrorCode.PLATFORM_INTERNAL_ERROR| alerts| `Bilko/prod/HR/storecove-api-key` | Storecove production API key | Cloud Run SA `bilko-prod-sa` | | `Bilko/stage/HR/storecove-legal-entity-id` | Storecove legal entity ID (persandbox) Prometheus| bilko_integration_request_totalCloud metric)
  6. Run
  7. AdapterConfig(market=HR,SA adapter_type=EINVOICE,`bilko-stage-sa` enabled=true)| row| `Bilko/prod/HR/storecove-legal-entity-id` | Storecove legal entity ID (prod) | Cloud Run SA `bilko-prod-sa` | **Mounting in Cloud Run:** ```yaml # gcp-deploy.yml (Cloud Run --set-secrets pattern): --set-secrets="STORECOVE_API_KEY=Bilko/stage/HR/storecove-api-key:latest,\ STORECOVE_LEGAL_ENTITY_ID=Bilko/stage/HR/storecove-legal-entity-id:latest" ``` **Env var naming convention:** `<PLATFORM>_<FIELD>`, uppercase, underscores. Accessed in `StorecoveApiClient` via `System.getenv("STORECOVE_API_KEY")`. **Secret rotation policy:** - Rotate API keys every 90 days OR on any Storecove security notice, whichever comes first. - Previous version retained in Secret Manager for 24h to allow graceful failover. - Rotation event: create new secret version → update Cloud Run env → verify health endpoint → delete previous version 24h later. **Never in source code or logs:** API keys, legal entity IDs, OIB values, IBAN values. `StorecoveHrFiskEInvoiceAdapter.sanitizeForLog()` must be called on all Storecove response bodies before logging. **RS future secrets (Phase 1S):** | GCP Secret Manager path | Content | | ----------------------------- | --------------------------- | | `Bilko/stage/RS/sef-api-key` | SEF sandbox access token | | `Bilko/prod/RS/sef-api-key` | SEF production DB
  8. access
token

| | `Bilko/stage/RS/sef-username` | SEF API username | | `Bilko/prod/RS/sef-username` | SEF API username (prod) | SEF uses OAuth2 with client credentials. The token endpoint is `https://efaktura.mfin.gov.rs/` (Serbian Ministry of Finance). Exact credentials shape to be confirmed at Phase 1S kickoff. ### 2.47 Per-Platform XML NamespaceField Mapping

TheHow canonical CanonicalInvoice`CanonicalInvoice` fields map to platform-specific XML/JSONJSON: elements:

| |-------------------------------|-----------------------------------------------------------------|taxId`||taxId`||`invoiceNumber`ID`InvoiceNumber`||`issueDate`IssueDate`||untdidCode`InvoiceType`||`currencyCode`Currency`||taxRate`Percent`TaxRate`||taxCategory`K||paymentReference`|PaymentReference`||iban`ID`IBAN`||
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/TaxIdTaxId` (PIB) | TBD | TBD
`buyer.taxId | `AccountingCustomerParty/Party/PartyTaxScheme/.../CompanyID @schemeID="9934"` (OIB) | `/Invoice/Buyer/TaxIdTaxId` (PIB) | TBD | TBD
invoiceNumber | `cbc:ID | `/Invoice/InvoiceNumber | TBD | TBD
issueDate | `cbc:IssueDateIssueDate` (ISO 8601) | `/Invoice/IssueDate | TBD | TBD
`typeCode.untdidCode | `cbc:InvoiceTypeCodeInvoiceTypeCode` (380/381/384) | `/Invoice/InvoiceType | TBD | TBD
currencyCode | `cbc:DocumentCurrencyCodeDocumentCurrencyCode` + @currencyID`@currencyID` on all amounts | `/Invoice/Currency | TBD | TBD
`taxBreakdowns[].taxRate | `TaxSubtotal/TaxCategory/Percent | `/Invoice/TaxTotal/TaxRate | TBD | TBD
`taxBreakdowns[].taxCategory | `TaxSubtotal/TaxCategory/IDID` (S/Z/E/K) per EN 16931 BT-118) | Serbian code set | TBD | TBD
`paymentMeans.paymentReference | `PaymentMeans/PaymentIDPaymentID` (HR "Poziv na broj") HR) `/Invoice/PaymentReference | TBD | TBD
`paymentMeans.iban | `PayeeFinancialAccount/ID | `/Invoice/BankAccount/IBAN | TBD | TBD
`adapterMetadata`

HR| CustomizationID`"hr.supplierOib"`, note`"hr.buyerOib"`, `"hr.pozivNaBroj"` (StorecoveHrFiskEInvoiceAdapter.ktinbound) lines| 247–252):`"rs.sefId"`, Two`"rs.supplierPib"` candidates| TBD default| isTBD CUSTOMIZATION_ID_PEPPOL_BIS3.| Verify with Storecove support before MC #8675 activation which to use for HR-FISK submission.

**SEF XML note (RS):note:** SEF does not use UBL 2.1. It uses a Serbian-specific XML schema published by the Ministry of Finance. The SEF adapter (Phasemaps 1S)`CanonicalInvoice` must map from CanonicalInvoice to SEF schema directly; it does NOT go through UBL. The `EInvoiceAdapter.serialize()` contract requires only that the output isreturns the platform's native wireformat. format.

**BA

adapters (Phase 1B):** CPF and UINO platforms have no published API specifications as of 2026-05-13. Phase 1B cannot begin until regulatory mandates define the technical specification (~2027 per plan v3 context). ### 2.58 HR Reference Implementation

StorecoveHrFiskEInvoiceAdapterDesign atDecisions apps/api/src/main/kotlin/no/alai/bilko/country/hr/StorecoveHrFiskEInvoiceAdapter.kt`StorecoveHrFiskEInvoiceAdapter` is the reference implementationimplementation. forFuture alladapters EInvoiceAdapter decisions in this ADR. Key design choices toMUST replicate inthese futurepatterns: adapters:

| Designdecision| |Ruleforfutureadapters|| comment|REQUIREDiffortests|`AdapterException`|| |||check)||`document_id`
DecisionLocation in reference impl Rationale
| | ------------------------------------------------- | --------------------------------------------------- | ---------------------------------- | | PII field redaction before logging | Lines 24–59 (REDACT_PII_FIELDS`REDACT_PII_FIELDS`, sanitizeForLog`sanitizeForLog`) | REQUIRED — GDPR / HR-FISK audit rules
Offline serialization (no credentials) | Lines 567–571 (`serialize()`) Allows| testingREQUIRED andper invoice§2.1 previewcontract without| platform| account
Idempotency key (SHA-256 of invoice.id + invoiceNumber) | Lines 591–600 (stub comment) Storecove 409activate deduplicationpost-#8675) (D5)
platform supports | | Credential validation on startup flag | Lines 83–138 (validateOnStartup`validateOnStartup`, `validate()`) Fail-fast| inREQUIRED production, permissivedefault infalse test
| Error code mapping to AdapterException | Lines 469–515 (StorecoveErrorMapper`StorecoveErrorMapper`) | REQUIRED — NEVER propagate platform-native exceptions
Structured metrics recording (`StorecoveMetrics`) | Lines 537–540 (StorecoveMetrics) Per-adapterREQUIRED — Prometheus counters
ISOTax 7064ID MODformat 11,10validation in `serialize()` | Lines 748–774 (OIB validation Lines| 194–225REQUIRED (StorecoveOibValidator) HR-FISKearly rejects invalid OIB witherror, no detailednetwork error
for
deduplication

| Lines 420–437 (`StorecovePayloadBuilder.wrap()`) | REQUIRED if platform supports 409 | --- ## 3. Adapter Lifecycle Governance

### 3.1 AdapterConfig Feature Flag

All adaptersadapter network paths (`submit`, `pollStatus`, `parseIncoming`) are gated by an AdapterConfig`AdapterConfig` row in the databasedatabase. (Defined fully in ADR-019 §2.4):

-- Table defined in ADR-019;4; referenced herehere:

for completeness```sql
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,                  -- WhyHuman-readable disabledstatus (e.g., "MC #8675 pending")note
    updated_at   TIMESTAMPTZ NOT NULL DEFAULT now(),
    UNIQUE (market, adapter_type)
);
```

NoSeed EInvoiceAdapterrow submitfor pathHR executesSTUB unlessstate adapter_config.(Flyway V17): ```sql INSERT INTO adapter_config (market, adapter_type, enabled, reason) VALUES ('HR', 'EINVOICE', false, 'Storecove account pending — MC #8675'); ``` Row is flipped to `enabled = truetrue` forby the operator (market,not 'EINVOICE').by Thiscode) allowsafter disablingSANDBOX_VERIFIED atransition brokenis adapterconfirmed withoutby aProveo redeploy.

evidence.

### 3.2 Adapter Versioning

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

error
(not

silent degradation). --- ## 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)`AdapterException` is the only exception type that crossescrossing the adapter boundary. Callers implement one error handler, not four platform-specific ones.
  • - **Lifecycle visibility.** lifecycleState`lifecycleState` is afirst-class. first-class property. Monitoring dashboards canDashboards show "HR adapter: STUB" and alert when a market is operatingoperates in degraded lifecyclestate. state.
  • -
  • **Canonical model.** CanonicalInvoice`CanonicalInvoice` enables cross-market reporting, invoice history normalisation,reporting and futureanalytics. features- (e.g.,**NOT_IMPLEMENTED group invoiceHTTP analytics503.** acrossClients HRreceive +a RS).
  • clean
"feature

not available" response instead of an HTTP 500 stack trace when an adapter is in STUB state. ### 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 mandatesregulations define the technicalspec. specification- **4 methods = all or nothing lifecycle.** If `parseIncoming()` is the last unfinished method, the adapter cannot advance to SANDBOX_VERIFIED. The HR partial-SANDBOX exception (~2027§2.5 perrule plan3) v3is §4a context).
  • pragmatic
workaround;

it should not become a pattern. ### 4.3 Risks

    -
  • **CanonicalInvoice field gap.** A platform-specific required field requiredhas by one market (e.g., HR's "Poziv na broj" payment reference) may not have ano canonical counterpart. **Resolution:** use `adapterMetadata: Map<String, String>` for platform-specific extras until they are common enoughgeneralise to merit a first-class fields. - **Storecove CustomizationID ambiguity (D3).** Two candidate CustomizationIDs — PEPPOL_BIS3 and HR_CIUS. **Resolution:** Verify with Storecove support before MC #8675 sandbox activation. This is a HALT item. Wrong choice → all HR invoices rejected. - **Storecove routing.network field (HALT-4).** Existing code does not include `routing.network` field.
  • OIB/PIB/JIBVerify with Storecove sandbox whether this is required. - **Secret rotation lag.** Expired API key → all `submit()` calls throw `AUTH_INVALID_CREDENTIALS`. **Mitigation:** 90-day rotation schedule + cert-expiry-monitor (Task 4.3 in Plan v3). - **OIB validation at serialize() vs submit().** ValidationValidates at serialize() time gives earlier errorsearly (no network needed)offline) but requires the adapter to know tax ID rules for each market — couplingcouples format and validation.validation Currentlogic. approachAccepted (HRtrade-off: validatesearly inerrors serialize())are isbetter pragmatic;than RSlate andones. BA--- adapters## should follow the same pattern.

5. References

|Reference|Path|||---------------------------------------------------||kt`||kt`||kt`|||adapter/AdapterTypes.kt`|1–41|kt`||counters)kt`|77–179|7064kt`|| kt`|||md`|---
Reference Path Lines
EInvoiceAdapter------------------------------------------------------------------------------------------------ | ------- | | `EInvoiceAdapter` interface (canonical) `apps/api/src/main/kotlin/no/alai/bilko/einvoice/EInvoiceTypes.kt | 200–224
CanonicalInvoice`CanonicalInvoice` definition | `apps/api/src/main/kotlin/no/alai/bilko/einvoice/EInvoiceTypes.kt | 141–156
AdapterLifecycleState`AdapterLifecycleState` enum | `apps/api/src/main/kotlin/no/alai/bilko/einvoice/EInvoiceTypes.kt | 22–26
TaxCategory`AdapterErrorCode` enum (EN+ 16931`AdapterException` 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`StorecoveMetrics` CustomizationID(Micrometer constants | `apps/api/src/main/kotlin/no/alai/bilko/country/hr/StorecoveMetrics.kt` | 1–73 | | `StorecoveApiClient` (credentials + base URL) | `StorecoveHrFiskEInvoiceAdapter.kt 247–252
OIB| validator`StorecoveOibValidator` (ISO 7064) MOD 11,10) | `StorecoveHrFiskEInvoiceAdapter.kt | 194–225
PII sanitize helperStorecoveHrFiskEInvoiceAdapter.kt24–59
Error mapper`StorecoveErrorMapper` (HTTP → AdapterErrorCode) | `StorecoveHrFiskEInvoiceAdapter.kt | 469–515
PII sanitize helper (`sanitizeForLog`) | `StorecoveHrFiskEInvoiceAdapter.kt` | 24–59 | | `HrUblBuilder` (UBL 2.1 offline build) | `StorecoveHrFiskEInvoiceAdapter.kt` | 241–387 | | `StorecovePayloadBuilder` (wrap JSON + dedup D2) | `StorecoveHrFiskEInvoiceAdapter.kt` | 418–450 | | ADR-019 §2.4 (AdapterConfig table) | `docs/architecture/ADR-019-INTEGRATION-ADAPTER-REGISTRY.md` | §2.4 | | Plan v3 §4d HR critical path (sandbox verification) | `~/system/specs/bilko-multi-market-architecture-plan-v3-2026-05-11.md` | 147–176 | | Plan v3 §4b ADR-016 requirement | `~/system/specs/bilko-multi-market-architecture-plan-v3-2026-05-11.md` | 125–126 | | ADR-bilko-003 §Layer 2 (EInvoice serializationserialization) 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.md125–126
Plan v3 §4d HR Critical Path (sandbox verification)~/system/specs/bilko-multi-market-architecture-plan-v3-2026-05-11.md147–176

6. Approval

**Status:** Accepted **Unblocks:

**
    -
  • Phase 1H Task 1H.2: `PluginHR.generateEInvoiceXml()` delegation to StorecoveHrFiskEInvoiceAdapter
  • `StorecoveHrFiskEInvoiceAdapter`
  • - Phase 1H Task 0'31H.4: DI wiring — lifecycle state check before submit/pollStatus dispatch - Phase 1H Task 1H.6: Storecove submit() activation (after MC #8675) - ADR-019):019: Integration Adapter Registry formalises lifecycle`AdapterConfig` governance
  • table
  • MCand #8675:secret Storecovetaxonomy account| activationRole | SANDBOX_VERIFIEDSign transition
  • |
Date ||--------------------------------|-----------------------------|----------|| 13||13|||---
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|||----------|||
Date Author Change
--------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | 2026-05-11 | Markos Zachariadis / Petter Graff Initial| v1 — Phase 0' ADR consolidationinitial (MC #100362)
2026-05-13 | Petter Graff | v2 — MC #100585: Full lifecycle state machine with explicit transition criteria; sandbox validation matrix (5 invoice types for HR-FISK Storecove); NOT_IMPLEMENTED transition rules; GCP Secret Manager taxonomy with HR+RS secret paths; HTTP 503 mapping for NOT_IMPLEMENTED; HALT items D3/D4 documented; StorecoveMetrics and StorecoveApiClient cited explicitly |