ADR-015: Four-Jurisdiction Plugin Architecture
# ADR-015 — Four-Jurisdiction Plugin Architecture
(CountryPlugin Kotlin Interface)
**Status:** Accepted
**Date:** 2026-05-1113
**Author:** Petter Graff (CodeCraft — Architecture Lead)
**Decision-maker:** CEO Alem Bašić
Mehanik clearance: /tmp/mehanik-cleared-100362
**MC Task:** #100362#100585 (Phase 0' ADR Consolidation)
Supersedes: nothing (first formal plugin ADR)
Cross-references:
ADR-023 (transitional routingConsolidation —marketCountryPluginselectioninterface)already**Supersedes:**live)ADR-015 - v1 (2026-05-11, MC #100362) — this is the authoritative version
**Cross-references:**
- ADR-016 (EInvoiceAdapter —
one`generateEInvoiceXml()`ofandthe`submitToFiscalPlatform()`8 plugin methods delegatesdelegate to it) - - ADR-017 (RLS multi-tenancy —
TaxJurisdiction`TaxJurisdiction` enum drives`country_code` column values)country_code - - ADR-019 (Integration Adapter Registry — adapters
usedcalled by plugin implementations) - - ADR-023 (transitional routing — single backend, market selected from org record)
- ADR-bilko-001 (promoted as ADR-017 —
single-DB +Option C single-DB decision context) - - ADR-bilko-002 (extraction strategy — Variant C package
organisationisolation rationale) - - ADR-bilko-003 (3-layer market abstraction
layers—3-layerCountryPluginisolationismodel)Layer - 1)
- Plan v3 §4a, §4b, §
55, §6 Phase 0' —`~/system/specs/bilko-multi-market-architecture-plan-v3-2026-05-11.mdmd`
##
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 demoarchitecture (Option B).architecture. Market differentiation is currently handled
by two mechanisms:
- 1.
`ComplianceCalendarService.kt` — manualkt`when(organization.country)` branching2. `StorecoveHrFiskEInvoiceAdapter.kt` — directly implementskt`EInvoiceAdapter`; noEInvoiceAdapter`CountryPlugin` wrapper existsCountryPlugin
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, whichviolates the expertOpen/Closed teamprinciple unanimouslyand rejectedcreates asunbounded creatingaudit compoundingsurface.
coupling debt.
**Verified gapabsence:** (v3 plan §2, lines 46–66):
CountryPluginKotlin interface:DOES NOT EXIST(`find ... -name "CountryPlugin.kt"` returns zeroresults)PluginHR,PluginRS,PluginBAFED,PluginBARS:DO NOT EXISTTaxJurisdictionenumresults (v3 plan §2). `TaxJurisdiction.kt`ktlinecurrently8–12):has:only`HR, RS, BA` (BA—BA_FEDandBA_RSare missing.BAas a bare valueconflates twolegallydistincttaxfiscaljurisdictions.jurisdictions).
Current reality (TaxJurisdiction.ktlinestool-verified):** 8–12):
enumembeds class`orgId` TaxJurisdictionin {the HR,JWT, //NOT Croatia`org.country`.
—The Storecove/Peppol`org.country` value is fetched from the `organizations` DB table via FINA`orgId` AS4in RS,the //request
Serbiamiddleware. —All SEFDI (Sistemwiring e-faktura)in BA, // Bosnia & Herzegovina — (future)
}
Thisthis ADR supersedesreflects thethis baretwo-step lookup.
BAvalue### and formalises the four-jurisdiction model.
1.2 Problem
Without a plugin abstraction:
- -
- Each new market forces edits to
`ComplianceCalendarService`,ComplianceCalendarService`InvoiceService`, and any other service thatInvoiceServicecurrentlybranches on`organization.country`.country - - The Open/Closed principle is violated: adding Bosnia FBiH
supportrequires modifying existing codeinacross multiple files, not extending it. - - Tax auditors reviewing Croatian PDV compliance must read shared
servicefiles that also contain Serbian PDV logicand Bosnian stubs—theaudit surface is unbounded. The-`StorecoveHrFiskEInvoiceAdapter`StorecoveHrFiskEInvoiceAdapteris directly wired toEInvoiceAdapterbuthas no dispatch mechanismroutesrouting "HR org, generate invoice" totheitHRcleanly.adapter.###That routing must live somewhere clean.
1.3 BA Split Rationale
Bosnia-Herzegovina (BA) is not a single fiscal jurisdiction.jurisdiction:
It| hasDimension two| legal entities
with independent tax systems:
| 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 aA single `PluginBA` with internal branching PluginBAwould reproducereproduces the exact Variant B coupling 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```kotlin
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')
* (FlywayNOTE: BA bare value is retained in the Kotlin enum during the V16 migration —window
* to allow backfill of existing DB rows. Remove BA after V16 validates on prod.
*
* See: ADR-015 §2.1, Plan v3 §6 Phase 1H Task 1H.1)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, // Bosnia bare value — DEPRECATED, retained for V16 backfill window only
BA_FED, // Bosnia FBiH — BAM, CPF e-invoice (stub), UIO-FBiH, FBiH Pravilnik, PDV 17%
BA_RS, // Bosnia RS entity — BAM, UINO e-invoice (stub), Poreska Uprava RS entity, PDV 17%
}
```
**Migration note:** AnyFlyway existingV16 DBbackfills row`BA with→ then country_codeBA_FED` =rows, 'BA'must be migrated
to either 'BA_FED' or 'BA_RS' beforeadds the NOT NULL + CHECK
constraintconstraint. `BA` is applied.removed Asfrom ofthe 2026-05-11,enum therein area nocleanup payingMC customers,after soV16 thisvalidates migrationon isprod.
data-safe### (Flyway V16).
2.2 CountryPlugin Interface
The— Full CountryPlugininterfaceContract
is writtenWritten to `apps/api/src/main/kotlin/no/alai/bilko/country/CountryPlugin.kt:
**Interface invariant:** Zero `if jurisdiction ==` branches in core services
(`services/`, `routes/`). All market differences are absorbed here.
```kotlin
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 all market-specific behaviour.
*
* INVARIANT: ZeroNo `if jurisdiction == X` or `when(jurisdiction)` branches in
core* services.apps/api/src/main/kotlin/no/alai/bilko/{services,routes}/.
* All market
* differences are absorbed here. (ADR-bilko-002 Variant C / ADR-bilko-003 3-layer model)C)
*
* ImplementationsImplementations:
* PluginHR → country/hr/PluginHR.kt (Phase 1H — priority):
* - PluginHR → apps/api/src/main/kotlin/no/alai/bilko/country/hr/PluginHR.kt
* - PluginRS → stubcountry/rs/PluginRS.kt (stub, Phase 1S)
* - PluginBAFED → stubcountry/ba/PluginBAFED.kt (stub, Phase 1B)
* - PluginBARS → stubcountry/ba/PluginBARS.kt (stub, Phase 1B)
*
* DIDI: wiring: Ktor `plugins/DI.kt`kt registers all implementations4 andin selectsa Map<TaxJurisdiction, CountryPlugin>.
* basedResolution: onorgId `org.country`from JWT claim→ DB lookup organizations.country → TaxJurisdiction.valueOf()
* → PluginRegistry.resolve() (see ADR-023015 §3.3)2.4 for full pipeline).
*/
interface CountryPlugin {
/**
* Returns the tax jurisdiction this plugin handles.
* Used by PluginRegistry to route. Must be consistent with the plugin's
* registration key in the DI registry to route to the correct plugin.map.
*/
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:HR: 25% (S — standard), 13% (AA — reduced-1), 5% (E — reduced-2), 0% (Z — zero/export)
* RS example:RS: 20% (standard), 10% (reduced), 0% (export)
* BA-FED/FED / BA-RS: 17% (standard), 0% (export)
*
* @throws UnsupportedOperationException for stub implementations (RS, BA)
*/
fun calculateVat(invoice: CanonicalInvoice): VatResult
/**
* Generates jurisdiction-specific e-invoice XML/bytes from the canonical model.
*
* Delegates to the platform-specific [EInvoiceAdapter]EInvoiceAdapter.serialize()] for this jurisdiction.
* Returns the wire-format payload (UBL 2.1 XML wrappedStorecove inenvelope platformfor envelope)HR; SEF XML for RS).
* Contract: OFFLINE — no network, no credentials required.
*
* HR:@throws delegatesUnsupportedOperationException to StorecoveHrFiskEInvoiceAdapter.serialize() (WORKS offline)
* RS: SEF XML serializer (Phase 1S)
* BA: CPF/UINOfor stub (Phase 1B)implementations
*/
fun generateEInvoiceXml(invoice: CanonicalInvoice): ByteArray
/**
* Submits a previously serialized e-invoice to the fiscal platform.
*
* [receipt] containsbundles the serialized bytes from [generateEInvoiceXml] plus
*with the originating
* [CanonicalInvoice] for idempotency key generation.
* * Returns a [FiscalSubmissionHandle] with platform-assignedthe platform submission ID.
* Throws [no.alai.bilko.adapter.AdapterException] on all failure modes — NEVER platform-native exceptions.modes.
*
* 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 thenadd *or add/rename accounts — these are minimums.
*
* HR: FINA Kontni Plan (11-year retention required)retention)
* RS: Serbian Pravilnik (10-year retention)
* BA: FBiH/FBiH / RS entity Pravilnik (10-year retention)
*/
fun getChartOfAccountsDefaults(): List<ChartOfAccountEntry>
/**
* Returns filing deadline schedule for this jurisdiction.
*
* Returns a sorted list of [FilingDeadline] for the next 12 months from the call date.
* Used by ComplianceCalendarService to populate per-org reminder schedules.
*
* 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/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)10
* RS: 10 years (— Zakon o računovodstvu RS)RS
* BA:BA-FED / BA-RS: 10 years
(FBiH) / 10 years (RS entity)
*
* The [RetentionPolicy] is usedUsed 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 usesvalidates CanonicalInvoice.currencyCode against 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=tz='Europe/Zagreb'
* RS: decimal=',', thousands='.', date='dd.MM.yyyy', time zone=tz='Europe/Belgrade'
* BA: decimal=',', thousands='.', date='dd.MM.yyyy', time zone=tz='Europe/Sarajevo'
*
* Used by report generation, PDF invoices, and UI date/number display.
*/
fun getFormatters(): JurisdictionFormatters
/**
* Extension hook for jurisdiction-specific validation beyond the standard 8 methods.
*
* Called by InvoiceService before invoice creation. Default implementation is a no-op;
* override to add market-specific business rules (e.g., HR OIB cross-validation
* against FINA company registry once APRCompanyRegistryAdapter is live).
*
* This hook is the designated extension point to avoid adding new required interface
* methods for market-specific edge cases. See §3.2 for evolution contract.
*
* @param invoice draft canonical invoice before persistence
* @throws no.alai.bilko.adapter.AdapterException with VALIDATION_BUSINESS_RULE if invalid
*/
fun validateInvoiceForJurisdiction(invoice: CanonicalInvoice) {
// Default: no-op. Override in PluginHR, PluginRS etc. as needed.
}
}
``` ### 2.3 Supporting Value Types
These types are defined alongside Defined in CountryPluginthe `no.alai.bilko.country` package (or countryin a sub-package `no.alai.bilko.country.model`):model
```kotlin
// 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. BigDecimal("25.00000000")
val category: no.alai.bilko.einvoice.TaxCategory,
val taxableAmount: java.math.BigDecimal,
val taxAmount: java.math.BigDecimal,
val description: String, // Human-readablereadable, fore.g. audit"HR trailstandard PDV 25%"
)
// 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 entry
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 deadlinesdeadline
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-compatiblecompatible, e.g. "dd.MM.yyyy"
val timeZoneId: String, // IANA tztz, e.g. "Europe/Zagreb"
val currencySymbol: String,
val currencyPosition: CurrencyPosition, // PREFIX or SUFFIX
)
enum class CurrencyPosition { PREFIX, SUFFIX }
```
### 2.4 DI RegistrationWiring Pattern
Strategy
**JWT reality:** The KtorJWT DIaccess moduletoken contains `orgId` only (apps/api/src/main/kotlin/no/alai/bilko/plugins/DI.kt)verified registersin plugins`JwtService.kt`
keyedlines 35–45). The `org.country` value is NOT embedded in the JWT. It is fetched from the
`organizations` DB table at request time by middleware before the route handler runs.
**Resolution pipeline:**
```
HTTP request
→ JWT validation (JwtService.verifyAccessToken)
→ extract orgId from JWT claim "orgId"
→ DB: SELECT country FROM organizations WHERE id = orgId (OrgScopePlugin / middleware)
→ TaxJurisdiction.valueOf(country)
→ PluginRegistry.resolve(jurisdiction)
→ CountryPlugin dispatch
```
**DI registration in `plugins/DI.kt`:TaxJurisdiction
```kotlin
// DI.kt — CountryPlugin registry (Phase 1H Task 1H.4)4
val plugins:pluginRegistry: 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
)
// In Koin module:
single<Map<TaxJurisdiction, CountryPlugin>> { pluginRegistry }
// Resolution —helper called(usable from any serviceKoin-injected handler with org context:service):
fun resolvePlugin(
jurisdiction: TaxJurisdiction)TaxJurisdiction,
registry: Map<TaxJurisdiction, CountryPlugin>
): CountryPlugin = plugins[registry[jurisdiction]
?: throw IllegalStateException(
"No CountryPlugin registered for $jurisdiction — check DI.kt"kt registration"
)
```
**Services that need a `CountryPlugin` receive it via constructor injection:**
```kotlin
class InvoiceService(
private val pluginRegistry: Map<TaxJurisdiction, CountryPlugin>
// ... other deps
) {
private fun plugin(org: Organization): CountryPlugin =
resolvePlugin(TaxJurisdiction.valueOf(org.country), pluginRegistry)
}
```
### 2.5 OrgScopePlugin Sequencing Decision
**Decision: CountryPlugin resolution runs AFTER OrgScopePlugin (org isolation middleware).**
Rationale:
1. **Security gate must run first.** OrgScopePlugin validates that the authenticated user
belongs to the org being operated on and sets the `app.current_org_id` Postgres session
variable for RLS PERMISSIVE enforcement (Phase 2A). This is a security boundary; no
business logic should execute before it.
2. **CountryPlugin requires an authenticated, org-scoped context.** Resolving a
`CountryPlugin` requires reading `organizations.country` from DB, which in turn requires
a verified `orgId`. OrgScopePlugin is what establishes and validates that `orgId`.
3. **Failure mode is clean.** If OrgScopePlugin fails (user not in org, org not found),
the request is rejected with 403 before CountryPlugin resolution is attempted. No
country-specific logic runs on unauthenticated requests.
**Execution order in the Ktor pipeline:**
```
1. Authentication plugin (JWT validation)
2. OrgScopePlugin:
a. Validate user.org_id matches the resource being accessed
b. SET app.current_org_id = :orgId (for RLS)
c. Fetch org record → populate OrgContext (includes org.country)
3. CountryPlugin resolution:
a. TaxJurisdiction.valueOf(orgContext.country)
b. resolvePlugin(jurisdiction) → inject into route handler
4. Route handler executes with both OrgContext and CountryPlugin available
```
**Parisa Tabriz (Securion) note:** OrgScopePlugin must complete step 2b before any
CountryPlugin method is called. This ensures the RLS session variable is set before any
DB query inside the plugin executes. Violating this order creates a window where a
CountryPlugin DB query runs without the RLS filter active.
### 2.6 TypeScript Packages — Separate Concern
The JWTfive principalTypeScript carries packages (org.country`packages/domain-rs`, 'HR'|`packages/domain-hr`, `packages/domain-ba`,
'RS'|`packages/domain-ba-fed`, `packages/domain-ba-rs`) 'BA_FED'|contain 'BA_RS')frontend domain types compiled to
`dist/`. They are **not loaded by the Kotlin runtime** and are **not in scope for this ADR**.
The routing`TaxJurisdiction` pipeline:
HTTPvalues requestmust →remain JWTconsistent validationbetween →the extractKotlin org.countryenum →and TaxJurisdiction.valueOf(org.country)any
→TypeScript resolvePlugin() → CountryPlugin dispatch
This replaces all when(organization.country) branchesenums in servicethese code.
(same
string values: `"HR"`, `"RS"`, `"BA_FED"`, `"BA_RS"`). That alignment is enforced at the API boundary (JWT claim and REST API JSON) — not via a shared runtime dependency. Backwards compatibility rule: if `TaxJurisdiction` gains a new value (e.g., `SI` for Slovenia), the corresponding TypeScript packages must be updated in the same PR. This is a documentation constraint, not a compile-time enforcement. --- ## 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/){services,routes}/` that contains thepatterns:
string- `if.*jurisdiction`
jurisdictionor- `when.*jurisdiction`
jurisdictionor- `if.*country ==`
patterns.- `when.*country`
This enforces the invariant from ADR-bilko-002 §conclusion.
The linting rule is a Phase 1H gateCI —gate. itIt runs in CI before any Phase 1H code merges.
to main. The rule is not applied to `country/` package itself (plugin implementations may internally branch on jurisdiction during their own construction if absolutely necessary). ### 3.2 Interface Evolution Contract
When a new method must be added to `CountryPlugin`:CountryPlugin
- 1.
Add**Prefer the extension hook** (`validateInvoiceForJurisdiction`) for market-specific validation that does not generalise across all markets. 2. If a new method is genuinely cross-market: add it with a defaultimplementationbody that throws`UnsupportedOperationException("Not implemented for $jurisdiction —`.openseeMC"MC #XXXX")- 3. Override in
`PluginHR` (priority market)PluginHRfirst.first; Otherotherimplementationsplugins follow in theirmarketphase.phase.4. - Default throws
are acceptable until the market phase begins — theysurface as clear runtime errors, not silent wrong behaviour.
This## pattern is specified in ADR-bilko-002 §consequences (line 145–146).
4. Implementation Path
| Files | | Status | ||
|---|---|---|---|
| ------------------------------------------------------- | ------------------------------------------ | ----------------- | | Phase 0' | | This ADR | `docs/architecture/ADR-015- | ...md` | DONE |
| Phase 1H.1 | | `{HR,RS,BA,BA_FED,BA_RS} | ` | `TaxJurisdiction. | kt` |
| Phase 1H.1 | | `CountryPlugin.kt` interface + supporting types written | | `country/CountryPlugin.kt` (NEW) | |
| Phase 1H.2 | | `PluginHR` implemented | (9 methods + hook) | `country/hr/PluginHR.kt` (NEW) | |
| Phase 1H.3 | | `PluginRS`, `PluginBAFED`, `PluginBARS` | stubs | `country/{rs,ba}/Plugin*. | kt` |
| Phase 1H.4 | | DI | | `plugins/DI. | | |
| Phase 1H.5 | Flyway V16 — backfill BA→BA_FED, add NOT NULL + CHECK | `V16__country_jurisdiction_constraint.sql` | Blocked by 0'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 = one new
plugin.file.** Adding Slovenia (SI) requirescreating`PluginSI.kt`, onefileDI(PluginSI.kt), registering it in DI,registration, andadding`SI`added toSI`TaxJurisdiction`. ZeroTaxJurisdictionchanges tocoreservices.service - changes.
- **Bounded audit surface.** Croatian
tax authority reviewingPDVcomplianceauditorsreadsread`country/hr/PluginHR.kt` only.ktNo-RS or BA logic mixed in. - **Team parallelism.** HR sprint and RS sprint
canwork concurrently ontheir plugin implementations without merge conflicts (separatefiles,files.separate-packages). Historical correctness via versioned**Versioned CoA.**`getChartOfAccountsDefaults()` seeds Pravilnik data; rate changesarehandled via the versioned`chart_of_accounts` table (ADR-017 §2.chart_of_accounts44)./###ADR-bilko-003 §Layer 3).
5.2 Negative
- -
Interface**Newevolutionrequired method 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.22)above).+ - extension hook for non-cross-cutting additions.
- **Boilerplate at scaffolding time.** Each
market requiresmarket: ~89 method bodies, CoA seed data,and atest harness. Estimate: 2 days per market for the core plugin scaffold.
**OrgScopePlugin coupling.** CountryPlugin resolution depends on OrgScopePlugin having run and fetched the org record. If OrgScopePlugin is ever refactored, the CountryPlugin resolution pipeline must be updated in lockstep. ### 5.3 Risks
- -
- **Jurisdiction if-branches in core services.**
Developers under deadlineDeadline pressurewill be temptedleads towrite`if (jurisdiction == TaxJurisdiction.HR)`inshortcuts.**Mitigation:**InvoiceServicerather than extending the plugin interface.Mitigation:ktlint/Detektlintrule (§3.1).+-code review gate. - **Stub plugin
silentHTTPfailure.500.** If`PluginRS` isPluginRSregistered asa stub and an RS user triggers`calculateVat()`,the`UnsupportedOperationException` propagates asUnsupportedOperationExceptionanHTTP500500.to the user.**Mitigation:** DI registry should checklifecycle`lifecycleState`state onat request time and return HTTP 503 (market feature notavailableavailable).for-market)**BAratherbackfillthanassumption.**500.V16
`BA
→ BA_FED` as default. If any existing BA org is actually RS entity, the assumption is wrong. **Mitigation:** CEO notified before V16 runs on prod; manual verification of all BA rows (currently 0 paying customers). --- ## 6. References
| Lines Referenced | ||
|---|---|---|
| ---------------- |
| `TaxJurisdiction. | kt` (current) | `apps/api/src/main/kotlin/no/alai/bilko/country/TaxJurisdiction. | kt` | 1– | 23
`JwtService.kt` ( | claims — orgId only) | `apps/api/src/main/kotlin/no/alai/bilko/auth/JwtService.kt` | 35–45 |
| `BilkoPrincipal.kt` | `apps/api/src/main/kotlin/no/alai/bilko/auth/BilkoPrincipal.kt` | 1–10 |
| `EInvoiceAdapter` interface | `apps/api/src/main/kotlin/no/alai/bilko/einvoice/EInvoiceTypes. | kt` | 200–224 |
`StorecoveHrFiskEInvoiceAdapter.kt` (HR | | `apps/api/src/main/kotlin/no/alai/bilko/country/hr/StorecoveHrFiskEInvoiceAdapter. | kt` | 537– | 777
| `apps/api/src/main/kotlin/no/alai/bilko/plugins/DI.kt` ||
| ||
| ||
| ||
| 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 data migration) **Unblocks:
**- -
- Phase 1H Task 1H.1:
`TaxJurisdiction` enum expansion +TaxJurisdiction`CountryPlugin.kt`ktauthoring- - Phase 1H Task 1H.2:
`PluginHR` implementationPluginHR - - ADR-
016016: EInvoiceAdapter contract (EInvoiceAdapter) —referenced from`generateEInvoiceXml()`)contract- - ADR-
019019: Adapter Registry (Adapter Registry) —referenced from`submitToFiscalPlatform()`)contract|
| ---------- | | Architecture Lead (Petter Graff) | | Signed | | 2026-05- | 13
| CEO (Alem Bašić) | | Not required for interface ADR | | — |
8. Document History
| Change | ||
|---|---|---|
| ------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | 2026-05-11 | | Petter Graff |