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,secreterrortaxonomy,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.kt` (canonical types on disk)kt- `apps/api/src/main/kotlin/no/alai/bilko/country/hr/StorecoveHrFiskEInvoiceAdapter.kt` (HR reference)kt- - Plan v3 §4b ADR-016 requirement + §4d HR critical path —
`~/system/specs/bilko-multi-market-architecture-plan-v3-2026-05-11.mdmd`
##
1. Context
### 1.1 The Four-Platform Problem
Bilko targets four tax jurisdictions with four incompatible e-invoice fiscal platforms:
| Transport | | Format | | Status | ||
|---|---|---|---|---|
| ------------------------------------ | ---------- | -------------------------- | --------------- |
| HR | | HR-FISK / FINA via Storecove | | Peppol AS4 | | UBL 2.1 + HR CIUS | | STUB (MC #8675) |
| RS | | 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.
§3). ### 1.2 Existing Types on Disk (verified 2026-05-11)
`EInvoiceTypes.kt` already defineskt the following (lines 1–224):
- -
`AdapterLifecycleState` enum:AdapterLifecycleState`STUB`,STUB`SANDBOX_VERIFIED`,SANDBOX_VERIFIED`PRODUCTION`PRODUCTION(lines-22–26)`EInvoiceStatus` enum:EInvoiceStatus`PENDING`,PENDING`APPROVED`,APPROVED`REJECTED`,REJECTED`CANCELLED`,CANCELLED`ERROR`ERROR(lines-32–38)InvoiceTypeCodeclass:`InvoiceTypeCode`: UNTDID codes 380, 381, 383, 384(lines-48–60)`Address`,Address`PartyInfo` /PartyInfo`Party` typealiasParty(lines-66–81)`PaymentMeans`:PaymentMeans`paymentMeansCode`,paymentMeansCode`paymentReference`,paymentReference`iban`iban(lines-87–93)`TaxCategory` enum:TaxCategory`S, Z, E, K, G, O,AE` per EN 16931 BT-118AE(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 belowSubmitResult: platformInvoiceId, initialStatus, submittedAt, rawResponse (lines 162–167)InvoiceTotalscompute object (lines 172–195)`EInvoiceAdapter` interface with 4 methods + 2 properties `AdapterTypes.kt` (EInvoiceAdapterlinesin200–224)`no.alai.bilko.adapter`)
- `AdapterErrorCode` enum with 10 codes including `NOT_IMPLEMENTED`
- `AdapterException(code, market, retryable, rawPayload, message, cause)`
The `EInvoiceAdapter` interface EInvoiceAdapterexistsand lifecycle states exist but isare not formally documented.
`StorecoveHrFiskEInvoiceAdapter` implements StorecoveHrFiskEInvoiceAdapteritthe directlyinterface with— `serialize()` is fully operational
(offline,offline; noall credentials)other methods throw `NOT_IMPLEMENTED`. This ADR formalises the 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.kt` lines 200–224.
Reproduced here ktin full as the normative 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.kt` lines 141–156:kt
```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):
| | "EUR" for HR (HALT-3 | EUR 2023-01-01) | serialize() | HR
`supplier. | taxId` | OIB (HR, 11-digit ISO 7064 MOD 11,10) | | serialize() | per
| | `lines` | Non-empty — EN 16931 §BG-25 | | serialize() | |
| | Must sum to lines. — tolerance 0.01 | |
| | HR inbound: `hr.supplierOib`, `hr.buyerOib`, `hr. | pozivNaBroj` | parseIncoming() | |
**What CanonicalInvoice is NOT:
**- -
- Not a DB entity (
storedmappedasfrom`invoices` +invoices`invoice_items`invoice_itemstables, mappedtables on read) - - Not a
DTO for theREST API DTO (API layer mapsto/fromseparately)its-own request/response models) - Not versioned independently —
itevolves with EN 16931 minor revisions
2.3 Adapter Lifecycle States
State Machine
Defined in `EInvoiceTypes.kt` lines 22–26. ktThisTransition 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()throwsNOT_IMPLEMENTED— blocked onMC #8675(Storecoveforaccount).HR/Storecove)
│pollStatus()throws2.NOT_IMPLEMENTED.Credentials parseIncoming()throwsNOT_IMPLEMENTED.
Transition to SANDBOX_VERIFIED requires (HR):
MC #8675 resolved: Storecove API key +loaded in GCP Secret ManagerSTORECOVE_LEGAL_ENTITY_ID- (see §2.6 secret taxonomy)
│ 3. 5 sandbox test
invoicesinvoice types pass with REAL platform submission IDs (§2.4) │ 4. pollStatus() confirmed for each submittedviainvoice│submit()and5.pollingProveoconfirmedevidenceviafilepollStatus():with- submission
B2BIDsoutbound commercial invoiceB2G 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 HRgovernment entityCredit 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 invoiceworkflowtypes InboundrequiredinvoiceforfromSANDBOX_VERIFIEDStorecovetransition.webhook
- All
5must produce real Storecove submission GUIDs (not mock strings) . - 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|IDs2uploaded| 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 toBookStackBilko's
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):
- 3.
- 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
alreadytrackedachievedby Securiontheaudit`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 adaptererrorishandlingalsoandmissingPIIcredentials,sanitization`NOT_IMPLEMENTED` 30takescontinuousprecedence.daysLifecycleonstatestagecheck 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 RunwithSAzero`bilko-stage-sa`|AdapterErrorCode.PLATFORM_INTERNAL_ERRORalerts| `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|Cloudbilko_integration_request_totalmetric)Run |AdapterConfig(market=HR,SAadapter_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 productionDBaccess
|
| `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` fields map to platform-specific XML/CanonicalInvoiceJSONJSON:
elements:
| CanonicalInvoice field | | HR (UBL 2.1 / Peppol) | | RS (SEF XML) | | BA-FED | BA-RS | |
|---|---|---|---|---|
-------------------------------- | ------ | ----- |
| `supplier. | taxId` | `AccountingSupplierParty/` (OIB) | | `/Invoice/Seller/TaxId` (PIB) | | TBD | | TBD |
`buyer. | taxId` | `AccountingCustomerParty/` (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 all amounts | | `/Invoice/ | Currency` | TBD | | TBD |
`taxBreakdowns[]. | taxRate` | `TaxSubtotal/TaxCategory/ | Percent` | `/Invoice/TaxTotal/ | TaxRate` | TBD | | TBD |
`taxBreakdowns[]. | taxCategory` | `TaxSubtotal/TaxCategory/ID` (S/Z/E/ | K per EN 16931 BT-118) | Serbian code set | | TBD | | TBD |
`paymentMeans. | paymentReference` | `PaymentMeans/PaymentID` (HR "Poziv na broj") | | `/Invoice/ | PaymentReference` | TBD | | TBD |
`paymentMeans. | iban` | `PayeeFinancialAccount/ | ID` | `/Invoice/BankAccount/ | IBAN` | TBD | | TBD |
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.
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
Design StorecoveHrFiskEInvoiceAdapteratDecisions
`StorecoveHrFiskEInvoiceAdapter` is the reference apps/api/src/main/kotlin/no/alai/bilko/country/hr/StorecoveHrFiskEInvoiceAdapter.ktimplementationimplementation. forFuture alladapters EInvoiceAdapter decisions in this ADR.
Key design choices toMUST
replicate inthese futurepatterns:
adapters:
| Location in reference impl | ||
|---|---|---|
| | | ------------------------------------------------- | --------------------------------------------------- | ---------------------------------- | | PII field redaction before logging | | Lines 24–59 (`REDACT_PII_FIELDS`, `sanitizeForLog`) | | REQUIRED — GDPR / |
| Offline serialization (no credentials) | | Lines 567–571 (`serialize()`) | |
| Idempotency key (SHA-256 of | | Lines 591–600 (stub | comment |
| platform supports | | Credential validation on startup flag | | Lines 83–138 (`validateOnStartup`, `validate()`) | |
| Error code mapping to | `AdapterException` | Lines 469–515 (`StorecoveErrorMapper`) | | REQUIRED — NEVER propagate | |
| Structured metrics recording | (`StorecoveMetrics`) | Lines 537–540 | | |
| —
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` row in the AdapterConfigdatabasedatabase. (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 row EInvoiceAdaptersubmitfor pathHR executesSTUB unlessstate true` 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 = trueforby the operator (market,not 'EINVOICE').by Thiscode) allowsafter disablingSANDBOX_VERIFIED
atransition brokenis adapterconfirmed withoutby aProveo redeploy.
### 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` implementation declares a minimum adapter CountryPluginversion it is compatible
with.version. Incompatibility
detected at startup → application fails fast with a clear 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 typethat crossescrossing the adapter boundary. Callers implement one error handler, not four platform-specific ones. - - **Lifecycle visibility.**
`lifecycleState` islifecycleStateafirst-class.first-class property. Monitoring dashboards canDashboards show "HR adapter: STUB" and alert when a marketis operatingoperates in degradedlifecyclestate.state.- - **Canonical model.**
`CanonicalInvoice` enables cross-marketCanonicalInvoicereporting, invoice history normalisation,reporting andfutureanalytics.features-(e.g.,**NOT_IMPLEMENTEDgroup→invoiceHTTPanalytics503.**acrossClientsHRreceive+aRS).clean
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 andchanges without semantic versioning guarantees. The adapter must track schemaversionchanges proactively. - - **BA adapters are TBD.
CPF and UINO platforms do not have published APIs as of 2026-05-11.** Phase 1Badapterwork cannot begin untilthe regulatory mandatesregulations define thetechnicalspec.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.5perruleplan3)v3is§4acontext).pragmatic
it should not become a pattern. ### 4.3 Risks
- -
- **CanonicalInvoice field gap.** A platform-specific required field
requiredhasby 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 theyare common enoughgeneralise tomerit afirst-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().**ValidationValidatesatserialize()time gives earlier errorsearly (no network needed)offline) butrequires the adapter to know tax ID rules for each market — couplingcouples format andvalidation.validationCurrentlogic.approachAccepted(HRtrade-off:validatesearlyinerrorsserialize())areisbetterpragmatic;thanRSlateandones.BA---adapters##should follow the same pattern.
5. References
| Lines | ||
|---|---|---|
------------------------------------------------------------------------------------------------ | ------- |
| `EInvoiceAdapter` interface | | `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 |
`AdapterErrorCode` enum | | `apps/api/src/main/kotlin/no/alai/bilko/ | adapter/AdapterTypes.kt` |
| | HR reference impl — full file | | `apps/api/src/main/kotlin/no/alai/bilko/country/hr/StorecoveHrFiskEInvoiceAdapter. | kt` | 1–777 |
| `apps/api/src/main/kotlin/no/alai/bilko/country/hr/StorecoveMetrics.kt` | 1–73 |
| `StorecoveApiClient` (credentials + base URL) | `StorecoveHrFiskEInvoiceAdapter. | kt` ||
MOD 11,10) | `StorecoveHrFiskEInvoiceAdapter. | kt` | 194–225 | |
| ||
| `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 | | `~/system/specs/bilko-multi-market-architecture-plan/ADR-bilko-003-market-abstraction-layers. | md` | 103–117 |
| ||
|
6. Approval
**Status:** Accepted **Unblocks:
**- -
- Phase 1H Task 1H.2:
`PluginHR.generateEInvoiceXml()` delegation toStorecoveHrFiskEInvoiceAdapter`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 Registryformalises—lifecycle`AdapterConfig`governancetable MCand#8675:secretStorecovetaxonomyaccount|activationRole→|SANDBOX_VERIFIEDSigntransition|
| Finverge — Markos Zachariadis | | Signed | | 2026-05- | 13
| Architecture Lead (Petter Graff) | | Signed | | 2026-05- | 13
| CEO (Alem Bašić) | | Not required for contract ADR | | — |
7. Document History
| Change | ||
|---|---|---|
| --------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | 2026-05-11 | | Markos Zachariadis / Petter Graff |