ADR-016: EInvoice Adapter Lifecycle and Contract
# ADR-016 — EInvoiceAdapter Lifecycle and Contract
**Status:** Accepted
**Date:** 2026-05-13
**Author:** Petter Graff (CodeCraft — Architecture Lead)
**Finverge Co-author:** Markos Zachariadis (Payments & Fiscal Integration)
**Decision-maker:** CEO Alem Bašić
**MC Task:** #100585 (Phase 0' ADR 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 — `AdapterConfig`, secret taxonomy, categories)
- 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 + §4d HR critical path — `~/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 | 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 |
Without a canonical abstraction, each platform's integration detail bleeds into the core
invoice service — reproducing the Variant B coupling problem (ADR-bilko-002 §3).
### 1.2 Existing Types on Disk (verified 2026-05-11)
`EInvoiceTypes.kt` already defines (lines 1–224):
- `AdapterLifecycleState` enum: `STUB`, `SANDBOX_VERIFIED`, `PRODUCTION`
- `EInvoiceStatus` enum: `PENDING`, `APPROVED`, `REJECTED`, `CANCELLED`, `ERROR`
- `InvoiceTypeCode`: UNTDID codes 380, 381, 383, 384
- `Address`, `PartyInfo` / `Party` typealias
- `PaymentMeans`: `paymentMeansCode`, `paymentReference`, `iban`
- `TaxCategory` enum: `S, Z, E, K, G, O, AE` per EN 16931 BT-118
- `TaxBreakdown`, `InvoiceLine`, `CanonicalInvoice`, `SubmitResult`, `InvoiceTotals`
- `EInvoiceAdapter` interface with 4 methods + 2 properties
`AdapterTypes.kt` (in `no.alai.bilko.adapter`) defines:
- `AdapterErrorCode` enum with 10 codes including `NOT_IMPLEMENTED`
- `AdapterException(code, market, retryable, rawPayload, message, cause)`
The `EInvoiceAdapter` interface and lifecycle states exist but are not formally documented.
`StorecoveHrFiskEInvoiceAdapter` implements the interface — `serialize()` is fully operational
offline; all other methods throw `NOT_IMPLEMENTED`. This ADR formalises the contract and
lifecycle governance.
---
## 2. Decision
### 2.1 EInvoiceAdapter Interface — Formal Contract
Defined in `apps/api/src/main/kotlin/no/alai/bilko/einvoice/EInvoiceTypes.kt` lines 200–224.
Reproduced here 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 required.
* - MUST be deterministic: same [invoice] input produces identical bytes.
* - MUST NOT log raw PII fields (OIB, IBAN, document_data) — call sanitizeForLog().
* - Returns the full wire-format payload for the platform:
* HR: Storecove JSON envelope wrapping UBL 2.1 XML
* RS: SEF XML (Serbian Ministry of Finance schema)
* BA: CPF/UINO platform format (TBD)
* - Throws AdapterException(VALIDATION_BUSINESS_RULE) for constraint violations
* (non-EUR currency for HR, invalid OIB, empty 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 — see §2.3).
* - Returns SubmitResult on success; throws AdapterException on ALL failures.
* - NEVER propagates platform-native exceptions (Ktor ResponseException, etc.) —
* map every platform exception to AdapterException before propagating.
* - Implementations in STUB lifecycle MUST throw 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 bytes from serialize()
* @param invoice original CanonicalInvoice (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 SubmitResult.platformInvoiceId from submit().
* - Returns current EInvoiceStatus.
* - This method is IDEMPOTENT — safe to call multiple times with the same submissionId.
* - Callers implement exponential backoff; this method does NOT retry internally.
* - Implementations in STUB lifecycle MUST throw 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 platform webhook (Storecove POST, SEF callback).
* - Returns CanonicalInvoice with adapterMetadata populated for platform-specific fields:
* HR: "hr.supplierOib", "hr.buyerOib", "hr.pozivNaBroj"
* RS: "rs.supplierPib", "rs.buyerPib", "rs.sefId"
* - Implementations in STUB lifecycle MUST throw NOT_IMPLEMENTED (see §2.5).
* - NEVER 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
The internal invoice representation, independent of any platform wire format.
Defined in `EInvoiceTypes.kt` lines 141–156:
```kotlin
data class CanonicalInvoice(
val id: String, // Internal UUID — Storecove document_id (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/381/383/384)
val currencyCode: String, // BT-5: ISO 4217 ("EUR", "RSD", "BAM")
val jurisdiction: TaxJurisdiction, // Routing 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 constraints:**
| Field | Constraint | Enforced in |
| ----------------- | ------------------------------------------------------------------------------ | ---------------------- |
| `currencyCode` | "EUR" for HR (HALT-3 — Croatia adopted EUR 2023-01-01) | serialize() HR |
| `supplier.taxId` | OIB (HR, 11-digit ISO 7064 MOD 11,10) / PIB (RS, 9-digit) / JIB (BA, 13-digit) | serialize() per market |
| `lines` | Non-empty — EN 16931 §BG-25 minimum one line | serialize() |
| `taxBreakdowns` | Must sum to lines.(taxRate \* lineTotal) — tolerance 0.01 | InvoiceService |
| `adapterMetadata` | HR inbound: `hr.supplierOib`, `hr.buyerOib`, `hr.pozivNaBroj` | parseIncoming() |
**What CanonicalInvoice is NOT:**
- Not a DB entity (mapped from `invoices` + `invoice_items` tables on read)
- Not a REST API DTO (API layer maps separately)
- Not versioned independently — evolves with EN 16931 minor revisions
### 2.3 Adapter Lifecycle State Machine
Defined in `EInvoiceTypes.kt` lines 22–26. Transition criteria formalised here:
```
STUB
│ Compiles. All 3 network methods throw NOT_IMPLEMENTED.
│ serialize() MAY be operational (HR: already works offline).
│ AdapterConfig row not required.
│
│ Transition criteria → SANDBOX_VERIFIED:
│ 1. Provider account provisioned (MC #8675 for HR/Storecove)
│ 2. Credentials loaded in GCP Secret Manager (see §2.6 secret taxonomy)
│ 3. 5 sandbox test invoice types pass with REAL platform submission IDs (§2.4)
│ 4. pollStatus() confirmed for each submitted invoice
│ 5. Proveo evidence file with submission IDs uploaded 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 state (2026-05-13):** STUB
- `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 types required for SANDBOX_VERIFIED transition. All must 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 |
| 2 | 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 Bilko's webhook endpoint. `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 use (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 (offline contract).
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 tracked by the
`AdapterConfig` feature 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 is also missing credentials, `NOT_IMPLEMENTED` takes precedence.
Lifecycle state check 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 SA `bilko-stage-sa` |
| `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 (sandbox) | Cloud Run SA `bilko-stage-sa` |
| `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 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.7 Per-Platform Field Mapping
How `CanonicalInvoice` fields map to platform-specific XML/JSON:
| CanonicalInvoice field | HR (UBL 2.1 / Peppol) | RS (SEF XML) | BA-FED | BA-RS |
| ------------------------------- | ----------------------------------------------------------------- | -------------------------------- | ------ | ----- |
| `supplier.taxId` | `AccountingSupplierParty/.../CompanyID @schemeID="9934"` (OIB) | `/Invoice/Seller/TaxId` (PIB) | TBD | TBD |
| `buyer.taxId` | `AccountingCustomerParty/.../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 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 |
| `adapterMetadata` | `"hr.supplierOib"`, `"hr.buyerOib"`, `"hr.pozivNaBroj"` (inbound) | `"rs.sefId"`, `"rs.supplierPib"` | TBD | TBD |
**SEF XML note:** SEF does not use UBL 2.1. It uses a Serbian-specific XML schema published
by the Ministry of Finance. The SEF adapter maps `CanonicalInvoice` → SEF schema directly;
it does NOT go through UBL. `EInvoiceAdapter.serialize()` returns the platform's native 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.8 HR Reference Implementation Design Decisions
`StorecoveHrFiskEInvoiceAdapter` is the reference implementation. Future adapters MUST
replicate these patterns:
| Design decision | Location in reference impl | Rule for future adapters |
| ------------------------------------------------- | --------------------------------------------------- | ---------------------------------- |
| PII field redaction before logging | Lines 24–59 (`REDACT_PII_FIELDS`, `sanitizeForLog`) | REQUIRED — GDPR / audit rules |
| Offline serialization (no credentials) | Lines 567–571 (`serialize()`) | REQUIRED per §2.1 contract |
| Idempotency key (SHA-256 of id + invoiceNumber) | Lines 591–600 (stub comment — activate post-#8675) | REQUIRED if platform supports |
| Credential validation on startup flag | Lines 83–138 (`validateOnStartup`, `validate()`) | REQUIRED — default false for tests |
| Error code mapping to `AdapterException` | Lines 469–515 (`StorecoveErrorMapper`) | REQUIRED — NEVER propagate native |
| Structured metrics recording (`StorecoveMetrics`) | Lines 537–540 | REQUIRED — Prometheus counters |
| Tax ID format validation in `serialize()` | Lines 748–774 (OIB check) | REQUIRED — early error, no network |
| `document_id` for deduplication | Lines 420–437 (`StorecovePayloadBuilder.wrap()`) | REQUIRED if platform supports 409 |
---
## 3. Adapter Lifecycle Governance
### 3.1 AdapterConfig Feature Flag
All adapter network paths (`submit`, `pollStatus`, `parseIncoming`) are gated by an
`AdapterConfig` row in the database. Defined fully in ADR-019 §2.4; referenced here:
```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, -- Human-readable status note
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (market, adapter_type)
);
```
Seed row for HR STUB state (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 = true` by the operator (not by code) after SANDBOX_VERIFIED
transition is confirmed by Proveo evidence.
### 3.2 Adapter Versioning
Each adapter exposes:
```kotlin
val adapterVersion: String // e.g. "1.0.0"
```
The `CountryPlugin` implementation declares a minimum adapter 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` is the only exception type crossing the
adapter boundary. Callers implement one error handler, not four platform-specific ones.
- **Lifecycle visibility.** `lifecycleState` is first-class. Dashboards show "HR adapter: STUB"
and alert when a market operates in degraded state.
- **Canonical model.** `CanonicalInvoice` enables cross-market reporting and analytics.
- **NOT_IMPLEMENTED → HTTP 503.** Clients receive a 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 changes without semantic versioning
guarantees. The adapter must track schema changes proactively.
- **BA adapters are TBD.** Phase 1B work cannot begin until regulations define the spec.
- **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
(§2.5 rule 3) is a pragmatic workaround; it should not become a pattern.
### 4.3 Risks
- **CanonicalInvoice field gap.** A platform-specific required field has no canonical
counterpart. **Resolution:** `adapterMetadata: Map<String, String>` for platform-specific
extras until they generalise to 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. Verify 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().** Validates early (offline) but couples
format and validation logic. Accepted trade-off: early errors are better than late ones.
---
## 5. References
| Reference | Path | 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 + `AdapterException` | `apps/api/src/main/kotlin/no/alai/bilko/adapter/AdapterTypes.kt` | 1–41 |
| HR reference impl — full file | `apps/api/src/main/kotlin/no/alai/bilko/country/hr/StorecoveHrFiskEInvoiceAdapter.kt` | 1–777 |
| `StorecoveMetrics` (Micrometer counters) | `apps/api/src/main/kotlin/no/alai/bilko/country/hr/StorecoveMetrics.kt` | 1–73 |
| `StorecoveApiClient` (credentials + base URL) | `StorecoveHrFiskEInvoiceAdapter.kt` | 77–179 |
| `StorecoveOibValidator` (ISO 7064 MOD 11,10) | `StorecoveHrFiskEInvoiceAdapter.kt` | 194–225 |
| `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 serialization) | `~/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 to `StorecoveHrFiskEInvoiceAdapter`
- Phase 1H Task 1H.4: DI wiring — lifecycle state check before submit/pollStatus dispatch
- Phase 1H Task 1H.6: Storecove submit() activation (after MC #8675)
- ADR-019: Integration Adapter Registry — `AdapterConfig` table and secret taxonomy
| Role | Sign | Date |
| -------------------------------- | ----------------------------- | ---------- |
| 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
| Date | Author | Change |
| ---------- | --------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 2026-05-11 | Markos Zachariadis / Petter Graff | v1 — Phase 0' initial (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 |