Skip to main content

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 routingConsolidationmarketCountryPlugin selectioninterface) already**Supersedes:** live)
  • ADR-015
  • v1 (2026-05-11, MC #100362) — this is the authoritative version **Cross-references:** - ADR-016 (EInvoiceAdapter — one`generateEInvoiceXml()` ofand the`submitToFiscalPlatform()` 8 plugin methods delegatesdelegate to it)
  • - ADR-017 (RLS multi-tenancy — TaxJurisdiction`TaxJurisdiction` enum drives country_code`country_code` column values)
  • - 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-layerCountryPlugin isolationis model)
  • Layer
  • 1) - Plan v3 §4a, §4b, §55, §6 Phase 0'`~/system/specs/bilko-multi-market-architecture-plan-v3-2026-05-11.md
  • md`
---
##

1. Context

### 1.1 Current State (tool-verified 2026-05-11)

The Kotlin/Ktor backend (`bilko-api-demodemo`, 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).architecture. Market differentiation is currently handled by two mechanisms:

    1.
  1. `ComplianceCalendarService.ktkt` — manual `when(organization.country)` branching
  2. 2. `StorecoveHrFiskEInvoiceAdapter.ktkt` — directly implements EInvoiceAdapter`EInvoiceAdapter`; no CountryPlugin`CountryPlugin` wrapper 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, whichviolates the expertOpen/Closed teamprinciple unanimouslyand rejectedcreates asunbounded creatingaudit compoundingsurface. coupling debt.

**Verified gapabsence:** (v3 plan §2, lines 46–66):

  • CountryPlugin Kotlin interface: DOES NOT EXIST (`find ... -name "CountryPlugin.kt"` returns zero results)
  • PluginHR, PluginRS, PluginBAFED, PluginBARS: DO NOT EXIST
  • TaxJurisdiction enumresults (v3 plan §2). `TaxJurisdiction.ktkt` linecurrently 8–12):has: only `HR, RS, BA` (BA — BA_FED and BA_RS are missing. BA as a bare value conflates two legally distinct taxfiscal jurisdictions.
  • jurisdictions).
**JWT

Current TaxJurisdiction.ktreality (linestool-verified):** 8–12):

`JwtService.kt`
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 BAlookup. value### and formalises the four-jurisdiction model.

1.2 Problem

Without a plugin abstraction:

    -
  • Each new market forces edits to ComplianceCalendarService`ComplianceCalendarService`, InvoiceService`InvoiceService`, and any other service that currently branches on `organization.countrycountry`.
  • - The Open/Closed principle is violated: adding Bosnia FBiH support requires modifying existing code inacross 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- StorecoveHrFiskEInvoiceAdapter`StorecoveHrFiskEInvoiceAdapter` is directly wired to EInvoiceAdapter buthas no dispatch mechanism routesrouting "HR org, generate invoice" to theit HRcleanly. 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:

||------------------||||||||||||||
DimensionBA-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`PluginBA` with internal branching would 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 country_codeBA_FED` =rows, 'BA'then 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 CountryPluginFull interfaceContract is writtenWritten to `apps/api/src/main/kotlin/no/alai/bilko/country/CountryPlugin.kt:

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 CountryPluginDefined in the `no.alai.bilko.countrycountry` package (or in a sub-package `no.alai.bilko.country.modelmodel`):

```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 TaxJurisdictionmiddleware 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`:

**
```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 org.countrypackages ('HR'`packages/domain-rs`, |`packages/domain-hr`, 'RS'`packages/domain-ba`, |`packages/domain-ba-fed`, 'BA_FED'`packages/domain-ba-rs`) |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:

enum
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.

packages
(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.*jurisdictionjurisdiction` or- `when.*jurisdictionjurisdiction` or- `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.

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.
  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 default implementationbody that throws `UnsupportedOperationException("Not implemented for $jurisdiction — opensee MC"MC #XXXX")`.
  2. 3. Override in PluginHR`PluginHR` (priority market) first.
  3. first;
  4. Otherother implementationsplugins follow in their marketphase. phase.
  5. 4.
  6. 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|||----------| ...md`||`kt`||||||stubskt`||||||kt`||kt`|---
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`TaxJurisdiction` expanded to `{HR,RS,BA,BA_FED,BA_RS} | `TaxJurisdiction.kt BLOCKED| BYBlocked by 0'
Phase 1H.1 | `CountryPlugin.ktkt` interface + supporting types written | `country/CountryPlugin.ktkt` (NEW) BLOCKED| BYBlocked by 0'
Phase 1H.2 PluginHR| `PluginHR` implemented (9 methods + hook) | `country/hr/PluginHR.ktkt` (NEW) BLOCKED| BYBlocked by 1H.1
Phase 1H.3 PluginRS| `PluginRS`, PluginBAFED`PluginBAFED`, PluginBARS`PluginBARS` stub | `country/{rs,ba}/Plugin*.kt BLOCKED| BYBlocked by 1H.1
Phase 1H.4 | DI registrationregistration; +OrgScopePlugin V16order Flywayenforced migration `plugins/DI.kt,kt` V16__*.sql BLOCKEDBlocked BYby 1H.2+3
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| `PluginRS` fully implemented | `country/rs/PluginRS.kt | Post-HR GA
Phase 1B PluginBAFED| `PluginBAFED`, PluginBARS`PluginBARS` implemented | `country/ba/Plugin*.kt | Post-RS GA
##

5. Consequences

### 5.1 Positive

    -
  • **Fifth market = one new plugin.file.** Adding Slovenia (SI) requires creating`PluginSI.kt`, one fileDI (PluginSI.kt), registering it in DI,registration, and adding`SI` SIadded to TaxJurisdiction`TaxJurisdiction`. Zero changes to core services.
  • service
  • changes. - **Bounded audit surface.** Croatian tax authority reviewing PDV complianceauditors readsread `country/hr/PluginHR.ktkt` only. 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,files. separate- packages).
  • Historical correctness via versioned**Versioned CoA.** `getChartOfAccountsDefaults()` seeds Pravilnik data; rate changes are handled via the versioned chart_of_accounts`chart_of_accounts` table (ADR-017 §2.44). /### ADR-bilko-003 §Layer 3).

5.2 Negative

    -
  • Interface**New evolutionrequired 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 a test 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 pressure will be temptedleads to write `if (jurisdiction == TaxJurisdiction.HR)` inshortcuts. InvoiceService**Mitigation:** rather than extending the plugin interface. Mitigation: ktlint/Detekt lint rule (§3.1). +- code review gate.
  • **Stub plugin silentHTTP failure.500.** If PluginRS`PluginRS` is registered as a stub and an RS user triggers `calculateVat()`, the UnsupportedOperationException`UnsupportedOperationException` propagates as an HTTP 500500. to the user. **Mitigation:** DI registry should check lifecycle`lifecycleState` state onat request time and return HTTP 503 (market feature not availableavailable). for- market)**BA ratherbackfill thanassumption.** 500.
  • V16
migrates
`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

|Reference|Path|||-----------------------------------------------------|kt`kt`23||claimskt`|||kt`777|||`apps/api/src/main/kotlin/no/alai/bilko/plugins/DI.kt` | md`||md`||md`||md`|---
Reference Path Lines Referenced
Current------------------------------------------------------------------------------------- | ---------------- | | `TaxJurisdiction.kt (current) | `apps/api/src/main/kotlin/no/alai/bilko/country/TaxJurisdiction.kt | 1–12
EInvoiceTypes.kt`JwtService.kt` (EInvoiceAdapter,JWT CanonicalInvoice) — 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.ktkt` (HR referencereference) impl) `apps/api/src/main/kotlin/no/alai/bilko/country/hr/StorecoveHrFiskEInvoiceAdapter.kt | 537–776
ADR-023 §3.3`DI.kt` (backendcurrent Koin module — no country differentiationplugin viayet) 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.md100–122
ADR-bilko-002 §decision (Variant C package extraction)~/system/specs/bilko-multi-market-architecture-plan/ADR-bilko-002-extraction-reuse-strategy.md57–111
ADR-bilko-003 §Layer| 1–367 (abstraction| layers) ~/system/specs/bilko-multi-market-architecture-plan/ADR-bilko-003-market-abstraction-layers.md59–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 data migration) **Unblocks:

**
    -
  • Phase 1H Task 1H.1: TaxJurisdiction`TaxJurisdiction` enum expansion + `CountryPlugin.ktkt` authoring
  • -
  • Phase 1H Task 1H.2: PluginHR`PluginHR` implementation
  • - ADR-016016: EInvoiceAdapter contract (EInvoiceAdapter) — referenced from `generateEInvoiceXml()`) contract
  • -
  • ADR-019019: Adapter Registry (Adapter Registry) — referenced from `submitToFiscalPlatform()`) contract
  • |
Role |Sign|Date||--------------------------------|------------------------------|13|||---
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|||----------|||
Date Author Change
------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | 2026-05-11 | Petter Graff Initial| v1 — Phase 0' ADR consolidationinitial (MC #100362)
2026-05-13 | Petter Graff | v2 — MC #100585: OrgScopePlugin sequencing decision; JWT reality (orgId, not country claim); extension hook `validateInvoiceForJurisdiction`; TypeScript packages backwards-compat section; DI wiring corrected to reflect actual JwtService contract |