Skip to main content

ADR-015 — Four-Jurisdiction Plugin Architecture (CountryPlugin Kotlin Interface)

ADR-015 — Four-Jurisdiction Plugin Architecture (CountryPlugin Kotlin Interface)

Status: Accepted Date: 2026-05-13 Author: Petter Graff (CodeCraft — Architecture Lead) Decision-maker: CEO Alem Bašić MC Task: #100585 (Phase 0' ADR Consolidation — CountryPlugin interface) Supersedes: ADR-015 v1 (2026-05-11, MC #100362) — this is the authoritative version Cross-references:

  • ADR-016 (EInvoiceAdapter — generateEInvoiceXml() and submitToFiscalPlatform() delegate to it)
  • ADR-017 (RLS multi-tenancy — TaxJurisdiction enum drives country_code column values)
  • ADR-019 (Integration Adapter Registry — adapters called by plugin implementations)
  • ADR-023 (transitional routing — single backend, market selected from org record)
  • ADR-bilko-001 (promoted as ADR-017 — Option C single-DB decision context)
  • ADR-bilko-002 (extraction strategy — Variant C package isolation rationale)
  • ADR-bilko-003 (3-layer market abstraction — CountryPlugin is Layer 1)
  • Plan v3 §4a, §4b, §5, §6 Phase 0' — ~/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. 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 requires editing shared service files. This violates the Open/Closed principle and creates unbounded audit surface.

Verified absence: find ... -name "CountryPlugin.kt" returns zero results (v3 plan §2). TaxJurisdiction.kt currently has: HR, RS, BA (BA conflates two distinct fiscal jurisdictions).

JWT reality (tool-verified): JwtService.kt embeds orgId in the JWT, NOT org.country. The org.country value is fetched from the organizations DB table via orgId in the request middleware. All DI wiring in this ADR reflects this two-step lookup.

1.2 Problem

Without a plugin abstraction:

  • Each new market forces edits to ComplianceCalendarService, InvoiceService, and any other service that branches on organization.country.
  • The Open/Closed principle is violated: adding Bosnia FBiH requires modifying existing code across multiple files, not extending it.
  • Tax auditors reviewing Croatian PDV compliance must read shared files that also contain Serbian PDV logic — audit surface is unbounded.
  • StorecoveHrFiskEInvoiceAdapter has no dispatch mechanism routing "HR org, generate invoice" to it cleanly.

1.3 BA Split Rationale

Bosnia-Herzegovina is not a single fiscal jurisdiction:

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

A single PluginBA with internal branching reproduces the Variant B coupling problem (ADR-bilko-002 §3). The split is required.


2. Decision

2.1 TaxJurisdiction Enum — Canonical Form

package no.alai.bilko.country

/**
 * Canonical tax jurisdictions supported by Bilko.
 *
 * DB column constraint: CHECK country_code IN ('HR', 'RS', 'BA_FED', 'BA_RS')
 * NOTE: 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.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 (stub), Poreska Uprava RS entity, PDV 17%
}

Migration note: Flyway V16 backfills BA → BA_FED rows, then adds the NOT NULL + CHECK constraint. BA is removed from the enum in a cleanup MC after V16 validates on prod.

2.2 CountryPlugin Interface — Full Contract

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

package no.alai.bilko.country

import no.alai.bilko.einvoice.CanonicalInvoice
import no.alai.bilko.einvoice.EInvoiceAdapter
import java.util.Currency

/**
 * Per-jurisdiction plugin — single extension point for all market-specific behaviour.
 *
 * INVARIANT: No `if jurisdiction == X` or `when(jurisdiction)` branches in
 * apps/api/src/main/kotlin/no/alai/bilko/{services,routes}/.
 * All market differences are absorbed here. (ADR-bilko-002 Variant C)
 *
 * Implementations:
 *   PluginHR      → country/hr/PluginHR.kt          (Phase 1H — priority)
 *   PluginRS      → country/rs/PluginRS.kt           (stub, Phase 1S)
 *   PluginBAFED   → country/ba/PluginBAFED.kt        (stub, Phase 1B)
 *   PluginBARS    → country/ba/PluginBARS.kt         (stub, Phase 1B)
 *
 * DI: plugins/DI.kt registers all 4 in a Map<TaxJurisdiction, CountryPlugin>.
 * Resolution: orgId from JWT → DB lookup organizations.country → TaxJurisdiction.valueOf()
 * → PluginRegistry.resolve() (see ADR-015 §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 map.
     */
    fun jurisdiction(): TaxJurisdiction

    /**
     * Calculates VAT breakdown for the given canonical invoice.
     *
     * Returns [VatResult] containing itemised tax lines per rate band.
     * Core invoice service calls this; NEVER inspects jurisdiction directly.
     *
     * HR: 25% (S — standard), 13% (AA — reduced-1), 5% (E — reduced-2), 0% (Z — zero/export)
     * RS: 20% (standard), 10% (reduced), 0% (export)
     * BA-FED / BA-RS: 17% (standard), 0% (export)
     *
     * @throws UnsupportedOperationException for stub implementations (RS, BA)
     */
    fun calculateVat(invoice: CanonicalInvoice): VatResult

    /**
     * Generates jurisdiction-specific e-invoice bytes from the canonical model.
     *
     * Delegates to the platform-specific [EInvoiceAdapter.serialize()] for this jurisdiction.
     * Returns the wire-format payload (UBL 2.1 XML Storecove envelope for HR; SEF XML for RS).
     * Contract: OFFLINE — no network, no credentials required.
     *
     * @throws UnsupportedOperationException for stub implementations
     */
    fun generateEInvoiceXml(invoice: CanonicalInvoice): ByteArray

    /**
     * Submits a previously serialized e-invoice to the fiscal platform.
     *
     * [receipt] bundles the serialized bytes from [generateEInvoiceXml] with the originating
     * [CanonicalInvoice] for idempotency key generation.
     * Returns [FiscalSubmissionHandle] with the platform submission ID.
     * Throws [no.alai.bilko.adapter.AdapterException] on all failure modes.
     *
     * HR lifecycle: STUB until MC #8675 (Storecove account activation).
     */
    fun submitToFiscalPlatform(receipt: FiscalReceipt): FiscalSubmissionHandle

    /**
     * Returns default Chart of Accounts entries for this jurisdiction.
     *
     * Called once on org creation to seed the tenant's account list with the
     * mandatory Pravilnik accounts. Company may add or rename — these are minimums.
     *
     * HR: FINA Kontni Plan (11-year retention)
     * 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.
     *
     * 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)
     * RS: monthly PDV return (within 15 days of month end)
     * BA: FBiH / RS entity PDV return schedules
     */
    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-FED / BA-RS: 10 years
     *
     * Used by the document archiving service 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 validates CanonicalInvoice.currencyCode against this on creation.
     */
    fun getCurrency(): Currency

    /**
     * Returns locale-specific formatters for this jurisdiction.
     *
     * HR: decimal='.', thousands=',', date='dd.MM.yyyy', tz='Europe/Zagreb'
     * RS: decimal=',', thousands='.', date='dd.MM.yyyy', tz='Europe/Belgrade'
     * BA: decimal=',', thousands='.', date='dd.MM.yyyy', 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

Defined in no.alai.bilko.country package (or 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. BigDecimal("25.0000")
    val category: no.alai.bilko.einvoice.TaxCategory,
    val taxableAmount: java.math.BigDecimal,
    val taxAmount: java.math.BigDecimal,
    val description: String,                 // Human-readable, e.g. "HR standard PDV 25%"
)

// Fiscal submission input
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 deadline
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, e.g. "dd.MM.yyyy"
    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 Wiring Strategy

JWT reality: The JWT access token contains orgId only (verified in JwtService.kt lines 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:

// Phase 1H Task 1H.4
val 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 (usable from any Koin-injected service):
fun resolvePlugin(
    jurisdiction: TaxJurisdiction,
    registry: Map<TaxJurisdiction, CountryPlugin>
): CountryPlugin = registry[jurisdiction]
    ?: throw IllegalStateException(
        "No CountryPlugin registered for $jurisdiction — check DI.kt registration"
    )

Services that need a CountryPlugin receive it via constructor injection:

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 five TypeScript packages (packages/domain-rs, packages/domain-hr, packages/domain-ba, packages/domain-ba-fed, packages/domain-ba-rs) contain frontend domain types compiled to dist/. They are not loaded by the Kotlin runtime and are not in scope for this ADR.

The TaxJurisdiction enum values must remain consistent between the Kotlin enum and any TypeScript enums in these 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 Detekt rule must reject any file in apps/api/src/main/kotlin/no/alai/bilko/{services,routes}/ that contains patterns:

  • if.*jurisdiction
  • when.*jurisdiction
  • if.*country ==
  • when.*country

This rule is a Phase 1H CI gate. It runs 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:

  1. 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 body that throws UnsupportedOperationException("Not implemented for $jurisdiction — see MC #XXXX").
  3. Override in PluginHR (priority market) first; other plugins follow in their phase.
  4. Default throws surface as clear runtime errors, not silent wrong behaviour.

4. Implementation Path

Phase Task Files Status
Phase 0' This ADR docs/architecture/ADR-015-...md DONE
Phase 1H.1 TaxJurisdiction expanded {HR,RS,BA,BA_FED,BA_RS} TaxJurisdiction.kt Blocked by 0'
Phase 1H.1 CountryPlugin.kt interface + supporting types written country/CountryPlugin.kt (NEW) Blocked by 0'
Phase 1H.2 PluginHR implemented (9 methods + hook) country/hr/PluginHR.kt (NEW) Blocked by 1H.1
Phase 1H.3 PluginRS, PluginBAFED, PluginBARS stubs country/{rs,ba}/Plugin*.kt Blocked by 1H.1
Phase 1H.4 DI registration; OrgScopePlugin order enforced plugins/DI.kt Blocked by 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 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 file. Adding Slovenia (SI) requires PluginSI.kt, one DI registration, and SI added to TaxJurisdiction. Zero core service changes.
  • Bounded audit surface. Croatian PDV auditors read country/hr/PluginHR.kt only.
  • Team parallelism. HR sprint and RS sprint work concurrently on separate files.
  • Versioned CoA. getChartOfAccountsDefaults() seeds Pravilnik data; rate changes handled via the versioned chart_of_accounts table (ADR-017 §2.4).

5.2 Negative

  • New required method touches all 4 implementations. Mitigation: default throw pattern (§3.2) + extension hook for non-cross-cutting additions.
  • Boilerplate at scaffolding time. Each market: ~9 method bodies, CoA seed data, 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. Deadline pressure leads to if (jurisdiction == TaxJurisdiction.HR) shortcuts. Mitigation: Detekt rule (§3.1).
  • Stub plugin HTTP 500. If PluginRS is a stub and an RS user triggers calculateVat(), UnsupportedOperationException propagates as HTTP 500. Mitigation: DI registry should check lifecycleState at request time and return HTTP 503 (market feature not available).
  • BA backfill assumption. 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 Lines Referenced
TaxJurisdiction.kt (current) apps/api/src/main/kotlin/no/alai/bilko/country/TaxJurisdiction.kt 1–23
JwtService.kt (JWT 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 reference) apps/api/src/main/kotlin/no/alai/bilko/country/hr/StorecoveHrFiskEInvoiceAdapter.kt 537–777
DI.kt (current Koin module — no country plugin yet) apps/api/src/main/kotlin/no/alai/bilko/plugins/DI.kt 1–67
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 + CountryPlugin.kt
  • Phase 1H Task 1H.2: PluginHR implementation
  • ADR-016: EInvoiceAdapter contract (referenced from generateEInvoiceXml())
  • ADR-019: Adapter Registry (referenced from submitToFiscalPlatform())
Role Sign Date
Architecture Lead (Petter Graff) Signed 2026-05-13
CEO (Alem Bašić) Not required for interface ADR

8. Document History

Date Author Change
2026-05-11 Petter Graff v1 — Phase 0' initial (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