Skip to main content

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_code column 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:

  1. ComplianceCalendarService.kt — manual when(organization.country) branching
  2. StorecoveHrFiskEInvoiceAdapter.kt — directly implements EInvoiceAdapter; no 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, which the expert team unanimously rejected as creating compounding coupling debt.

Verified gap (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 enum (TaxJurisdiction.kt line 8–12): only HR, RS, BABA_FED and BA_RS are missing. BA as 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 on organization.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 StorecoveHrFiskEInvoiceAdapter is directly wired to EInvoiceAdapter but 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:

  1. Add the method with a default implementation that throws UnsupportedOperationException("Not implemented for $jurisdiction — open MC").
  2. Override in PluginHR (priority market) first.
  3. Other implementations follow in their market phase.
  4. 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 adding SI to TaxJurisdiction. Zero changes to core services.
  • Bounded audit surface. Croatian tax authority reviewing PDV compliance reads country/hr/PluginHR.kt 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, separate packages).
  • Historical correctness via versioned CoA. getChartOfAccountsDefaults() seeds Pravilnik data; rate changes are handled via the versioned chart_of_accounts table (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) in InvoiceService rather than extending the plugin interface. Mitigation: ktlint/Detekt lint rule (§3.1) + code review gate.
  • Stub plugin silent failure. If PluginRS is registered as a stub and an RS user triggers calculateVat(), the UnsupportedOperationException propagates 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: TaxJurisdiction enum expansion + CountryPlugin.kt authoring
  • Phase 1H Task 1H.2: PluginHR implementation
  • 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)