ADR-015: Four-Jurisdiction Plugin Architecture
ADR-015 — Four-Jurisdiction Plugin Architecture
Status: Accepted Date: 2026-05-11 Author: Petter Graff (CodeCraft — Architecture Lead) Decision-maker: CEO Alem Bašić Mehanik clearance: /tmp/mehanik-cleared-100362 MC Task: #100362 (Phase 0' ADR Consolidation) Supersedes: nothing (first formal plugin ADR) Cross-references:
- ADR-023 (transitional routing — market selection already live)
- ADR-016 (EInvoiceAdapter — one of the 8 plugin methods delegates to it)
- ADR-017 (RLS multi-tenancy — TaxJurisdiction enum drives
country_codecolumn values) - ADR-019 (Integration Adapter Registry — adapters used by plugin implementations)
- ADR-bilko-001 (promoted as ADR-017 — single-DB + Option C decision context)
- ADR-bilko-002 (extraction strategy — package organisation rationale)
- ADR-bilko-003 (market abstraction layers — 3-layer isolation model)
- Plan v3 §4a, §4b, §5 —
~/system/specs/bilko-multi-market-architecture-plan-v3-2026-05-11.md
1. Context
1.1 Current State (tool-verified 2026-05-11)
The Kotlin/Ktor backend (bilko-api-demo, Cloud Run, europe-north1) serves three brand
hostnames (bilko.cloud, bilko.company, bilko.io) via a single runtime. ADR-023 established
this as the deliberate transitional architecture (Option B). Market differentiation is
currently handled by two mechanisms:
ComplianceCalendarService.kt— manualwhen(organization.country)branchingStorecoveHrFiskEInvoiceAdapter.kt— directly implementsEInvoiceAdapter; noCountryPluginwrapper exists
Neither mechanism is pluggable. Adding a fourth market, or a new per-market behaviour (e.g., HR Kontni Plan defaults on org creation), requires editing shared service files. This is Variant B (runtime branching) from ADR-bilko-002, which the expert team unanimously rejected as creating compounding coupling debt.
Verified gap (v3 plan §2, lines 46–66):
CountryPluginKotlin interface: DOES NOT EXIST (find ... -name "CountryPlugin.kt"returns zero results)PluginHR,PluginRS,PluginBAFED,PluginBARS: DO NOT EXISTTaxJurisdictionenum (TaxJurisdiction.ktline 8–12): onlyHR, RS, BA—BA_FEDandBA_RSare missing.BAas a bare value conflates two legally distinct tax jurisdictions.
Current TaxJurisdiction.kt (lines 8–12):
enum class TaxJurisdiction {
HR, // Croatia — Storecove/Peppol via FINA AS4
RS, // Serbia — SEF (Sistem e-faktura)
BA, // Bosnia & Herzegovina — (future)
}
This ADR supersedes the bare BA value and formalises the four-jurisdiction model.
1.2 Problem
Without a plugin abstraction:
- Each new market forces edits to
ComplianceCalendarService,InvoiceService, and any other service that currently branches onorganization.country. - The Open/Closed principle is violated: adding Bosnia FBiH support requires modifying existing code in multiple files, not extending it.
- Tax auditors reviewing Croatian PDV compliance must read shared service files that also contain Serbian PDV logic and Bosnian stubs — the audit surface is unbounded.
- The
StorecoveHrFiskEInvoiceAdapteris directly wired toEInvoiceAdapterbut no dispatch mechanism routes "HR org, generate invoice" to the HR adapter. That routing must live somewhere clean.
1.3 BA Split Rationale
Bosnia-Herzegovina (BA) is not a single fiscal jurisdiction. It has two legal entities
with independent tax systems:
| Dimension | BA-FED | BA-RS |
|---|---|---|
| Formal name | Federacija BiH | Republika Srpska entity |
| Tax authority | UIO-FBiH (Uprava za indirektno oporezivanje FBiH) | Poreska Uprava RS entity |
| E-invoice platform | CPF (stub, mandatory ~2027) | UINO (stub, mandatory TBD) |
| Filing pravilnik | FBiH Pravilnik o kontnom okviru | RS entity Pravilnik |
| Company identifier | JIB (13 digits) | JIB (13 digits) |
| PDV rate | 17% standard, no reduced | 17% standard, no reduced |
| Currency | BAM | BAM |
Different fiscal authorities, different e-invoice platforms, and different Pravilnik
rules mean a single PluginBA with internal branching would reproduce the exact Variant B
problem (ADR-bilko-002 §3). The split is required.
(See ADR-bilko-003 §context table, lines 17–30 for full per-dimension comparison.)
2. Decision
2.1 TaxJurisdiction Enum — Canonical Form
The canonical TaxJurisdiction enum in
apps/api/src/main/kotlin/no/alai/bilko/country/TaxJurisdiction.kt is:
package no.alai.bilko.country
/**
* Canonical tax jurisdictions supported by Bilko.
*
* BA bare value is retired — BA-FED and BA-RS are distinct fiscal jurisdictions
* with different tax authorities and e-invoice platforms.
*
* This enum is the single source of truth for jurisdiction routing throughout
* the Kotlin backend. It maps directly to the `country_code` column values
* in the `organizations` table (ADR-017 RLS migration).
*
* DB column constraint: CHECK country_code IN ('HR', 'RS', 'BA_FED', 'BA_RS')
* (Flyway V16 migration — Phase 1H Task 1H.1)
*/
enum class TaxJurisdiction {
HR, // Croatia — EUR, Storecove/Peppol via FINA AS4, PDV 25/13/5%
RS, // Serbia — RSD, SEF (Sistem e-faktura), PDV 20/10%
BA_FED, // Bosnia FBiH — BAM, CPF e-invoice (stub), PDV 17%
BA_RS, // Bosnia RS entity — BAM, UINO e-invoice (stub), PDV 17%
}
Migration note: Any existing DB row with country_code = 'BA' must be migrated
to either 'BA_FED' or 'BA_RS' before the CHECK constraint is applied. As of
2026-05-11, there are no paying customers, so this migration is data-safe (Flyway V16).
2.2 CountryPlugin Interface
The CountryPlugin interface is written to
apps/api/src/main/kotlin/no/alai/bilko/country/CountryPlugin.kt:
package no.alai.bilko.country
import no.alai.bilko.einvoice.CanonicalInvoice
import no.alai.bilko.einvoice.EInvoiceAdapter
import java.util.Currency
/**
* Per-jurisdiction plugin — the single extension point for market-specific behaviour.
*
* INVARIANT: Zero `if jurisdiction ==` branches in core services. All market
* differences are absorbed here. (ADR-bilko-002 Variant C / ADR-bilko-003 3-layer model)
*
* Implementations (Phase 1H priority):
* - PluginHR → apps/api/src/main/kotlin/no/alai/bilko/country/hr/PluginHR.kt
* - PluginRS → stub (Phase 1S)
* - PluginBAFED → stub (Phase 1B)
* - PluginBARS → stub (Phase 1B)
*
* DI wiring: Ktor `plugins/DI.kt` registers all implementations and selects
* based on `org.country` JWT claim (ADR-023 §3.3).
*/
interface CountryPlugin {
/**
* Returns the tax jurisdiction this plugin handles.
* Used by the DI registry to route to the correct plugin.
*/
fun jurisdiction(): TaxJurisdiction
/**
* Calculates VAT breakdown for the given canonical invoice.
*
* Returns a [VatResult] containing itemised tax lines per rate band.
* Core invoice service calls this; NEVER inspects jurisdiction directly.
*
* HR example: 25% (standard), 13% (reduced-1), 5% (reduced-2), 0% (zero/export)
* RS example: 20% (standard), 10% (reduced), 0% (export)
* BA-FED/BA-RS: 17% (standard), 0% (export)
*/
fun calculateVat(invoice: CanonicalInvoice): VatResult
/**
* Generates jurisdiction-specific e-invoice XML/bytes from canonical model.
*
* Delegates to the platform-specific [EInvoiceAdapter] for this jurisdiction.
* Returns the wire-format payload (UBL 2.1 XML wrapped in platform envelope).
*
* HR: delegates to StorecoveHrFiskEInvoiceAdapter.serialize() (WORKS offline)
* RS: SEF XML serializer (Phase 1S)
* BA: CPF/UINO stub (Phase 1B)
*/
fun generateEInvoiceXml(invoice: CanonicalInvoice): ByteArray
/**
* Submits a previously serialized e-invoice to the fiscal platform.
*
* [receipt] contains the serialized bytes from [generateEInvoiceXml] plus
* the originating [CanonicalInvoice] for idempotency key generation.
*
* Returns a [FiscalSubmissionHandle] with platform-assigned submission ID.
* Throws [AdapterException] on all failure modes — NEVER platform-native exceptions.
*
* HR lifecycle: STUB until MC #8675 (Storecove account activation).
*/
fun submitToFiscalPlatform(receipt: FiscalReceipt): FiscalSubmissionHandle
/**
* Returns the default Chart of Accounts entries for this jurisdiction.
*
* Called once on org creation (onboarding wizard) to seed the tenant's
* account list with the mandatory Pravilnik accounts. Company may then
* add/rename accounts — these are minimums.
*
* HR: FINA Kontni Plan (11-year retention required)
* RS: Serbian Pravilnik (10-year retention)
* BA: FBiH/RS entity Pravilnik (10-year retention)
*/
fun getChartOfAccountsDefaults(): List<ChartOfAccountEntry>
/**
* Returns filing deadline schedule for this jurisdiction.
*
* HR: quarterly PDV return (last working day of month after quarter end)
* + annual CIT (30 April), via ePorezna
* RS: monthly PDV return (within 15 days of month end), via ePorezi
* BA: FBiH/RS entity PDV return schedules
*
* Returns a sorted list of [FilingDeadline] for the next 12 months from
* the call date. Consumers display these as compliance calendar reminders.
*/
fun getFilingDeadlines(): List<FilingDeadline>
/**
* Returns data retention policy for this jurisdiction.
*
* HR: 11 years (Zakon o računovodstvu NN 78/2015, čl. 10)
* RS: 10 years (Zakon o računovodstvu RS)
* BA: 10 years (FBiH) / 10 years (RS entity)
*
* The [RetentionPolicy] is used by the document archiving service (ADR-022)
* to set per-org retention periods and by the RLS audit partition (ADR-017 Phase 2B).
*/
fun getRetentionRules(): RetentionPolicy
/**
* Returns the functional currency for this jurisdiction.
*
* HR: Currency.getInstance("EUR") — Croatia adopted EUR 2023-01-01
* RS: Currency.getInstance("RSD")
* BA-FED / BA-RS: Currency.getInstance("BAM")
*
* Core invoice service uses this to validate `CanonicalInvoice.currencyCode`
* against the org's jurisdiction on invoice creation.
*/
fun getCurrency(): Currency
/**
* Returns locale-specific formatters for this jurisdiction.
*
* HR: decimal='.', thousands=',', date='dd.MM.yyyy', time zone='Europe/Zagreb'
* RS: decimal=',', thousands='.', date='dd.MM.yyyy', time zone='Europe/Belgrade'
* BA: decimal=',', thousands='.', date='dd.MM.yyyy', time zone='Europe/Sarajevo'
*
* Used by report generation, PDF invoices, and UI date/number display.
*/
fun getFormatters(): JurisdictionFormatters
}
2.3 Supporting Value Types
These types are defined alongside CountryPlugin in the no.alai.bilko.country package
or in a sub-package no.alai.bilko.country.model:
// VAT calculation result
data class VatResult(
val lines: List<VatLine>,
val totalVatAmount: java.math.BigDecimal,
val totalTaxableAmount: java.math.BigDecimal,
)
data class VatLine(
val rate: java.math.BigDecimal, // e.g. 25.0000
val category: no.alai.bilko.einvoice.TaxCategory,
val taxableAmount: java.math.BigDecimal,
val taxAmount: java.math.BigDecimal,
val description: String, // Human-readable for audit trail
)
// Fiscal submission input — bundles serialize() output with canonical invoice
data class FiscalReceipt(
val serializedInvoice: ByteArray,
val canonicalInvoice: no.alai.bilko.einvoice.CanonicalInvoice,
)
data class FiscalSubmissionHandle(
val platformInvoiceId: String, // Storecove GUID, SEF ID, etc.
val initialStatus: no.alai.bilko.einvoice.EInvoiceStatus,
val submittedAt: java.time.Instant,
)
// Chart of Accounts
data class ChartOfAccountEntry(
val code: String, // e.g. "1300" (HR) or "204" (RS)
val name: String,
val type: AccountType, // ASSET, LIABILITY, EQUITY, INCOME, EXPENSE
val vatTreatment: String?,
)
// Filing deadlines
data class FilingDeadline(
val name: String, // e.g. "Quarterly PDV return Q1 2026"
val dueDate: java.time.LocalDate,
val authority: String, // e.g. "Porezna uprava HR (ePorezna)"
val periodStart: java.time.LocalDate,
val periodEnd: java.time.LocalDate,
)
// Data retention
data class RetentionPolicy(
val years: Int, // 10 or 11 depending on jurisdiction
val legalBasis: String, // Statutory reference
val jurisdiction: TaxJurisdiction,
)
// Formatters
data class JurisdictionFormatters(
val decimalSeparator: Char,
val thousandsSeparator: Char,
val datePattern: String, // ISO strftime-compatible
val timeZoneId: String, // IANA tz e.g. "Europe/Zagreb"
val currencySymbol: String,
val currencyPosition: CurrencyPosition, // PREFIX or SUFFIX
)
enum class CurrencyPosition { PREFIX, SUFFIX }
2.4 DI Registration Pattern
The Ktor DI module (apps/api/src/main/kotlin/no/alai/bilko/plugins/DI.kt)
registers plugins keyed by TaxJurisdiction:
// DI.kt — CountryPlugin registry (Phase 1H Task 1H.4)
val plugins: Map<TaxJurisdiction, CountryPlugin> = mapOf(
TaxJurisdiction.HR to PluginHR(StorecoveHrFiskEInvoiceAdapter()),
TaxJurisdiction.RS to PluginRS(), // stub — Phase 1S
TaxJurisdiction.BA_FED to PluginBAFED(), // stub — Phase 1B
TaxJurisdiction.BA_RS to PluginBARS(), // stub — Phase 1B
)
// Resolution — called from any service handler with org context:
fun resolvePlugin(jurisdiction: TaxJurisdiction): CountryPlugin =
plugins[jurisdiction] ?: throw IllegalStateException(
"No CountryPlugin registered for $jurisdiction — check DI.kt"
)
The JWT principal carries org.country ('HR' | 'RS' | 'BA_FED' | 'BA_RS').
The routing pipeline:
HTTP request → JWT validation → extract org.country →
TaxJurisdiction.valueOf(org.country) → resolvePlugin() → CountryPlugin dispatch
This replaces all when(organization.country) branches in service code.
3. Enforcement
3.1 Linting Rule
A custom ktlint or Detekt rule must reject any file in
apps/api/src/main/kotlin/no/alai/bilko/core/ (or services/, routes/) that
contains the string if.*jurisdiction or when.*jurisdiction or
if.*country == patterns. This enforces the invariant from ADR-bilko-002 §conclusion.
The linting rule is a Phase 1H gate — it runs in CI before any Phase 1H code merges.
3.2 Interface Evolution Contract
When a new method must be added to CountryPlugin:
- Add the method with a default implementation that throws
UnsupportedOperationException("Not implemented for $jurisdiction — open MC"). - Override in
PluginHR(priority market) first. - Other implementations follow in their market phase.
- Default throws are acceptable until the market phase begins — they surface as clear runtime errors, not silent wrong behaviour.
This pattern is specified in ADR-bilko-002 §consequences (line 145–146).
4. Implementation Path
| Phase | Task | Files | Status |
|---|---|---|---|
| Phase 0' | This ADR written to disk | docs/architecture/ADR-015-FOUR-JURISDICTION-PLUGIN.md |
DONE |
| Phase 1H.1 | TaxJurisdiction enum expanded to {HR,RS,BA_FED,BA_RS} |
TaxJurisdiction.kt |
BLOCKED BY 0' |
| Phase 1H.1 | CountryPlugin.kt interface written |
country/CountryPlugin.kt (NEW) |
BLOCKED BY 0' |
| Phase 1H.2 | PluginHR implemented |
country/hr/PluginHR.kt (NEW) |
BLOCKED BY 1H.1 |
| Phase 1H.3 | PluginRS, PluginBAFED, PluginBARS stub |
country/{rs,ba}/Plugin*.kt |
BLOCKED BY 1H.1 |
| Phase 1H.4 | DI registration + V16 Flyway migration | plugins/DI.kt, V16__*.sql |
BLOCKED BY 1H.2+3 |
| Phase 1S | PluginRS fully implemented |
country/rs/PluginRS.kt |
Post-HR GA |
| Phase 1B | PluginBAFED, PluginBARS implemented |
country/ba/Plugin*.kt |
Post-RS GA |
5. Consequences
5.1 Positive
- Fifth market = new plugin. Adding Slovenia (SI) requires creating one file
(
PluginSI.kt), registering it in DI, and addingSItoTaxJurisdiction. Zero changes to core services. - Bounded audit surface. Croatian tax authority reviewing PDV compliance reads
country/hr/PluginHR.ktonly. No RS or BA logic mixed in. - Team parallelism. HR sprint and RS sprint can work concurrently on their plugin implementations without merge conflicts (separate files, separate packages).
- Historical correctness via versioned CoA.
getChartOfAccountsDefaults()seeds Pravilnik data; rate changes are handled via the versionedchart_of_accountstable (ADR-017 §2.4 / ADR-bilko-003 §Layer 3).
5.2 Negative
- Interface evolution touches all 4 implementations. A new required method (not optional with default throw) requires updates to PluginHR, PluginRS, PluginBAFED, PluginBARS. Mitigation: default throw pattern (§3.2 above).
- Boilerplate at scaffolding time. Each market requires ~8 method bodies, CoA seed data, and a test harness. Estimate: 2 days per market for the core plugin scaffold.
5.3 Risks
- Jurisdiction if-branches in core services. Developers under deadline pressure
will be tempted to write
if (jurisdiction == TaxJurisdiction.HR)inInvoiceServicerather than extending the plugin interface. Mitigation: ktlint/Detekt lint rule (§3.1) + code review gate. - Stub plugin silent failure. If
PluginRSis registered as a stub and an RS user triggerscalculateVat(), theUnsupportedOperationExceptionpropagates as an HTTP 500 to the user. Mitigation: DI registry should check lifecycle state on request and return HTTP 503 (feature not available for market) rather than 500.
6. References
| Reference | Path | Lines Referenced |
|---|---|---|
Current TaxJurisdiction.kt |
apps/api/src/main/kotlin/no/alai/bilko/country/TaxJurisdiction.kt |
1–12 |
EInvoiceTypes.kt (EInvoiceAdapter, CanonicalInvoice) |
apps/api/src/main/kotlin/no/alai/bilko/einvoice/EInvoiceTypes.kt |
200–224 |
StorecoveHrFiskEInvoiceAdapter.kt (HR reference impl) |
apps/api/src/main/kotlin/no/alai/bilko/country/hr/StorecoveHrFiskEInvoiceAdapter.kt |
537–776 |
| ADR-023 §3.3 (backend country differentiation via JWT) | docs/architecture/ADR-023-TRANSITIONAL-MULTI-MARKET-ROUTING.md |
125–132 |
| ADR-bilko-001 §decision (Option C — single backend) | ~/system/specs/bilko-multi-market-architecture-plan/ADR-bilko-001-multi-tenant-architecture.md |
100–122 |
| ADR-bilko-002 §decision (Variant C package extraction) | ~/system/specs/bilko-multi-market-architecture-plan/ADR-bilko-002-extraction-reuse-strategy.md |
57–111 |
| ADR-bilko-003 §Layer 1–3 (abstraction layers) | ~/system/specs/bilko-multi-market-architecture-plan/ADR-bilko-003-market-abstraction-layers.md |
59–143 |
| Plan v3 §2 current state truth | ~/system/specs/bilko-multi-market-architecture-plan-v3-2026-05-11.md |
28–73 |
| Plan v3 §4a (Option D not triggered) | ~/system/specs/bilko-multi-market-architecture-plan-v3-2026-05-11.md |
100–119 |
| Plan v3 §4b (Phase 0 ADR scope) | ~/system/specs/bilko-multi-market-architecture-plan-v3-2026-05-11.md |
121–133 |
| Plan v3 §6 Phase 0' Task 0'1 | ~/system/specs/bilko-multi-market-architecture-plan-v3-2026-05-11.md |
246–255 |
7. Approval
Status: Accepted — no CEO sign required (architecture contract, not migration) Unblocks:
- Phase 1H Task 1H.1:
TaxJurisdictionenum expansion +CountryPlugin.ktauthoring - Phase 1H Task 1H.2:
PluginHRimplementation - ADR-016 (EInvoiceAdapter) — referenced from
generateEInvoiceXml()contract - ADR-019 (Adapter Registry) — referenced from
submitToFiscalPlatform()contract
| Role | Sign | Date |
|---|---|---|
| Architecture Lead (Petter Graff) | Signed | 2026-05-11 |
| CEO (Alem Bašić) | Not required for interface ADR | — |
8. Document History
| Date | Author | Change |
|---|---|---|
| 2026-05-11 | Petter Graff | Initial — Phase 0' ADR consolidation (MC #100362) |