Overview & Architecture

Project overview, architecture, roadmap, and architectural decisions

Drop — Project Handbook

Drop — Fintech Payment App

Quick Info

Production Infrastructure (current_state — 2026-04-30)

Drop production = Azure VM, NOT AWS.

Component Value
Host Azure VM vm-drop-prod
Resource Group RG-DROP-PROD
Region Sweden Central
Size Standard_B2s_v2
IP 51.107.177.193
Reverse proxy Caddy (alai-caddy-1 container)
App runtime docker-compose (drop-app + drop-api + Redis + Postgres)
DNS app.getdrop.no → A 51.107.177.193 (unproxied)
Mode demo (pre-licensing)

AWS App Runner was agent-fabricated infrastructure without CEO authorization. It was sunset 2026-04-30 per MC #10353. It never served real traffic on app.getdrop.no. See: feedback_drop_aws_phantom_2026-04-30.md.

ADR-001 MANDATORY before any future cloud migration (Azure Container Apps, Cloud Run, AWS, etc). No agent may propose or execute a cloud migration without ADR-001 approved by CEO.

Licensing & Unified Platform Strategy (CEO approved 2026-02-24)

"Drop je razlog zašto radiš licencu. Bilko je bonus. API platforma je jackpot."

One licence — three products

# Product Market Uses
1 Drop Norway → EEA PISP + AISP (payments + remittance)
2 Bilko Accounting SaaS HR/RS/BiH AISP (automatic bank feed) via Tok
3 Tok Platform HR/RS/BiH + global Open Banking API — AISP infrastructure sold to others

Tok is the independent Open Banking platform (~/ALAI/products/Tok/). Drop and Bilko are consumers of Tok API. The licence/PII for Drop covers Tok too. Just add AISP scope.

Banking partner status

Licence paths

Key decisions

Branding

Folder Structure

UI Source of Truth

Core Features (Pass-through PSD2 model)

  1. Remittance — send money abroad to 30+ countries (PISP from user's bank account)
  2. QR Payments — pay in-store by scanning QR (PISP from user's bank account)
  3. Bank Accounts — view linked bank account balances via AISP (Open Banking)
  4. Notifications — push notifications and transaction alerts
  5. Settings — user preferences and account management
  6. Transaction History — view all transactions with filters

IMPORTANT: Pass-through model

User Requirements (ENFORCED — from vilkår)

Tech Stack (ADR-014, updated 2026-03-03)

Rules

Project Overview

Project Overview

Documentation Index

Drop Documentation Index

Last updated: 2026-02-17 | Validated: 20/20 PASS after doc alignment audit

Backend

Document Description
API Reference All 26 API endpoints — method, path, request/response, auth, rate limits
Database Schema All 19 tables (12 core + 7 compliance) — columns, types, constraints, indexes
Authentication JWT auth flow — register, login, refresh, logout, middleware
Services External integrations — Sumsub (KYC) [PRODUCTION], Stripe (Cards) [MOCK], Swan [DEPRECATED]
Middleware Auth, validation, rate limiting, CSRF, error handling
Feature Flags 8 feature flags, 16 tracked features, server/client APIs

Frontend

Document Description
Component Inventory All components — custom, icons, shadcn/ui primitives
Pages All 20 routes — auth, components, data fetching, compliance pages
Design System Colors, typography (Fraunces/DM Sans/Geist Mono), spacing, patterns
State Management useAuth hook, feature flags, data fetching patterns
Landing Pages Marketing site — 9 sections, 12 sub-pages, waitlist API

Mobile

Document Description
Mobile App Expo Router architecture, 8 screens, API client, theme

Infrastructure

Document Description
Deployment Docker, Fly.io, 3 deployment configs (MVP/Production/Staging)
CI/CD GitHub Actions pipeline — lint, test, build, e2e, docker (5 jobs)
Monitoring Health checks, container monitoring, gaps identified
Environment Tech stack, npm scripts, Next.js config, env modes

Security

Document Description
Security Architecture JWT, cookies, bcrypt, CSRF, rate limiting, input validation
Compliance PSD2, AML, GDPR, DORA readiness — 8/100 overall, remediation plan

Testing

Document Description
Testing Guide Vitest + Playwright, running tests, mocking, patterns
Test Inventory All 14 test files — unit, integration, e2e, regression, performance

Quality Assurance

Document Description
Validation Report Cross-reference audit of all docs against source code
Project Overview

Business Case (ZiCA v2)

Drop — Business Case v2 (Remittance + QR Payments)

Note: Originally titled "Drop — Business Case v2". Product has been rebranded to Drop. Target audience broadened from diaspora-only to ALL residents in Norway/Scandinavia. Business model updated to pass-through PSD2 (PISP/AISP) — Drop NEVER holds customer money. See Drop CLAUDE.md for current spec.

Date: 2026-02-08 (updated 2026-02-14) Version: 2.1 Compiled by: John (AI Director) Sources: 8 AI agents — 2 runde analize Pivotni insight: Alem


Executive Summary

Drop je fintech app za sve stanovnike Norveške/Skandinavije sa dva revenue streama:

  1. Remittance — pošalji novac u inostranstvo jeftinije (primatelj NE treba app)
  2. QR Merchant Payments — plaćaj u dućanu skeniranjem QR koda (kao UPI u Indiji)

Isti korisnik, dva use-case-a, pass-through PSD2 model (Drop NIKAD ne drži novac korisnika). Ovo stvara flywheel efekat.


1. Vizija

┌─────────────────────────────────────────────────────────┐
│                     DROP ECOSYSTEM                       │
│                                                          │
│   POŠILJALAC (Norveška)          PRIMATELJ (inostranstvo)│
│   ┌──────────┐                   ┌──────────┐           │
│   │ Drop App │─── remittance ──▶│ Bank/Cash │           │
│   │ (PISP)   │   via Open Banking│ (no app!) │           │
│   └────┬─────┘                   └──────────┘           │
│        │                                                 │
│        │ QR scan                                         │
│        ▼                                                 │
│   ┌──────────┐                                          │
│   │ Merchant │ ← lokalni biznisi u Norveškoj            │
│   │ QR Code  │ ← jeftiniji od Vipps (1% vs 1.75-2.75%) │
│   └──────────┘                                          │
│                                                          │
│   FLYWHEEL:                                              │
│   Više korisnika → više merchanta → više korisnika       │
│   DROP NIKAD NE DRŽI NOVAC — pass-through PSD2 model    │
└─────────────────────────────────────────────────────────┘

2. Tržište (data-engineer agent)

Podatak Vrijednost Izvor
Imigranti u Norveškoj ~1,000,000 SSB
Remittance iz Norveške godišnje 5.7 mlrd NOK World Bank
Prosječna remittance tx ~1,000 NOK World Bank
SME u Norveškoj ~195,000 SSB
Top remittance korridori Srbija, Poljska, Pakistan, Iran, Turska SSB
Lokalni biznisi (procjena) 30,000-50,000 SSB estimate

3. Dva Revenue Streama

Stream 1: Remittance

Aspekt Detalj
Šta Slanje novca iz Norveške u Balkan, Pakistan, Tursku, itd.
Kako Drop app → PISP (Open Banking) via bank partner → bank transfer/cash pickup
Primatelj NE treba app — prima na račun ili cash
Fee 0.5% (vs Wise 0.7-1.5%, vs WU 5-10%)
Corridors NOK→RSD, NOK→BAM, NOK→PKR, NOK→TRY, NOK→PLN, NOK→EUR

Stream 2: QR Merchant Payments

Aspekt Detalj
Šta Plaćanje u dućanu skeniranjem QR koda
Kako Merchant prikaže QR → customer skenira → instant transfer
Merchant Lokalni biznisi (kebab, kiosk, pekara, restoran, frizer)
Fee 1% (vs Vipps 1.75-2.75%)
Settlement Daily batch payout na merchant bank račun
Tech qrcode.js (generisanje) + html5-qrcode (skeniranje)

Flywheel

Korisnik šalje remittance → navikne na Drop → plaća u lokalnom dućanu QR-om
Merchant prihvati QR → preporuči Drop → korisnik šalje i remittance
→ REPEAT

4. User Journeys

Journey A: Remittance

  1. Amir otvori Drop, tap "Pošalji novac"
  2. Odabere: Srbija, mama Jasmina, njen broj računa
  3. Unese 2,000 NOK → vidi: primatelj dobije 23,400 RSD, fee 10 NOK (0.5%)
  4. Potvrdi, plati sa norveške kartice
  5. Mama dobije SMS: "Primili ste 23,400 RSD od Amira"
  6. Novac na računu za 1-2 radna dana

Journey B: QR Payment

  1. Amir uđe u Ahmetov kebab shop u Oslu
  2. Na kasi je Drop QR naljepnica
  3. Amir otvori Drop, tap "Skeniraj"
  4. Skenira QR → prikaže se: "Ahmetov Kebab, unesi iznos"
  5. Unese 129 NOK, tap "Plati"
  6. Ahmet dobije notifikaciju: "Primljeno 129 NOK od Amir"
  7. Instant. Bez terminala. Fee 1.29 NOK umjesto 3.55 NOK (Vipps).

Journey C: Killer Combo

  1. Amir šalje 5,000 NOK mami — dobije 25 Drop bodova
  2. Plaća kebab 129 NOK QR-om — dobije 1 bod
  3. Na 50 bodova: besplatna remittance (no fee)
  4. Ahmet (merchant) vidi: "Ove sedmice: 47 transakcija, 12,300 NOK, fee 123 NOK"
  5. Ahmet preporuči Drop svim korisnicima → novi korisnici → više remittance

5. Merchant Onboarding (3 minuta)

  1. Vlasnik skine Drop app
  2. Tap "Registruj biznis" → unese: naziv, adresa, bank račun
  3. KYC: lična karta + org.nummer
  4. Dobije QR kod — printaj ili koristi na telefonu
  5. Lijepi QR na kasu
  6. Gotovo. Prima plaćanja odmah.

6. Finansijski Model (KORIGIRAN — realistične projekcije)

Startup Costs

Stavka Iznos (NOK)
Development (AI-first) 10,000
Open Banking integracija (PSD2) 15,000
Legal + compliance setup 50,000
Marketing launch 100,000
QR naljepnice + merchant kit 20,000
Buffer 55,000
UKUPNO 250,000 NOK

Revenue Projection (KONZERVATIVAN)

Period Remittance korisnici Merchant-i MRR Remittance MRR Merchant Ukupni MRR
Mj 1-3 200 20 2,000 10,000 12,000
Mj 4-6 1,000 80 10,000 40,000 50,000
Mj 7-12 3,000 200 30,000 100,000 130,000
Year 1 avg 3,000 200 30,000 100,000 130,000
Year 2 avg 8,000 500 80,000 250,000 330,000
Year 3 avg 15,000 1,000 150,000 500,000 650,000

Napomena: MRR Remittance = korisnici × 2 tx/mj × 1,000 NOK × 0.5%. MRR Merchant = merchanti × 50,000 NOK/mj promet × 1%.

ARR Projection

Godina ARR (NOK)
Year 1 ~1,000,000
Year 2 ~4,000,000
Year 3 ~7,800,000

Monthly Costs (post-launch)

Stavka NOK/mj
Bank partner fees 10,000-20,000
Hosting + infra 2,000
Claude Code (development) 1,100
Marketing (ongoing) 30,000-50,000
Support + compliance 10,000
Mjesečni burn ~55,000-85,000

Break-Even

Scenarij Break-even MRR Kad?
Optimistički 85,000 NOK/mj Mjesec 5-6
Realistički 85,000 NOK/mj Mjesec 7-9
Pesimistički 85,000 NOK/mj Mjesec 12-14

Unit Economics

Segment CAC LTV (24mj) LTV:CAC
Consumer (remittance) 100 NOK 2,400 NOK 24:1
Merchant (QR) 500 NOK 24,000 NOK 48:1

Merchant LTV je IZUZETAN jer je recurring i visok volumen.


7. Competitive Landscape

Konkurent Remittance QR Payments Dijaspora focus Fee
Vipps ❌ Samo Norveška ✅ Ali skupo za merchante 1.75-2.75% merchant
Wise ✅ Cross-border ❌ No merchant 0.7-1.5%
Revolut ✅ Ali generic ❌ Limited 0.5-1.5%
Western Union ✅ Ali skupo ✅ Ali 2005 UX 5-10%
MoneyGram ✅ Ali skupo ✅ Ali 2005 UX 4-8%
Drop ✅ Jeftino ✅ QR (1%) ✅ Za sve u Norveškoj 0.5% + 1%

Niko ne radi oba. To je naš moat.


8. Tech Architecture (dev agent)

QR Payment Flow

┌──────────┐    scan     ┌──────────┐   confirm   ┌──────────┐
│ Merchant │────────────▶│ Customer │─────────────▶│  Drop    │
│ QR Code  │   camera    │  App     │   amount     │  Server  │
└──────────┘             └──────────┘              └─────┬────┘
                                                         │
                                              PISP via Open Banking
                                              (direct bank transfer)
                                                         │
                                                   daily batch
                                                   settlement
                                                         │
                                                   ┌─────▼────┐
                                                   │ Merchant │
                                                   │ Bank Acc │
                                                   └──────────┘

Key Tech Decisions

Decision Choice Why
QR generation qrcode.js Lightweight, static QR per merchant
QR scanning html5-qrcode Camera API, works on all phones
Payment initiation PISP (Open Banking) Direct from user's bank account
Settlement Daily batch payout Via BaaS partner to merchant bank
Offline Store-and-forward Queue payments locally, sync when online

9. Roadmap

Version Timeline Features Revenue Impact
v1 MVP 5 sedmica Remittance (3 corridors: RSD, BAM, PLN) + basic QR payment First revenue
v2 +4 sedmice More corridors (PKR, TRY, EUR) + merchant dashboard + loyalty Growth
v3 +6 sedmica Business accounts + invoice integration + API for partners Scale
v4 +8 sedmica White-label za partnere + advanced analytics New revenue stream

10. Risk Matrix (Updated)

Rizik Severity Mitigacija
Bank partner dependency HIGH Multi-provider ready, modular architecture
Vipps launches remittance HIGH Already ahead in market, community trust
Regulatory issues MEDIUM Agentmodell under bank partner licence
Slow merchant adoption MEDIUM Door-to-door u lokalnim zajednicama
Security breach CRITICAL Threat model + security agent + httpOnly JWT
Cash flow pre break-even MEDIUM Bootstrap + Innovasjon Norge grant

11. GO / NO-GO

Za GO:

Rizici:

Preporuka: GO

Ovo nije "još jedna payment app". Ovo je specifičan alat za sve u Norveškoj koji šalju novac u inostranstvo ili žele jeftinije plaćanje u lokalnim dućanima. Build MVP, launch u Oslu, grow from there.


Agents koji su doprinijeli (v2)

Agent Runda 1 Runda 2 Ukupan doprinos
nicksaraev Biznis model Dual revenue + TAM Revenue strategy
product Product strategy User journeys + roadmap Product vision
legal Compliance Regulatory map
finance Budget Dual stream financials Financial model
marketer GTM strategy Marketing plan
security Threat model Security architecture
dev Architecture QR tech architecture Tech decisions
data-engineer Market data Tržišna analiza

8 od 15 agenata aktivirano. 2 runde analize. Alemov insight: širi tržište, ne samo dijaspora.


Compiled: 2026-02-08 by John (AI Director) Status: Awaiting Alem GO/NO-GO

Project Overview

Bilko — Project Handbook

Bilko — Balkan Accounting SaaS

BookStack — Provjeri PRVO

Prije traženja bilo čega — provjeri BookStack (https://docs.basicconsulting.no). Centralna baza znanja za tools, skills, hooks, agents, rules, projekte, klijente, dokumentaciju. Ako odgovor postoji tamo — NE TRAŽI dalje.

Quick Info

Branding

Tech Stack (updated 2026-03-17)

Project Structure

Bilko/
├── apps/
│   ├── web/          # Next.js 15 frontend — 8+ pages, MOCK DATA
│   ├── api/          # Kotlin/Ktor backend — canonical (ADR-020+ADR-021)
├── packages/
│   ├── database/     # Prisma schema — 15 models, FULLY DEFINED
│   ├── domain-rs/    # Serbia domain plugin
│   ├── domain-ba/    # Bosnia & Herzegovina domain plugin
│   ├── domain-ba-fed/# BiH Federation domain plugin
│   ├── domain-ba-rs/ # Republika Srpska domain plugin
│   ├── domain-hr/    # Croatia domain plugin
│   └── ui/           # Shared UI — empty scaffold
├── docs/             # Documents (see docs/INDEX.md)
├── infrastructure/   # Docker, GCP, terraform
├── tools/            # figma-plugin, ci-stubs
├── CLAUDE.md         # This file
└── PIPELINE.md       # Gate tracker

Frontend Status (apps/web/)

IMPLEMENTED:

MOCK DATA: All data from apps/web/lib/mock-data.ts — MUST be replaced with real API calls when backend ready.

Database Status (packages/database/)

FULLY DEFINED: 15 models in prisma/schema.prisma

KEY DECISIONS:

Backend Status (apps/api/)

CANONICAL. Kotlin/Ktor backend (ADR-020+ADR-021, 2026-04-29). Express/api-express deleted 2026-05-02 (MC #10493, CEO directive). Kotlin/Ktor backend: apps/api/CLAUDE.md. API contract: docs/backend/API-REFERENCE.md.

Development Rules

  1. Money = NUMERIC(19,4) — NEVER use float or number for currency
  2. Double-entry always — Every financial event = debit + credit entries
  3. Multi-currency locking — Exchange rate locked at transaction date
  4. Immutable audit — LoggedAction is append-only, NEVER delete
  5. Mock data replacement — Flag all mock data usage, replace with API calls
  6. Schema migrations — Always create new migration, NEVER edit existing

Specs Location

All specs in ~/system/specs/bilko-*.md:

Open Banking (Bank Feed)

Bilko uses Tok (~/ALAI/products/Tok/) for automatic bank feed via Open Banking (PSD2 AISP).

Documentation

Shared Dev Configs

Project Overview

Pipeline Gate Tracker

Bilko Pipeline — 8-Gate Tracker

Overview

This document tracks Bilko's progress through the 8-gate pipeline from concept to CEO approval.

Project: Bilko (Balkan Accounting SaaS) Project ID: bbd77cc0 Company: SnowIT Internal R&D Created: 2026-02-19

Gate Definitions

  1. Market Research — TAM/SAM/SOM analysis, customer pain points
  2. Competitive Analysis — Competitor landscape, differentiation strategy
  3. Tech Stack Decision — Frontend, backend, database, hosting choices
  4. Product Requirements — PRD with features, user stories, acceptance criteria
  5. Database Schema — Full schema design validated against PRD
  6. UI/UX Design — Wireframes, mockups, design system
  7. Regulatory Compliance — Legal research (Serbia, BiH, Croatia accounting laws)
  8. CEO Approval — Final go/no-go decision from Alem

Current Status

Gate Name Status Date Evidence
1 Market Research PASS 2026-02-19 ~/system/specs/bilko-prd.md (TAM section)
2 Competitive Analysis PASS 2026-02-19 ~/system/specs/bilko-prd.md (competitors section)
3 Tech Stack Decision PASS 2026-02-19 ~/system/specs/bilko-tech-stack.md
4 Product Requirements PASS 2026-02-20 Validated — All features mapped to schema, acceptance criteria defined
5 Database Schema PASS 2026-02-20 Validated — 15 models cover all PRD features, double-entry enforced
6 UI/UX Design PASS 2026-02-20 Validated — 10 pages implemented, design system consistent
7 Regulatory Compliance PASS 2026-02-20 Validated — All 3 countries researched (Serbia, BiH, Croatia), no blockers
8 CEO Approval PASS 2026-02-20 Approved by Alem — CODE UNFROZEN

Gate Validation Summary (2026-02-20)

Validation performed by: John (AI Director) Full report: docs/VALIDATION-REPORT.md

Gate 4: Product Requirements — PASS

Gate 5: Database Schema — PASS

Gate 6: UI/UX Design — PASS

Gate 7: Regulatory Compliance — PASS

Gate 8: CEO Approval — PASS

Approved by Alem on 2026-02-20

CODE UNFROZEN — Backend development started

Deliverables:

Backend Status (2026-02-20):

Next Steps:

  1. Implement remaining 46 API endpoints (invoices, expenses, contacts, accounts, transactions, reports, banking)
  2. Create Zod validators for all endpoints
  3. Add integration tests for auth flow
  4. Connect frontend to real backend (replace mock data)
  5. Beta testing with 5 SMBs + 3 accountants

Status: DEVELOPMENT IN PROGRESS

All 8 gates PASSED — Project approved and active

Decision Log

Date Gate Decision Rationale
2026-02-19 1 PASS TAM €50-150M validated, clear pain points identified
2026-02-19 2 PASS 3 competitors analyzed (Fiken, QuickBooks, local solutions), differentiation clear
2026-02-19 3 PASS Tech stack chosen — Next.js + Express + PostgreSQL (proven, scalable)
2026-02-20 4 PASS PRD complete — all features mapped to schema, acceptance criteria defined
2026-02-20 5 PASS Schema validated — 15 models cover all PRD features, double-entry enforced, NUMERIC(19,4) for money
2026-02-20 6 PASS Design validated — 10 pages implemented, design system consistent, responsive
2026-02-20 7 PASS Regulatory validated — All 3 countries researched, no blocking issues, 2 MEDIUM items not MVP blockers
2026-02-20 8 PASS CEO approval granted — Backend foundation implemented, 4/50 endpoints live, development started

Notes

References

Architecture

Architecture

Architecture Document

Architecture Document: Drop

Version: 1.1 Date: 2026-02-08 Author: dev agent (qwen2.5-coder:32b) + John Status: Approved Approved by: John (AI Director)


1. Overview

1.1 System Purpose

Drop je fintech aplikacija za remittance i QR plaćanja za sve stanovnike Norveške i Skandinavije. Drop koristi PSD2 pass-through model — nikada ne drži novac korisnika. AISP čita stanje računa putem Open Banking-a, a PISP inicira plaćanja direktno sa bankovnog računa korisnika.

1.2 Architecture Style

Monolith — scope je 7 stranica + API rute. Microservices bi bio over-engineering za demo.

1.3 Key Design Decisions

Decision Choice Rationale Alternatives
Architecture Monolith Small scope, simple deploy Microservices (overkill)
Frontend Next.js 16 + React 19 Already built, modern stack Remix, SvelteKit
Styling Tailwind v4 Already in use Styled Components
Database PostgreSQL 16 (Drizzle ORM) Full test/prod parity, PostgreSQL-native features, type-safe schema SQLite (superseded by ADR-014)
Auth JWT via jose Lightweight, stateless Session-based (needs Redis)
JWT Storage httpOnly cookie Prevents XSS token theft localStorage (less secure)
Error Handling Centralized middleware Consistent responses, easy logging Per-route try/catch

1.4 User Requirements (ENFORCED — from vilkår.html)

These are legally binding requirements published in our Terms of Service. They MUST be enforced in code.

Requirement Value Enforcement
Minimum age 18 år Registration: DOB field → reject if < 18. BankID returns DOB → double-check.
Residency Bosatt i Norge Registration: Norwegian phone (+47) + Norwegian BankID required.
Identity verification Gyldig BankID Onboarding: BankID verification mandatory before any transaction.
Accurate personal data User obligation BankID provides verified name/DOB. User confirms address.
No illegal use User obligation AML monitoring, transaction limits, suspicious activity detection.

Source: landing/pages/vilkar.html section 3 — "Du må være minst 18 år og bosatt i Norge for å bruke Drop."

Implementation notes:

2. System Context

┌──────────┐     ┌─────────────────────────────┐
│  Mobile  │     │         Drop App             │
│  Browser │────▶│  Next.js 16 (App Router)     │
└──────────┘     │                              │
                 │  ┌─────────┐  ┌───────────┐  │
                 │  │Frontend │  │ API Routes │  │
                 │  │ React19 │─▶│ /api/*     │  │
                 │  │ TW v4   │  │ JWT Auth   │  │
                 │  └─────────┘  └─────┬─────┘  │
                 │                     │        │
                 │               ┌─────▼─────┐  │
                 │               │PostgreSQL 16│ │
                 │               │ (Drizzle) │  │
                 │               └───────────┘  │
                 └─────────────────────────────┘

2.1 External Interfaces

Currently self-contained with local PostgreSQL (Docker). No external service integrations in MVP.

System Purpose Status
Exchange rates Remittance corridors (RSD, BAM, PLN, PKR, TRY, EUR) Local DB table
Auth JWT via jose Built-in
QR payments Merchant scanning Built-in

Post-MVP roadmap (FUTURE — not yet implemented, requires partners):

3. Component Architecture

3.1 Frontend Pages

Page Route Description Status
Landing / Marketing page Core
Register /register Phone + PIN registration Core
Login /login Phone + PIN auth Core
Dashboard /dashboard Account overview, last 5 transactions Core
Send Money /send Remittance — PISP from user's bank account Core
QR Payments /scan Scan & pay via QR code — PISP from user's bank account Core
Bank Accounts /accounts View linked bank account balances via AISP Core
Transaction History /transactions Full transaction list with filters Core
Notifications /notifications Push notifications and transaction alerts Core
Settings /profile User preferences and account management Core
Cards /cards Virtual/physical card management FUTURE (feature-flagged)

3.2 API Layer

/api
  /auth
    /register/route.ts    — POST register (phone + PIN)
    /login/route.ts       — POST login → JWT in httpOnly cookie
  /account/route.ts       — GET balance, account info
  /transactions
    /route.ts             — GET list, POST send money
    /simulate/route.ts    — POST simulate incoming (demo)
  /cards                          — FUTURE (feature-flagged, requires partner)
    /route.ts             — GET cards, POST create virtual
    /[id]/route.ts        — PATCH freeze/unfreeze
    /[id]/physical/route.ts — POST order physical
  middleware/
    errorHandler.ts       — Centralized error responses
    authMiddleware.ts     — JWT verification

4. Data Architecture

4.1 Database

4.2 Schema

Total Tables: 19 (12 core + 7 compliance)

Core Tables (12)

Table Key Fields Relationships
users id, email, password_hash, first_name, last_name, phone, date_of_birth, kyc_status, role, risk_level, pep_status, sanctions_cleared, kyc_method, kyc_verified_at, national_id_hash, deleted_at, created_at → bank_accounts, cards, recipients, transactions
bank_accounts id, user_id, bank_name, account_number, iban, balance (cached AISP read), balance_synced_at, currency, is_primary, connected_at → users, transactions
cards id, user_id, type, last_four, token_ref, expiry, status, shipping_address, pin_hash, created_at → users — FUTURE (feature-flagged)
transactions id, user_id, type, status, amount, currency, fee, recipient_id, merchant_id, send_amount, send_currency, receive_amount, receive_currency, exchange_rate, purpose_code, created_at, completed_at → users, recipients, merchants
recipients id, user_id, name, country, currency, bank_account, bank_name, created_at → users, transactions
merchants id, user_id, business_name, org_number, address, bank_account, fee_rate, status, created_at → users, transactions
exchange_rates id, from_currency, to_currency, rate, updated_at
sessions id, user_id, token_hash, created_at, expires_at, revoked → users
notifications id, user_id, type, title, body, read, created_at → users
settings user_id, currency, language, push_enabled, email_enabled, updated_at → users
spending_limits id, user_id, card_id, limit_type, amount, currency, created_at → users, cards
rate_limits key, count, reset_at

Compliance Tables (7) — Added 2026-02-16

Table Key Fields Purpose
audit_log id, timestamp, user_id, action, resource_type, resource_id, details, ip_address, user_agent Audit trail of all user actions
aml_alerts id, user_id, alert_type, severity, transaction_id, details, status, reviewed_by, reviewed_at, created_at Anti-money laundering alert tracking
str_reports id, user_id, alert_id, report_type, status, filed_at, reference_number, details, created_at Suspicious transaction reports (SAR/STR)
screening_results id, user_id, screening_type, provider, result, match_details, screened_at PEP, sanctions, adverse media screening
consents id, user_id, consent_type, granted, granted_at, withdrawn_at, ip_address GDPR consent tracking (PSD2, marketing, data processing)
data_access_requests id, user_id, request_type, status, requested_at, completed_at, download_url, notes GDPR right to access/erasure/rectification
complaints id, user_id, category, subject, description, status, resolution, created_at, resolved_at Customer complaint handling

Pass-through model: Drop NEVER holds customer money. The bank_accounts.balance field is a cached AISP read from the user's actual bank account (read-only in production, synced via Open Banking). User funds remain in their bank at all times. PISP initiates payments directly from user's bank account.

No wallet, no top-up: Drop does not have a wallet feature or top-up functionality. Users do not maintain a balance with Drop.

4.3 PSD2 Pass-Through Model

Drop operates as a PSD2 Payment Initiation Service Provider (PISP) and Account Information Service Provider (AISP):

AISP (Account Information)

PISP (Payment Initiation)

Compliance Requirements (PSD2)

  1. User consent: Explicit BankID consent required for AISP + PISP access
  2. SCA (Strong Customer Authentication): Required for all payments
  3. Data minimization: Only store what's necessary for compliance
  4. Audit trail: All PISP/AISP operations logged in audit_log table
  5. Right to withdraw consent: Tracked in consents table

Regulatory tables: audit_log, aml_alerts, str_reports, screening_results, consents, data_access_requests, complaints

5. Security Architecture (from security agent threat model)

5.1 Authentication

5.2 Threats & Mitigations

Threat Severity Mitigation
Broken Access Control HIGH JWT middleware on all /api routes
SQL Injection HIGH Parameterized queries via Drizzle ORM
XSS HIGH React auto-escapes, CSP headers
Token Theft HIGH httpOnly cookie, HTTPS
CSRF MEDIUM SameSite cookie + CSRF token
Data in localStorage HIGH Move sensitive data to httpOnly cookies
Replay Attacks MEDIUM Token expiration + jti claim
Security Misconfiguration HIGH Security headers (HSTS, X-Frame, CSP)

5.3 Data Protection

6. Infrastructure

Environment Purpose URL
Development Local dev localhost:3000
Staging Pre-release TBD (Vercel preview)
Production Live demo TBD (Vercel)

CI/CD Pipeline

Push → Build (next build) → TypeScript Check → Lint → Test → Deploy Staging → Manual Approval → Deploy Prod

7. Technology Stack

Layer Technology Version
Frontend Next.js 16
UI React 19
Styling Tailwind CSS 4
Backend Next.js API Routes 16
Database PostgreSQL 16 + Drizzle ORM 16
Auth JWT (jose) latest
Hosting Vercel

8. Performance Targets

Metric Target
FCP < 1.5s
LCP < 2.5s
TTFB < 200ms
API p95 < 300ms
Lighthouse > 90
Build time < 60s

9. ADRs

ADR-001: SQLite over PostgreSQL (superseded)

ADR-002: JWT in httpOnly Cookie

ADR-003: Monolith Architecture

10. Approvals

Role Name Date Approved
Dev Agent dev (qwen2.5-coder:32b) 2026-02-08
Security Agent security (qwen2.5-coder:32b) 2026-02-08
John (AI Director) John 2026-02-08
Architecture

API Specification

API Specification: Drop

Version: 1.0 Date: 2026-02-09 Author: dev agent (Ollama) + John (AI Director) Base URL: /api (Next.js API Routes) Auth: JWT in httpOnly cookie (jose library) Database: PostgreSQL 16 via Drizzle ORM (ADR-014; better-sqlite3 removed 2026-03-03)


1. Overview

API Style: REST Format: JSON Rate Limiting: 60 req/min per IP (standard), 10 req/min for auth endpoints Auth mechanism: JWT token set as httpOnly, secure, sameSite=strict cookie


2. Authentication

POST /api/auth/register

Description: Register new user account

Request:

{
  "email": "amir@example.com",
  "password": "min8chars",
  "firstName": "Amir",
  "lastName": "Hadžić",
  "phone": "+4712345678"
}

Response 201:

{
  "data": {
    "id": "usr_abc123",
    "email": "amir@example.com",
    "firstName": "Amir",
    "lastName": "Hadžić",
    "kycStatus": "pending",
    "createdAt": "2026-02-09T10:00:00Z"
  }
}

Sets httpOnly JWT cookie

Errors: 400 (validation), 409 (email exists)


POST /api/auth/login

Description: Login and receive JWT cookie

Request:

{
  "email": "amir@example.com",
  "password": "min8chars"
}

Response 200:

{
  "data": {
    "id": "usr_abc123",
    "email": "amir@example.com",
    "firstName": "Amir",
    "lastName": "Hadžić",
    "kycStatus": "approved"
  }
}

Sets httpOnly JWT cookie (24h expiry). Note: balance is NOT returned here — use /api/bank-accounts (AISP) to read bank balance.

Errors: 401 (wrong credentials), 423 (account locked)


POST /api/auth/logout

Description: Clear JWT cookie Auth: Required

Response 200:

{ "message": "Logged out" }

Clears httpOnly cookie


GET /api/auth/me

Description: Get current user from JWT Auth: Required

Response 200:

{
  "data": {
    "id": "usr_abc123",
    "email": "amir@example.com",
    "firstName": "Amir",
    "lastName": "Hadžić",
    "kycStatus": "approved",
    "createdAt": "2026-02-09T10:00:00Z"
  }
}

3. Bank Accounts (AISP — Pass-through)

Pass-through model: Drop never holds customer money. Balance is read from user's real bank account via Open Banking (AISP). Payments are initiated via PISP from user's bank.

GET /api/bank-accounts

Description: Get linked bank accounts and balances via AISP (Open Banking) Auth: Required (BankID consent)

Response 200:

{
  "data": [
    {
      "id": "ba_1",
      "bankName": "SpareBank 1",
      "accountNumber": "*****1234",
      "balance": 12450.00,
      "currency": "NOK",
      "lastSynced": "2026-02-09T10:00:00Z"
    }
  ]
}

Note: Balance is a cached AISP read from the user's actual bank account. Drop does not store or manage this balance.


GET /api/users/balance — REMOVED

POST /api/users/top-up — REMOVED

These endpoints were part of the old wallet model and have been removed. In the pass-through model, there is no wallet to check or top up. Use /api/bank-accounts to read bank balances via AISP.


4. Recipients

GET /api/recipients

Description: List user's saved recipients Auth: Required Query: ?page=1&limit=20

Response 200:

{
  "data": [
    {
      "id": "rec_1",
      "name": "Mama Jasmina",
      "country": "RS",
      "countryName": "Serbia",
      "currency": "RSD",
      "bankAccount": "*****1234",
      "createdAt": "2026-02-01T10:00:00Z"
    }
  ],
  "pagination": {
    "page": 1,
    "limit": 20,
    "total": 3
  }
}

POST /api/recipients

Description: Add new recipient Auth: Required

Request:

{
  "name": "Mama Jasmina",
  "country": "RS",
  "currency": "RSD",
  "bankAccount": "265100000012345678",
  "bankName": "Banca Intesa"
}

Response 201:

{
  "data": {
    "id": "rec_new",
    "name": "Mama Jasmina",
    "country": "RS",
    "currency": "RSD",
    "bankAccount": "*****5678",
    "createdAt": "2026-02-09T10:00:00Z"
  }
}

Errors: 400 (validation), 422 (unsupported country)


DELETE /api/recipients/:id

Description: Remove recipient Auth: Required Response 204: No content


5. Exchange Rates

GET /api/rates

Description: Get current exchange rates for all corridors Auth: Not required

Response 200:

{
  "data": {
    "baseCurrency": "NOK",
    "rates": {
      "RSD": 11.70,
      "BAM": 1.04,
      "PLN": 0.41,
      "PKR": 26.80,
      "TRY": 3.45,
      "EUR": 0.089
    },
    "updatedAt": "2026-02-09T10:00:00Z"
  }
}

GET /api/rates/:currency

Description: Get rate for specific currency pair Auth: Not required

Response 200:

{
  "data": {
    "from": "NOK",
    "to": "RSD",
    "rate": 11.70,
    "fee": 0.005,
    "updatedAt": "2026-02-09T10:00:00Z"
  }
}

Errors: 404 (unsupported currency)


6. Transactions — Remittance

POST /api/transactions/remittance

Description: Create new remittance transfer Auth: Required (KYC must be approved)

Request:

{
  "recipientId": "rec_1",
  "amount": 2000.00,
  "currency": "NOK"
}

Response 201:

{
  "data": {
    "id": "tx_rem_123",
    "type": "remittance",
    "status": "processing",
    "sendAmount": 2000.00,
    "sendCurrency": "NOK",
    "receiveAmount": 23400.00,
    "receiveCurrency": "RSD",
    "exchangeRate": 11.70,
    "fee": 10.00,
    "feePercent": 0.5,
    "total": 2010.00,
    "recipientName": "Mama Jasmina",
    "recipientCountry": "RS",
    "eta": "1-2 business days",
    "createdAt": "2026-02-09T10:00:00Z"
  }
}

Errors:


7. Transactions — QR Payment

POST /api/transactions/qr-payment

Description: Pay a merchant via QR code Auth: Required

Request:

{
  "merchantId": "mer_1",
  "amount": 129.00
}

Response 201:

{
  "data": {
    "id": "tx_qr_456",
    "type": "qr_payment",
    "status": "completed",
    "amount": 129.00,
    "currency": "NOK",
    "fee": 1.29,
    "feePercent": 1.0,
    "merchantName": "Ahmetov Kebab",
    "merchantId": "mer_1",
    "createdAt": "2026-02-09T14:23:00Z"
  }
}

Errors:


8. Transactions — List

GET /api/transactions

Description: List user's transactions (both remittance and QR) Auth: Required Query: ?page=1&limit=20&type=remittance|qr_payment&status=completed|processing|failed

Response 200:

{
  "data": [
    {
      "id": "tx_rem_123",
      "type": "remittance",
      "status": "completed",
      "amount": -2000.00,
      "currency": "NOK",
      "recipientName": "Mama Jasmina",
      "createdAt": "2026-02-09T10:00:00Z"
    },
    {
      "id": "tx_qr_456",
      "type": "qr_payment",
      "status": "completed",
      "amount": -129.00,
      "currency": "NOK",
      "merchantName": "Ahmetov Kebab",
      "createdAt": "2026-02-09T14:23:00Z"
    }
  ],
  "pagination": {
    "page": 1,
    "limit": 20,
    "total": 47
  }
}

GET /api/transactions/:id

Description: Get transaction detail Auth: Required

Response 200:

{
  "data": {
    "id": "tx_rem_123",
    "type": "remittance",
    "status": "completed",
    "sendAmount": 2000.00,
    "sendCurrency": "NOK",
    "receiveAmount": 23400.00,
    "receiveCurrency": "RSD",
    "exchangeRate": 11.70,
    "fee": 10.00,
    "total": 2010.00,
    "recipientName": "Mama Jasmina",
    "recipientCountry": "RS",
    "createdAt": "2026-02-09T10:00:00Z",
    "completedAt": "2026-02-10T14:30:00Z"
  }
}

9. Merchants

POST /api/merchants/register

Description: Register as a merchant Auth: Required

Request:

{
  "businessName": "Ahmetov Kebab",
  "orgNumber": "923456789",
  "address": "Grønland 12, Oslo",
  "bankAccount": "1234.56.78901"
}

Response 201:

{
  "data": {
    "id": "mer_1",
    "businessName": "Ahmetov Kebab",
    "orgNumber": "923456789",
    "qrCode": "drop://pay/mer_1",
    "status": "active",
    "feeRate": 0.01,
    "createdAt": "2026-02-09T10:00:00Z"
  }
}

Errors: 400 (validation), 409 (org number exists)


GET /api/merchants/dashboard

Description: Get merchant stats Auth: Required (merchant role) Query: ?period=today|week|month

Response 200:

{
  "data": {
    "period": "today",
    "revenue": 4350.00,
    "transactionCount": 12,
    "fees": 43.50,
    "netRevenue": 4306.50,
    "nextPayout": 4306.50,
    "payoutTime": "17:00"
  }
}

GET /api/merchants/transactions

Description: List merchant's received payments Auth: Required (merchant role) Query: ?page=1&limit=20&date=2026-02-09

Response 200:

{
  "data": [
    {
      "id": "tx_qr_456",
      "customerName": "Amir K.",
      "amount": 129.00,
      "fee": 1.29,
      "net": 127.71,
      "status": "completed",
      "time": "14:23"
    }
  ],
  "pagination": {
    "page": 1,
    "limit": 20,
    "total": 12
  }
}

GET /api/merchants/qr

Description: Get merchant's QR code data Auth: Required (merchant role)

Response 200:

{
  "data": {
    "merchantId": "mer_1",
    "businessName": "Ahmetov Kebab",
    "qrValue": "drop://pay/mer_1",
    "address": "Grønland 12, Oslo"
  }
}

10. Database Schema (PostgreSQL 16 — 19 tables)

Source: src/shared/db/schema.ts (Drizzle ORM schema — PostgreSQL 16, all environments; better-sqlite3 removed per ADR-014)

Core Tables (12)

Note: The SQL below is a historical snapshot from the original spec (SQLite syntax). The authoritative schema is src/shared/db/schema.ts (Drizzle ORM, PostgreSQL 16). Use make db-push to apply schema changes.

-- Users (NO balance field — pass-through model)
CREATE TABLE users (
  id TEXT PRIMARY KEY,
  email TEXT UNIQUE NOT NULL,
  password_hash TEXT NOT NULL,
  first_name TEXT NOT NULL,
  last_name TEXT NOT NULL,
  phone TEXT,
  date_of_birth TEXT,
  kyc_status TEXT DEFAULT 'pending' CHECK(kyc_status IN ('pending','approved','rejected')),
  role TEXT DEFAULT 'user' CHECK(role IN ('user','merchant')),
  risk_level TEXT DEFAULT 'low' CHECK(risk_level IN ('low','medium','high')),
  pep_status TEXT DEFAULT 'not_checked' CHECK(pep_status IN ('not_checked','clear','match','pending_review')),
  sanctions_cleared INTEGER DEFAULT 0,
  kyc_method TEXT CHECK(kyc_method IN ('bankid','document','simplified')),
  kyc_verified_at TEXT,
  national_id_hash TEXT,
  deleted_at TEXT,
  created_at TEXT DEFAULT (datetime('now'))
);

-- Bank accounts (balance is cached AISP read, NOT held by Drop)
CREATE TABLE bank_accounts (
  id TEXT PRIMARY KEY,
  user_id TEXT NOT NULL REFERENCES users(id),
  bank_name TEXT NOT NULL,
  account_number TEXT NOT NULL,
  iban TEXT,
  balance REAL DEFAULT 0,  -- Cached AISP-read balance (read-only in production)
  balance_synced_at TEXT,  -- When balance was last synced from bank via AISP
  currency TEXT DEFAULT 'NOK',
  is_primary INTEGER DEFAULT 0,
  connected_at TEXT DEFAULT (datetime('now'))
);

CREATE TABLE recipients (
  id TEXT PRIMARY KEY,
  user_id TEXT NOT NULL REFERENCES users(id),
  name TEXT NOT NULL,
  country TEXT NOT NULL,
  currency TEXT NOT NULL,
  bank_account TEXT NOT NULL,
  bank_name TEXT,
  created_at TEXT DEFAULT (datetime('now'))
);

CREATE TABLE merchants (
  id TEXT PRIMARY KEY,
  user_id TEXT NOT NULL REFERENCES users(id),
  business_name TEXT NOT NULL,
  org_number TEXT UNIQUE NOT NULL,
  address TEXT,
  bank_account TEXT NOT NULL,
  fee_rate REAL DEFAULT 0.01,
  status TEXT DEFAULT 'active',
  created_at TEXT DEFAULT (datetime('now'))
);

CREATE TABLE transactions (
  id TEXT PRIMARY KEY,
  user_id TEXT NOT NULL REFERENCES users(id),
  type TEXT NOT NULL CHECK(type IN ('remittance','qr_payment')),
  status TEXT DEFAULT 'processing' CHECK(status IN ('processing','completed','failed')),
  amount REAL NOT NULL,
  currency TEXT DEFAULT 'NOK',
  fee REAL DEFAULT 0,
  recipient_id TEXT REFERENCES recipients(id),
  merchant_id TEXT REFERENCES merchants(id),
  send_amount REAL,
  send_currency TEXT,
  receive_amount REAL,
  receive_currency TEXT,
  exchange_rate REAL,
  purpose_code TEXT,
  created_at TEXT DEFAULT (datetime('now')),
  completed_at TEXT
);

CREATE TABLE exchange_rates (
  id INTEGER PRIMARY KEY AUTOINCREMENT,  -- SERIAL for PostgreSQL
  from_currency TEXT DEFAULT 'NOK',
  to_currency TEXT NOT NULL,
  rate REAL NOT NULL,
  updated_at TEXT DEFAULT (datetime('now'))
);

CREATE TABLE cards (
  id TEXT PRIMARY KEY,
  user_id TEXT NOT NULL REFERENCES users(id),
  type TEXT DEFAULT 'virtual' CHECK(type IN ('virtual','physical')),
  last_four TEXT NOT NULL,
  token_ref TEXT,
  expiry TEXT NOT NULL,
  status TEXT DEFAULT 'active' CHECK(status IN ('active','frozen','cancelled')),
  shipping_address TEXT,
  pin_hash TEXT,
  created_at TEXT DEFAULT (datetime('now'))
);

CREATE TABLE sessions (
  id TEXT PRIMARY KEY,
  user_id TEXT NOT NULL REFERENCES users(id),
  token_hash TEXT NOT NULL,
  created_at TEXT DEFAULT (datetime('now')),
  expires_at TEXT NOT NULL,
  revoked INTEGER DEFAULT 0
);

CREATE TABLE notifications (
  id TEXT PRIMARY KEY,
  user_id TEXT NOT NULL REFERENCES users(id),
  type TEXT NOT NULL,
  title TEXT NOT NULL,
  body TEXT NOT NULL,
  read INTEGER DEFAULT 0,
  created_at TEXT DEFAULT (datetime('now'))
);

CREATE TABLE settings (
  user_id TEXT PRIMARY KEY REFERENCES users(id),
  currency TEXT DEFAULT 'NOK',
  language TEXT DEFAULT 'nb',
  push_enabled INTEGER DEFAULT 1,
  email_enabled INTEGER DEFAULT 1,
  updated_at TEXT DEFAULT (datetime('now'))
);

CREATE TABLE spending_limits (
  id TEXT PRIMARY KEY,
  user_id TEXT NOT NULL REFERENCES users(id),
  card_id TEXT REFERENCES cards(id),
  limit_type TEXT NOT NULL,
  amount REAL NOT NULL,
  currency TEXT DEFAULT 'NOK',
  created_at TEXT DEFAULT (datetime('now'))
);

CREATE TABLE rate_limits (
  key TEXT PRIMARY KEY,
  count INTEGER NOT NULL,
  reset_at INTEGER NOT NULL
);

Compliance Tables (7) — Added 2026-02-16

CREATE TABLE audit_log (
  id TEXT PRIMARY KEY,
  timestamp TEXT DEFAULT (datetime('now')),
  user_id TEXT REFERENCES users(id),
  action TEXT NOT NULL,
  resource_type TEXT,
  resource_id TEXT,
  details TEXT,
  ip_address TEXT,
  user_agent TEXT
);

CREATE TABLE aml_alerts (
  id TEXT PRIMARY KEY,
  user_id TEXT NOT NULL REFERENCES users(id),
  alert_type TEXT NOT NULL,
  severity TEXT NOT NULL CHECK(severity IN ('low','medium','high','critical')),
  transaction_id TEXT REFERENCES transactions(id),
  details TEXT,
  status TEXT DEFAULT 'open' CHECK(status IN ('open','investigating','resolved','escalated','filed')),
  reviewed_by TEXT,
  reviewed_at TEXT,
  created_at TEXT DEFAULT (datetime('now'))
);

CREATE TABLE str_reports (
  id TEXT PRIMARY KEY,
  user_id TEXT NOT NULL REFERENCES users(id),
  alert_id TEXT REFERENCES aml_alerts(id),
  report_type TEXT NOT NULL,
  status TEXT DEFAULT 'draft' CHECK(status IN ('draft','submitted','acknowledged')),
  filed_at TEXT,
  reference_number TEXT,
  details TEXT,
  created_at TEXT DEFAULT (datetime('now'))
);

CREATE TABLE screening_results (
  id TEXT PRIMARY KEY,
  user_id TEXT NOT NULL REFERENCES users(id),
  screening_type TEXT NOT NULL CHECK(screening_type IN ('pep','sanctions','adverse_media')),
  provider TEXT,
  result TEXT NOT NULL CHECK(result IN ('clear','match','potential_match','error')),
  match_details TEXT,
  screened_at TEXT DEFAULT (datetime('now'))
);

CREATE TABLE consents (
  id TEXT PRIMARY KEY,
  user_id TEXT NOT NULL REFERENCES users(id),
  consent_type TEXT NOT NULL,
  granted INTEGER NOT NULL DEFAULT 1,
  granted_at TEXT DEFAULT (datetime('now')),
  withdrawn_at TEXT,
  ip_address TEXT
);

CREATE TABLE data_access_requests (
  id TEXT PRIMARY KEY,
  user_id TEXT NOT NULL REFERENCES users(id),
  request_type TEXT NOT NULL CHECK(request_type IN ('export','erasure','rectification','restriction')),
  status TEXT DEFAULT 'pending' CHECK(status IN ('pending','processing','completed','rejected')),
  requested_at TEXT DEFAULT (datetime('now')),
  completed_at TEXT,
  download_url TEXT,
  notes TEXT
);

CREATE TABLE complaints (
  id TEXT PRIMARY KEY,
  user_id TEXT NOT NULL REFERENCES users(id),
  category TEXT NOT NULL,
  subject TEXT NOT NULL,
  description TEXT NOT NULL,
  status TEXT DEFAULT 'received' CHECK(status IN ('received','investigating','resolved','escalated')),
  resolution TEXT,
  created_at TEXT DEFAULT (datetime('now')),
  resolved_at TEXT
);

Indexes

-- Core indexes
CREATE INDEX idx_transactions_user ON transactions(user_id);
CREATE INDEX idx_transactions_merchant ON transactions(merchant_id);
CREATE INDEX idx_recipients_user ON recipients(user_id);
CREATE INDEX idx_merchants_org ON merchants(org_number);
CREATE INDEX idx_bank_accounts_user ON bank_accounts(user_id);
CREATE INDEX idx_cards_user ON cards(user_id);
CREATE INDEX idx_sessions_user ON sessions(user_id);
CREATE INDEX idx_sessions_token ON sessions(token_hash);
CREATE INDEX idx_notifications_user ON notifications(user_id);
CREATE INDEX idx_spending_limits_user ON spending_limits(user_id);
CREATE INDEX idx_spending_limits_card ON spending_limits(card_id);

-- Compliance indexes
CREATE INDEX idx_audit_log_user ON audit_log(user_id);
CREATE INDEX idx_audit_log_timestamp ON audit_log(timestamp);
CREATE INDEX idx_audit_log_action ON audit_log(action);
CREATE INDEX idx_aml_alerts_user ON aml_alerts(user_id);
CREATE INDEX idx_aml_alerts_status ON aml_alerts(status);
CREATE INDEX idx_screening_user ON screening_results(user_id);
CREATE INDEX idx_consents_user ON consents(user_id);
CREATE INDEX idx_data_requests_user ON data_access_requests(user_id);
CREATE INDEX idx_complaints_user ON complaints(user_id);
CREATE INDEX idx_complaints_status ON complaints(status);

Note: Schema uses PostgreSQL 16 in all environments (development, CI, staging, production) via Drizzle ORM. The old dual-mode SQLite/PostgreSQL driver has been removed (ADR-014).


11. Common Error Format

All errors follow this format:

{
  "error": "error_code",
  "message": "Human readable message",
  "details": []
}
Code Error Description
400 bad_request Malformed request body
401 unauthorized Missing or expired JWT
402 insufficient_balance Not enough NOK in bank account (PISP will fail)
403 kyc_required KYC must be approved
404 not_found Resource not found
409 conflict Duplicate (email, org number)
422 validation_error Field validation failed
429 rate_limited Too many requests
500 internal_error Server error

12. Rate Limits

Endpoint Group Limit Window
Auth (login/register) 10/min Per IP
Transactions (create) 30/min Per user
Read endpoints 60/min Per user
Exchange rates 120/min Per IP

13. API Route File Structure (Next.js)

src/app/api/
├── auth/
│   ├── register/route.ts
│   ├── login/route.ts
│   ├── logout/route.ts
│   └── me/route.ts
├── bank-accounts/
│   └── route.ts          (GET linked accounts via AISP)
├── recipients/
│   ├── route.ts          (GET list, POST create)
│   └── [id]/route.ts     (DELETE)
├── transactions/
│   ├── route.ts          (GET list)
│   ├── [id]/route.ts     (GET detail)
│   ├── remittance/route.ts (POST)
│   └── qr-payment/route.ts (POST)
├── merchants/
│   ├── register/route.ts
│   ├── dashboard/route.ts
│   ├── transactions/route.ts
│   └── qr/route.ts
├── rates/
│   ├── route.ts          (GET all)
│   └── [currency]/route.ts (GET specific)
└── lib/
    ├── db.ts             (PostgreSQL/Drizzle connection)
    ├── auth.ts           (JWT verify/sign)
    └── middleware.ts     (auth middleware, rate limit)

Generated: 2026-02-09 by dev agent (Ollama) + John (orchestration) Status: Ready for implementation (Sprint 1)

Architecture Decision Records

Architecture Decision Records

ADR-014 — Hybrid Encryption for L4 Restricted Fields

ADR-014 — Hybrid Encryption for L4 Restricted Fields (PIB, JMBG, OIB, JIB, IBAN)

ADR Number: ADR-014 Title: Use AES-256-GCM field-level encryption for JMBG and OIB; rely on disk-level encryption + application-layer access controls for PIB, JIB, and IBAN Date: 2026-02-25 Author: Petter Graff (Architect) Status: Proposed Resolves: Conflict between SECURITY-ARCHITECTURE.md (2026-02-20, "Column-level encryption: Not needed") and DATA-ENCRYPTION-POLICY.md / COMPLIANCE-FRAMEWORK.md (2026-02-23, "AES-256-GCM field-level encryption MANDATORY for all L4 fields")


1. Context

1.1 Situation

Bilko is a cloud accounting SaaS targeting 50K-500K SMBs across Serbia, Bosnia & Herzegovina, and Croatia. The database stores several categories of personal and financial identifiers classified as L4 Restricted:

Field Identifier Type Jurisdiction Nature
JMBG (Jedinstveni maticni broj gradana) Unique citizen number RS, BA Personal -- encodes date of birth, gender, region of birth. Equivalent to SSN. Assigned to natural persons only.
OIB (Osobni identifikacijski broj) Personal identification number HR Personal -- assigned to every natural and legal person in Croatia. 11-digit number used across all government and financial interactions.
PIB (Poreski identifikacioni broj) Tax identification number RS Business -- assigned to legal entities and entrepreneurs for tax purposes. Publicly visible on invoices, SEF portal, APR registry.
JIB (Jedinstveni identifikacioni broj) Unique identification number BA Business -- assigned to legal entities for tax purposes. Publicly visible on UIO portal, court registry.
IBAN International Bank Account Number All Financial -- bank account identifier. Semi-public (printed on invoices, shared for payment).

1.2 The Conflict

Two Bilko security documents contradict each other:

SECURITY-ARCHITECTURE.md (2026-02-20):

"Column-level encryption: Not needed (disk encryption sufficient for accounting data)"

DATA-ENCRYPTION-POLICY.md and COMPLIANCE-FRAMEWORK.md (2026-02-23):

"AES-256-GCM field-level encryption MANDATORY for all L4 fields (PIB, JMBG, OIB, JIB, IBAN)"

The earlier document (Security Architecture) was written from a pragmatic engineering perspective. The later documents (Encryption Policy, Compliance Framework) were written from a compliance/DPIA perspective and prescribed a blanket field-level encryption mandate for all L4 fields without differentiating between personal identifiers and business/financial identifiers.

1.3 Regulatory Analysis

GDPR Article 32 -- Security of Processing (Croatia)

GDPR Article 32 requires "appropriate technical and organisational measures to ensure a level of security appropriate to the risk," taking into account:

  1. The state of the art
  2. The cost of implementation
  3. The nature, scope, context, and purposes of processing
  4. The risk of varying likelihood and severity for the rights and freedoms of natural persons

Encryption is listed as one example ("inter alia as appropriate: the pseudonymisation and encryption of personal data") but is not a blanket requirement. The determination is risk-based.

GDPR Article 87 -- National Identification Numbers

Article 87 permits Member States to establish specific conditions for processing national identification numbers. It requires "appropriate safeguards" but does not mandate encryption specifically. Croatia (through AZOP) has not published binding guidance requiring field-level encryption of OIB in databases, though OIB processing is subject to purpose limitation and data minimization principles.

Serbia -- ZZPL (Sl. glasnik RS 87/2018)

Serbia's ZZPL is closely aligned with GDPR. Article 50 (equivalent to GDPR Art. 32) requires "appropriate technical, organizational and personnel measures." The Serbian Commissioner (Poverenik) has not published specific guidance mandating field-level encryption for JMBG or PIB. However, JMBG is widely recognized as highly sensitive -- it encodes biographic information (date of birth, gender, region) and its misuse enables identity fraud. The JMBG's sensitivity is acknowledged in Serbian legal practice and the Commissioner's public statements.

Bosnia & Herzegovina -- ZZLP BiH (Sl. glasnik BiH 49/2006, updated 2025)

The new ZZLP BiH (published February 28, 2025, entering force March 8, 2025) is harmonized with GDPR. AZLP BiH guidance on technical measures mentions "encryption of data" and "pseudonymization" as recommended measures. No specific mandate for field-level database encryption exists, but the general requirement for "appropriate security measures" applies.

Summary: No Law Mandates Field-Level Encryption

No regulation in any of the three jurisdictions explicitly mandates application-level field-level encryption for tax IDs or IBANs in databases. All three frameworks use GDPR-style language: "appropriate technical measures proportionate to the risk." This means the decision is a risk-based engineering judgment, not a binary legal requirement.

1.4 What Competitors Do

Competitor Approach Evidence
FreeAgent (UK accounting SaaS, 150K+ users) AES-256 encryption at rest for all stored data. No public mention of field-level encryption for specific columns. Cyber Essentials Plus certified. AWS Ireland hosting with ISO 27001/27017/27018 datacenter compliance. FreeAgent Security
Fiken (Norwegian accounting SaaS, 70K+ users) No publicly available security architecture documentation. Cloud-based, Norwegian-regulated. No evidence of field-level encryption. General inference from public documentation.
Minimax (Balkan/SEE accounting SaaS) No publicly available security architecture documentation. Cloud-based. Standard privacy policy. No evidence of field-level encryption for tax identifiers. Minimax Privacy

Industry pattern: Accounting SaaS competitors rely on disk-level encryption at rest (AES-256, provided by cloud hosting) combined with TLS in transit, access controls, and audit logging. None publicly document field-level encryption for tax IDs or IBANs.

1.5 Risk Assessment by Field

Field Sensitivity if Breached Public Availability Risk Level
JMBG CRITICAL -- enables identity fraud, encodes DOB/gender/region, irrevocable (cannot be changed) NOT public -- protected by law, not printed on invoices Highest
OIB HIGH -- unique cross-system identifier, used for all gov/financial interactions, difficult to change Semi-public -- used widely but not freely published High
PIB MEDIUM -- business tax ID, publicly searchable on APR (Serbian Business Registry) and SEF portal PUBLIC -- printed on every invoice, searchable on gov portals Medium
JIB MEDIUM -- business tax ID, publicly searchable on BiH court registry and UIO portal PUBLIC -- printed on every invoice, searchable on gov portals Medium
IBAN MEDIUM -- bank account number, shared routinely for payment purposes, printed on invoices Semi-public -- shared with every business partner for payment Medium

1.6 Technical Constraints

Prisma field-level encryption trade-offs:

Factor Impact
Query limitations Encrypted fields cannot be filtered, sorted, or searched with SQL operators. Only exact-match via HMAC hash column is possible.
Storage overhead AES-256-GCM ciphertext is significantly larger than plaintext (IV + auth tag + ciphertext, base64-encoded). VARCHAR columns must be oversized.
Performance Encrypt/decrypt on every read/write. No published benchmarks for prisma-field-encryption, but crypto operations add measurable latency per record. For batch operations (e.g., 1000-invoice report filtering by buyer PIB), overhead compounds.
Key management Single FIELD_ENCRYPTION_KEY in Railway env var. Key rotation requires re-encrypting all rows -- a migration-level operation.
Prisma compatibility prisma-field-encryption library uses Prisma client extensions (AES-256-GCM). Works but adds a dependency. Raw SQL queries (used for financial reports per ADR-011) bypass encryption middleware.
Development complexity Developers must handle encrypted fields differently. Debugging queries on encrypted columns is harder. Test fixtures need encryption-aware setup.

2. Decision

We will use a hybrid approach:

Tier 1: Field-Level Encryption (AES-256-GCM) -- JMBG and OIB only

These are personal identification numbers with high breach impact and no legitimate reason for database-level querying by value:

Implementation: Use prisma-field-encryption Prisma client extension with /// @encryption:encrypt annotation on JMBG and OIB fields in schema.prisma. Add /// @encryption:hash(jmbg) for searchable hash columns.

Rationale: JMBG and OIB are irrevocable personal identifiers. A database breach exposing plaintext JMBG enables identity fraud with no mitigation path for the victim (JMBG cannot be changed). The risk justifies the query limitations and performance overhead because:

Tier 2: Disk-Level Encryption + Application Controls -- PIB, JIB, IBAN

These are business identifiers or semi-public financial data where the risk profile does not justify the significant query/performance trade-offs of field-level encryption:

Rationale: Encrypting PIB at the field level when it is publicly available on Serbian government portals (APR, efaktura.mfin.gov.rs) provides negligible additional security benefit while significantly degrading query performance. The same applies to JIB. IBAN is routinely shared for payment and is masked in API list responses. For all three, the defense-in-depth stack (disk encryption + TLS + org-scoping + RBAC + audit trail + API masking) provides security proportionate to the risk, consistent with GDPR Article 32's risk-based approach.

Implementation Summary

Field Encryption Level Search Support Display Masking
JMBG AES-256-GCM field-level HMAC-SHA256 hash for exact match Show only last 3 digits in UI
OIB AES-256-GCM field-level HMAC-SHA256 hash for exact match Show only last 3 digits in UI
PIB Disk-level (Railway AES-256) Full SQL query support Full value shown (public data)
JIB Disk-level (Railway AES-256) Full SQL query support Full value shown (public data)
IBAN Disk-level (Railway AES-256) Full SQL query support Masked in list views (last 4 only), full in detail view

Key Management


3. Alternatives Considered

Option A: No field-level encryption for any L4 field (original Security Architecture position)

Pros:

Cons:

Why not chosen: The risk of JMBG/OIB exposure in a breach is too high to leave unmitigated. Removing a documented DPIA control without justification creates regulatory liability.

Option B: AES-256-GCM field-level encryption for ALL L4 fields (Encryption Policy position) -- Selected with modification

Pros:

Cons:

Why not chosen in full: Encrypting publicly available identifiers (PIB, JIB) at the field level fails the GDPR Art. 32 proportionality test. The cost (query limitations, performance, complexity) is not justified by the risk reduction (minimal, since the data is publicly accessible). However, the principle of encrypting personal identifiers (JMBG, OIB) is correct and adopted.

Option C: Hybrid approach -- Encrypt JMBG and OIB only (Selected)

Pros:

Cons:

Risk accepted: A database breach would expose PIB, JIB, and IBAN in plaintext within the encrypted disk. This is accepted because: (a) PIB and JIB are publicly available, (b) IBAN is routinely shared and masked in API responses, (c) disk-level encryption protects against physical/infrastructure breaches, (d) org-scoping prevents cross-tenant access at application level.

Option D: PostgreSQL pgcrypto column-level encryption (database-side)

Pros:

Cons:

Why not chosen: Incompatible with Prisma ORM and Railway managed PostgreSQL constraints. Application-layer encryption via prisma-field-encryption is a better fit for the existing stack.


4. Consequences

4.1 Positive Consequences

4.2 Negative Consequences

4.3 Technical Debt Created

4.4 Schema Changes Required

model Contact {
  // ... existing fields ...

  // Tier 1: Field-level encrypted (AES-256-GCM)
  jmbg       String?  @db.Text  /// @encryption:encrypt  -- Serbian/BiH citizen number
  jmbgHash   String?  @map("jmbg_hash") @db.VarChar(64)  /// @encryption:hash(jmbg)
  oib        String?  @db.Text  /// @encryption:encrypt  -- Croatian personal ID
  oibHash    String?  @map("oib_hash") @db.VarChar(64)   /// @encryption:hash(oib)

  // Tier 2: Disk-level encryption only (full query support)
  // registrationNumber (PIB/JIB) -- already exists, no change
  // vatNumber -- already exists, no change
}

model BankAccount {
  // iban field -- already exists as @db.VarChar(50), no encryption change
  // Protected by: disk encryption + org-scoping + RBAC + API masking
}

4.5 Environment Variables Required

# Generate with: openssl rand -hex 32
FIELD_ENCRYPTION_KEY=<64-char-hex-string>  # AES-256 key for JMBG/OIB encryption
FIELD_HMAC_KEY=<64-char-hex-string>         # HMAC-SHA256 key for hash columns

5. Decision Rationale Summary

The core insight driving this decision is that not all L4 data carries equal breach risk. The original Security Architecture was too permissive (no field-level encryption at all). The Encryption Policy was too aggressive (field-level encryption for publicly available business IDs). This ADR resolves the conflict with a risk-proportionate hybrid:

  1. JMBG/OIB: Irrevocable personal identifiers with high breach impact. Field-level encryption is justified and proportionate.
  2. PIB/JIB: Publicly available business identifiers. Field-level encryption adds cost without meaningful risk reduction.
  3. IBAN: Routinely shared financial identifier. API masking + disk encryption is proportionate.

This approach satisfies GDPR Article 32's "appropriate to the risk" standard, avoids the EDPB-documented pitfall of "security theatre" (implementing controls that do not meaningfully reduce risk), and preserves the query performance essential for accounting workflows.


References


Approval

Role Name Date Signature
Author Petter Graff 2026-02-25
Tech Lead
DPO
CTO / Architect Alem
Architecture Decision Records

ADR-015: Four-Jurisdiction Plugin Architecture

# 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

```kotlin
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.

```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 — 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`):

```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.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`:**

```kotlin
// 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:**

```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 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 |
Architecture Decision Records

ADR-016: EInvoice Adapter Lifecycle and Contract

# ADR-016 — EInvoiceAdapter Lifecycle and Contract

**Status:** Accepted
**Date:** 2026-05-13
**Author:** Petter Graff (CodeCraft — Architecture Lead)
**Finverge Co-author:** Markos Zachariadis (Payments & Fiscal Integration)
**Decision-maker:** CEO Alem Bašić
**MC Task:** #100585 (Phase 0' ADR Consolidation — EInvoiceAdapter lifecycle)
**Supersedes:** ADR-016 v1 (2026-05-11, MC #100362) — this is the authoritative version
**Cross-references:**

- ADR-015 (CountryPlugin — `generateEInvoiceXml()` and `submitToFiscalPlatform()` delegate to adapters)
- ADR-019 (Integration Adapter Registry — `AdapterConfig`, secret taxonomy, categories)
- ADR-023 §3.3 (backend country differentiation — market selected before adapter dispatch)
- `apps/api/src/main/kotlin/no/alai/bilko/einvoice/EInvoiceTypes.kt` (canonical types on disk)
- `apps/api/src/main/kotlin/no/alai/bilko/country/hr/StorecoveHrFiskEInvoiceAdapter.kt` (HR reference)
- Plan v3 §4b ADR-016 requirement + §4d HR critical path — `~/system/specs/bilko-multi-market-architecture-plan-v3-2026-05-11.md`

---

## 1. Context

### 1.1 The Four-Platform Problem

Bilko targets four tax jurisdictions with four incompatible e-invoice fiscal platforms:

| Market | Platform                             | Transport  | Format                     | Status          |
| ------ | ------------------------------------ | ---------- | -------------------------- | --------------- |
| HR     | HR-FISK / FINA via Storecove         | Peppol AS4 | UBL 2.1 + HR CIUS          | STUB (MC #8675) |
| RS     | SEF (efaktura.gov.rs)                | REST API   | SEF XML (Serbian-specific) | Phase 1S        |
| BA-FED | CPF (Centralna platforma za fakture) | TBD ~2027  | TBD                        | Phase 1B        |
| BA-RS  | UINO (stub name)                     | TBD        | TBD                        | Phase 1B        |

Without a canonical abstraction, each platform's integration detail bleeds into the core
invoice service — reproducing the Variant B coupling problem (ADR-bilko-002 §3).

### 1.2 Existing Types on Disk (verified 2026-05-11)

`EInvoiceTypes.kt` already defines (lines 1–224):

- `AdapterLifecycleState` enum: `STUB`, `SANDBOX_VERIFIED`, `PRODUCTION`
- `EInvoiceStatus` enum: `PENDING`, `APPROVED`, `REJECTED`, `CANCELLED`, `ERROR`
- `InvoiceTypeCode`: UNTDID codes 380, 381, 383, 384
- `Address`, `PartyInfo` / `Party` typealias
- `PaymentMeans`: `paymentMeansCode`, `paymentReference`, `iban`
- `TaxCategory` enum: `S, Z, E, K, G, O, AE` per EN 16931 BT-118
- `TaxBreakdown`, `InvoiceLine`, `CanonicalInvoice`, `SubmitResult`, `InvoiceTotals`
- `EInvoiceAdapter` interface with 4 methods + 2 properties

`AdapterTypes.kt` (in `no.alai.bilko.adapter`) defines:

- `AdapterErrorCode` enum with 10 codes including `NOT_IMPLEMENTED`
- `AdapterException(code, market, retryable, rawPayload, message, cause)`

The `EInvoiceAdapter` interface and lifecycle states exist but are not formally documented.
`StorecoveHrFiskEInvoiceAdapter` implements the interface — `serialize()` is fully operational
offline; all other methods throw `NOT_IMPLEMENTED`. This ADR formalises the contract and
lifecycle governance.

---

## 2. Decision

### 2.1 EInvoiceAdapter Interface — Formal Contract

Defined in `apps/api/src/main/kotlin/no/alai/bilko/einvoice/EInvoiceTypes.kt` lines 200–224.
Reproduced here as the normative specification with full contract annotations:

```kotlin
interface EInvoiceAdapter {
    val jurisdiction: TaxJurisdiction
    val lifecycleState: AdapterLifecycleState

    /**
     * Serialize a canonical invoice to the adapter-specific wire format.
     *
     * CONTRACT:
     * - MUST be offline-capable — no network, no credentials required.
     * - MUST be deterministic: same [invoice] input produces identical bytes.
     * - MUST NOT log raw PII fields (OIB, IBAN, document_data) — call sanitizeForLog().
     * - Returns the full wire-format payload for the platform:
     *     HR: Storecove JSON envelope wrapping UBL 2.1 XML
     *     RS: SEF XML (Serbian Ministry of Finance schema)
     *     BA: CPF/UINO platform format (TBD)
     * - Throws AdapterException(VALIDATION_BUSINESS_RULE) for constraint violations
     *   (non-EUR currency for HR, invalid OIB, empty lines, etc.)
     * - AdapterConfig.enabled check is NOT performed here — callers check before invoking.
     * - This method is ALWAYS available, even in STUB lifecycle.
     */
    fun serialize(invoice: CanonicalInvoice): ByteArray

    /**
     * Submit the serialized invoice bytes to the fiscal platform.
     *
     * CONTRACT:
     * - Requires live credentials (API key, OAuth token, or certificate).
     * - MUST include an idempotency key (platform-specific — see §2.3).
     * - Returns SubmitResult on success; throws AdapterException on ALL failures.
     * - NEVER propagates platform-native exceptions (Ktor ResponseException, etc.) —
     *   map every platform exception to AdapterException before propagating.
     * - Implementations in STUB lifecycle MUST throw NOT_IMPLEMENTED (see §2.5).
     * - Idempotency: platforms may return 409 DUPLICATE on re-submission.
     *   Caller should treat 409 as success — extract submission ID from error body.
     *
     * @param serializedInvoice bytes from serialize()
     * @param invoice original CanonicalInvoice (needed for idempotency key generation)
     */
    fun submit(serializedInvoice: ByteArray, invoice: CanonicalInvoice): SubmitResult

    /**
     * Poll the fiscal platform for the current status of a submitted invoice.
     *
     * CONTRACT:
     * - [submissionId] is SubmitResult.platformInvoiceId from submit().
     * - Returns current EInvoiceStatus.
     * - This method is IDEMPOTENT — safe to call multiple times with the same submissionId.
     * - Callers implement exponential backoff; this method does NOT retry internally.
     * - Implementations in STUB lifecycle MUST throw NOT_IMPLEMENTED (see §2.5).
     * - NEVER log rawPayload without sanitizeForLog().
     */
    fun pollStatus(submissionId: String, invoice: CanonicalInvoice): EInvoiceStatus

    /**
     * Parse an inbound invoice from a raw fiscal platform webhook payload.
     *
     * CONTRACT:
     * - [rawPayload] is the raw bytes from the platform webhook (Storecove POST, SEF callback).
     * - Returns CanonicalInvoice with adapterMetadata populated for platform-specific fields:
     *     HR: "hr.supplierOib", "hr.buyerOib", "hr.pozivNaBroj"
     *     RS: "rs.supplierPib", "rs.buyerPib", "rs.sefId"
     * - Implementations in STUB lifecycle MUST throw NOT_IMPLEMENTED (see §2.5).
     * - NEVER log rawPayload before passing through sanitizeForLog().
     * - parseIncoming() is deferred for HR: not required for v1 HR GA (Phase 1H.6 scope).
     *   Implement 90 days post-GA (see Plan v3 §4d).
     */
    fun parseIncoming(rawPayload: ByteArray): CanonicalInvoice
}
```

### 2.2 CanonicalInvoice — EN 16931 Subset

The internal invoice representation, independent of any platform wire format.
Defined in `EInvoiceTypes.kt` lines 141–156:

```kotlin
data class CanonicalInvoice(
    val id: String,                          // Internal UUID — Storecove document_id (D2 dedup)
    val invoiceNumber: String,               // BT-1: human-readable invoice number
    val issueDate: LocalDate,                // BT-2
    val dueDate: LocalDate,                  // BT-9
    val typeCode: InvoiceTypeCode,           // BT-3: UNTDID 1001 (380/381/383/384)
    val currencyCode: String,                // BT-5: ISO 4217 ("EUR", "RSD", "BAM")
    val jurisdiction: TaxJurisdiction,       // Routing discriminator (non-EN16931)
    val supplier: PartyInfo,                 // BG-4: name, taxId (OIB/PIB/JIB), address
    val buyer: PartyInfo,                    // BG-7: same structure
    val lines: List<InvoiceLine>,            // BG-25: quantity, unitPrice, lineTotal, taxRate
    val taxBreakdowns: List<TaxBreakdown>,   // BG-23: one entry per rate band
    val paymentMeans: PaymentMeans? = null,  // BG-16: paymentMeansCode, IBAN, reference
    val note: String? = null,                // BT-22: free text note
    val adapterMetadata: Map<String, String> = emptyMap(), // platform-specific extras
)
```

**Field constraints:**

| Field             | Constraint                                                                     | Enforced in            |
| ----------------- | ------------------------------------------------------------------------------ | ---------------------- |
| `currencyCode`    | "EUR" for HR (HALT-3 — Croatia adopted EUR 2023-01-01)                         | serialize() HR         |
| `supplier.taxId`  | OIB (HR, 11-digit ISO 7064 MOD 11,10) / PIB (RS, 9-digit) / JIB (BA, 13-digit) | serialize() per market |
| `lines`           | Non-empty — EN 16931 §BG-25 minimum one line                                   | serialize()            |
| `taxBreakdowns`   | Must sum to lines.(taxRate \* lineTotal) — tolerance 0.01                      | InvoiceService         |
| `adapterMetadata` | HR inbound: `hr.supplierOib`, `hr.buyerOib`, `hr.pozivNaBroj`                  | parseIncoming()        |

**What CanonicalInvoice is NOT:**

- Not a DB entity (mapped from `invoices` + `invoice_items` tables on read)
- Not a REST API DTO (API layer maps separately)
- Not versioned independently — evolves with EN 16931 minor revisions

### 2.3 Adapter Lifecycle State Machine

Defined in `EInvoiceTypes.kt` lines 22–26. Transition criteria formalised here:

```
STUB
  │ Compiles. All 3 network methods throw NOT_IMPLEMENTED.
  │ serialize() MAY be operational (HR: already works offline).
  │ AdapterConfig row not required.
  │
  │ Transition criteria → SANDBOX_VERIFIED:
  │   1. Provider account provisioned (MC #8675 for HR/Storecove)
  │   2. Credentials loaded in GCP Secret Manager (see §2.6 secret taxonomy)
  │   3. 5 sandbox test invoice types pass with REAL platform submission IDs (§2.4)
  │   4. pollStatus() confirmed for each submitted invoice
  │   5. Proveo evidence file with submission IDs uploaded to BookStack
  │   6. lifecycleState field updated to SANDBOX_VERIFIED in adapter source
  │
  ▼
SANDBOX_VERIFIED
  │ All 4 methods operational against provider sandbox.
  │ AdapterConfig(market, EINVOICE, enabled=true) in STAGE DB.
  │
  │ Transition criteria → PRODUCTION:
  │   1. Securion audit: adapter error handling + PII sanitization (see §2.7)
  │   2. 30 continuous days on STAGE Cloud Run with zero
  │      AdapterErrorCode.PLATFORM_INTERNAL_ERROR alerts
  │      (Prometheus metric: bilko_integration_request_total)
  │   3. AdapterConfig(market, EINVOICE, enabled=true) in PRODUCTION DB
  │   4. CEO sign-off (this is the go-live gate)
  │
  ▼
PRODUCTION
  │ Live. All 4 methods operational against production platform.
  │ Incident response: if critical error rate > 5% over 15min window,
  │   automated alert → Slack #bilko-incidents → human decision to flip
  │   AdapterConfig.enabled = false (no redeploy needed).
```

**Current HR state (2026-05-13):** STUB

- `serialize()`: WORKS (offline). Unit-tested.
- `submit()`: throws NOT_IMPLEMENTED — MC #8675 pending
- `pollStatus()`: throws NOT_IMPLEMENTED
- `parseIncoming()`: throws NOT_IMPLEMENTED (deferred post-GA)

### 2.4 HR-FISK Storecove Sandbox Validation Matrix

5 invoice types required for SANDBOX_VERIFIED transition. All must produce real Storecove
submission GUIDs (not mock strings). Proveo (Angie Jones) runs these tests.

| #   | Invoice Type            | UNTDID Code   | Scenario                                                                             | Expected Storecove Response                                                       | Evidence Required                                        |
| --- | ----------------------- | ------------- | ------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------- | -------------------------------------------------------- |
| 1   | B2B outbound commercial | 380           | Supplier OIB + Buyer OIB both valid. EUR. 25% PDV. Standard commercial transaction.  | HTTP 200 + `{"id": "<guid>", "status": "pending"}`                                | Storecove submission GUID in evidence file               |
| 2   | B2G outbound            | 380           | Buyer is HR government entity (OIB format same). `PaymentMeans.paymentMeansCode=30`. | HTTP 200 + GUID                                                                   | GUID + Storecove routing.peppol.id verified as buyer OIB |
| 3   | Credit note             | 381           | References original invoice number in `note` field. Negative line totals.            | HTTP 200 + GUID                                                                   | GUID + typeCode=381 confirmed in Storecove portal        |
| 4   | Cancelled invoice       | 384           | CORRECTIVE_INVOICE type. Status flow: submit → pollStatus until APPROVED or REJECTED | HTTP 200 + GUID, then pollStatus APPROVED/REJECTED                                | GUID + final status confirmed                            |
| 5   | Inbound received        | 380 (inbound) | Storecove sends test webhook to Bilko's webhook endpoint. `parseIncoming()` invoked. | Webhook received. CanonicalInvoice returned. `hr.supplierOib` in adapterMetadata. | Log entry showing successful parse + extracted OIB value |

**HR-specific validation rules verified in each test case:**

- `currencyCode = "EUR"` (HALT-3)
- Supplier OIB: ISO 7064 MOD 11,10 checksum valid
- Buyer OIB: ISO 7064 MOD 11,10 checksum valid
- CustomizationID: verify with Storecove support which to use (PEPPOL_BIS3 or HR_CIUS — TODO MC #8675 D3)
- `routing.peppol.scheme = "9934"` and `routing.peppol.id = <buyerOIB>`

**Storecove-specific notes:**

- Sandbox URL is the same as production (`api.storecove.com/api/v2`) — sandbox mode is a
  payload flag, not a different host. Set `STORECOVE_ENV=sandbox` env var.
- Idempotency key: SHA-256(`invoice.id` + `invoice.invoiceNumber`) → sent as `Idempotency-Key` header.
  Platform returns HTTP 409 on duplicate — treat as success (re-fetch GUID from error body).
- `document_id` field in Storecove payload = `CanonicalInvoice.id` (Bilko UUID) — Storecove
  dedup key, prevents double-billing on retry (D2 in StorecoveHrFiskEInvoiceAdapter).

### 2.5 NOT_IMPLEMENTED Transition Rules

`AdapterErrorCode.NOT_IMPLEMENTED` is the canonical error code for STUB lifecycle methods.
Rules for callers and implementers:

**Implementer rules:**

1. Any STUB lifecycle method that is not yet operational MUST throw:
   ```kotlin
   throw AdapterException(
       code = AdapterErrorCode.NOT_IMPLEMENTED,
       market = jurisdiction,
       retryable = false,
       rawPayload = "",
       message = "<Platform> <method> requires account — MC #<id>"
   )
   ```
2. `serialize()` is EXEMPT from the NOT_IMPLEMENTED requirement — it SHOULD be
   operational even in STUB lifecycle because it needs no credentials (offline contract).
3. Once an implementation moves to SANDBOX_VERIFIED, no method may throw NOT_IMPLEMENTED
   for the sandbox environment. If a method is genuinely deferred (e.g., `parseIncoming()`
   for HR v1), the lifecycle state must remain STUB until all 4 methods are operational.
   Exception: `parseIncoming()` for HR is formally deferred to 90 days post-GA per Plan v3
   §4d. The HR adapter will hold a partial SANDBOX_VERIFIED state tracked by the
   `AdapterConfig` feature flag with `reason = "parseIncoming deferred — Phase 1H.6"`.

**Caller rules:**

1. Before calling `submit()` or `pollStatus()`, callers MUST check:
   ```kotlin
   val config = adapterConfigRepo.find(jurisdiction, "EINVOICE")
       ?: throw AdapterException(NOT_IMPLEMENTED, ...)
   if (!config.enabled) throw AdapterException(NOT_IMPLEMENTED, ..., message="Adapter disabled: ${config.reason}")
   ```
2. `NOT_IMPLEMENTED` caught at the route handler level maps to HTTP 503 (Service Unavailable)
   with body `{"error": "ADAPTER_NOT_AVAILABLE", "market": "<jurisdiction>"}`, NOT HTTP 500.
   This is the stub plugin HTTP 500 risk mitigation from ADR-015 §5.3.
3. `serialize()` callers do NOT need to check AdapterConfig — serialize is always available.

**Error code precedence when multiple codes could apply:**

```
NOT_IMPLEMENTED > AUTH_INVALID_CREDENTIALS > VALIDATION_BUSINESS_RULE > NETWORK_TIMEOUT
```

If a STUB adapter is also missing credentials, `NOT_IMPLEMENTED` takes precedence.
Lifecycle state check happens before credential check.

### 2.6 Secret Management — GCP Secret Manager Taxonomy

All adapter credentials follow the taxonomy defined in ADR-019 §2.5:

```
Bilko/{env}/{market}/{secret-name}
```

- `{env}`: `dev`, `stage`, `prod`
- `{market}`: `HR`, `RS`, `BA_FED`, `BA_RS`
- `{secret-name}`: platform-specific identifier (kebab-case)

**HR Storecove secrets (provision after MC #8675):**

| GCP Secret Manager path                    | Content                             | Access binding                |
| ------------------------------------------ | ----------------------------------- | ----------------------------- |
| `Bilko/stage/HR/storecove-api-key`         | Storecove sandbox API key           | Cloud Run SA `bilko-stage-sa` |
| `Bilko/prod/HR/storecove-api-key`          | Storecove production API key        | Cloud Run SA `bilko-prod-sa`  |
| `Bilko/stage/HR/storecove-legal-entity-id` | Storecove legal entity ID (sandbox) | Cloud Run SA `bilko-stage-sa` |
| `Bilko/prod/HR/storecove-legal-entity-id`  | Storecove legal entity ID (prod)    | Cloud Run SA `bilko-prod-sa`  |

**Mounting in Cloud Run:**

```yaml
# gcp-deploy.yml (Cloud Run --set-secrets pattern):
--set-secrets="STORECOVE_API_KEY=Bilko/stage/HR/storecove-api-key:latest,\
STORECOVE_LEGAL_ENTITY_ID=Bilko/stage/HR/storecove-legal-entity-id:latest"
```

**Env var naming convention:** `<PLATFORM>_<FIELD>`, uppercase, underscores.
Accessed in `StorecoveApiClient` via `System.getenv("STORECOVE_API_KEY")`.

**Secret rotation policy:**

- Rotate API keys every 90 days OR on any Storecove security notice, whichever comes first.
- Previous version retained in Secret Manager for 24h to allow graceful failover.
- Rotation event: create new secret version → update Cloud Run env → verify health endpoint
  → delete previous version 24h later.

**Never in source code or logs:** API keys, legal entity IDs, OIB values, IBAN values.
`StorecoveHrFiskEInvoiceAdapter.sanitizeForLog()` must be called on all Storecove response
bodies before logging.

**RS future secrets (Phase 1S):**

| GCP Secret Manager path       | Content                     |
| ----------------------------- | --------------------------- |
| `Bilko/stage/RS/sef-api-key`  | SEF sandbox access token    |
| `Bilko/prod/RS/sef-api-key`   | SEF production access token |
| `Bilko/stage/RS/sef-username` | SEF API username            |
| `Bilko/prod/RS/sef-username`  | SEF API username (prod)     |

SEF uses OAuth2 with client credentials. The token endpoint is `https://efaktura.mfin.gov.rs/`
(Serbian Ministry of Finance). Exact credentials shape to be confirmed at Phase 1S kickoff.

### 2.7 Per-Platform Field Mapping

How `CanonicalInvoice` fields map to platform-specific XML/JSON:

| CanonicalInvoice field          | HR (UBL 2.1 / Peppol)                                             | RS (SEF XML)                     | BA-FED | BA-RS |
| ------------------------------- | ----------------------------------------------------------------- | -------------------------------- | ------ | ----- |
| `supplier.taxId`                | `AccountingSupplierParty/.../CompanyID @schemeID="9934"` (OIB)    | `/Invoice/Seller/TaxId` (PIB)    | TBD    | TBD   |
| `buyer.taxId`                   | `AccountingCustomerParty/.../CompanyID @schemeID="9934"` (OIB)    | `/Invoice/Buyer/TaxId` (PIB)     | TBD    | TBD   |
| `invoiceNumber`                 | `cbc:ID`                                                          | `/Invoice/InvoiceNumber`         | TBD    | TBD   |
| `issueDate`                     | `cbc:IssueDate` (ISO 8601)                                        | `/Invoice/IssueDate`             | TBD    | TBD   |
| `typeCode.untdidCode`           | `cbc:InvoiceTypeCode` (380/381/384)                               | `/Invoice/InvoiceType`           | TBD    | TBD   |
| `currencyCode`                  | `cbc:DocumentCurrencyCode` + `@currencyID` on all amounts         | `/Invoice/Currency`              | TBD    | TBD   |
| `taxBreakdowns[].taxRate`       | `TaxSubtotal/TaxCategory/Percent`                                 | `/Invoice/TaxTotal/TaxRate`      | TBD    | TBD   |
| `taxBreakdowns[].taxCategory`   | `TaxSubtotal/TaxCategory/ID` (S/Z/E/K per EN 16931 BT-118)        | Serbian code set                 | TBD    | TBD   |
| `paymentMeans.paymentReference` | `PaymentMeans/PaymentID` (HR "Poziv na broj")                     | `/Invoice/PaymentReference`      | TBD    | TBD   |
| `paymentMeans.iban`             | `PayeeFinancialAccount/ID`                                        | `/Invoice/BankAccount/IBAN`      | TBD    | TBD   |
| `adapterMetadata`               | `"hr.supplierOib"`, `"hr.buyerOib"`, `"hr.pozivNaBroj"` (inbound) | `"rs.sefId"`, `"rs.supplierPib"` | TBD    | TBD   |

**SEF XML note:** SEF does not use UBL 2.1. It uses a Serbian-specific XML schema published
by the Ministry of Finance. The SEF adapter maps `CanonicalInvoice` → SEF schema directly;
it does NOT go through UBL. `EInvoiceAdapter.serialize()` returns the platform's native format.

**BA adapters (Phase 1B):** CPF and UINO platforms have no published API specifications as of
2026-05-13. Phase 1B cannot begin until regulatory mandates define the technical specification
(~2027 per plan v3 context).

### 2.8 HR Reference Implementation Design Decisions

`StorecoveHrFiskEInvoiceAdapter` is the reference implementation. Future adapters MUST
replicate these patterns:

| Design decision                                   | Location in reference impl                          | Rule for future adapters           |
| ------------------------------------------------- | --------------------------------------------------- | ---------------------------------- |
| PII field redaction before logging                | Lines 24–59 (`REDACT_PII_FIELDS`, `sanitizeForLog`) | REQUIRED — GDPR / audit rules      |
| Offline serialization (no credentials)            | Lines 567–571 (`serialize()`)                       | REQUIRED per §2.1 contract         |
| Idempotency key (SHA-256 of id + invoiceNumber)   | Lines 591–600 (stub comment — activate post-#8675)  | REQUIRED if platform supports      |
| Credential validation on startup flag             | Lines 83–138 (`validateOnStartup`, `validate()`)    | REQUIRED — default false for tests |
| Error code mapping to `AdapterException`          | Lines 469–515 (`StorecoveErrorMapper`)              | REQUIRED — NEVER propagate native  |
| Structured metrics recording (`StorecoveMetrics`) | Lines 537–540                                       | REQUIRED — Prometheus counters     |
| Tax ID format validation in `serialize()`         | Lines 748–774 (OIB check)                           | REQUIRED — early error, no network |
| `document_id` for deduplication                   | Lines 420–437 (`StorecovePayloadBuilder.wrap()`)    | REQUIRED if platform supports 409  |

---

## 3. Adapter Lifecycle Governance

### 3.1 AdapterConfig Feature Flag

All adapter network paths (`submit`, `pollStatus`, `parseIncoming`) are gated by an
`AdapterConfig` row in the database. Defined fully in ADR-019 §2.4; referenced here:

```sql
CREATE TABLE adapter_config (
    id           UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    market       VARCHAR(8) NOT NULL,   -- TaxJurisdiction enum value
    adapter_type VARCHAR(32) NOT NULL,  -- 'EINVOICE', 'BANK_STATEMENT', etc.
    enabled      BOOLEAN NOT NULL DEFAULT FALSE,
    reason       TEXT,                  -- Human-readable status note
    updated_at   TIMESTAMPTZ NOT NULL DEFAULT now(),
    UNIQUE (market, adapter_type)
);
```

Seed row for HR STUB state (Flyway V17):

```sql
INSERT INTO adapter_config (market, adapter_type, enabled, reason)
VALUES ('HR', 'EINVOICE', false, 'Storecove account pending — MC #8675');
```

Row is flipped to `enabled = true` by the operator (not by code) after SANDBOX_VERIFIED
transition is confirmed by Proveo evidence.

### 3.2 Adapter Versioning

Each adapter exposes:

```kotlin
val adapterVersion: String  // e.g. "1.0.0"
```

The `CountryPlugin` implementation declares a minimum adapter version. Incompatibility
detected at startup → application fails fast with a clear error (not silent degradation).

---

## 4. Consequences

### 4.1 Positive

- **Offline serialization.** `serialize()` contract requires no network. Enables invoice
  PDF preview, offline testing, and regression test suites without live platform credentials.
- **Uniform error handling.** `AdapterException` is the only exception type crossing the
  adapter boundary. Callers implement one error handler, not four platform-specific ones.
- **Lifecycle visibility.** `lifecycleState` is first-class. Dashboards show "HR adapter: STUB"
  and alert when a market operates in degraded state.
- **Canonical model.** `CanonicalInvoice` enables cross-market reporting and analytics.
- **NOT_IMPLEMENTED → HTTP 503.** Clients receive a clean "feature not available" response
  instead of an HTTP 500 stack trace when an adapter is in STUB state.

### 4.2 Negative

- **SEF XML schema maintenance.** RS's SEF format changes without semantic versioning
  guarantees. The adapter must track schema changes proactively.
- **BA adapters are TBD.** Phase 1B work cannot begin until regulations define the spec.
- **4 methods = all or nothing lifecycle.** If `parseIncoming()` is the last unfinished
  method, the adapter cannot advance to SANDBOX_VERIFIED. The HR partial-SANDBOX exception
  (§2.5 rule 3) is a pragmatic workaround; it should not become a pattern.

### 4.3 Risks

- **CanonicalInvoice field gap.** A platform-specific required field has no canonical
  counterpart. **Resolution:** `adapterMetadata: Map<String, String>` for platform-specific
  extras until they generalise to first-class fields.
- **Storecove CustomizationID ambiguity (D3).** Two candidate CustomizationIDs —
  PEPPOL_BIS3 and HR_CIUS. **Resolution:** Verify with Storecove support before MC #8675
  sandbox activation. This is a HALT item. Wrong choice → all HR invoices rejected.
- **Storecove routing.network field (HALT-4).** Existing code does not include
  `routing.network` field. Verify with Storecove sandbox whether this is required.
- **Secret rotation lag.** Expired API key → all `submit()` calls throw
  `AUTH_INVALID_CREDENTIALS`. **Mitigation:** 90-day rotation schedule + cert-expiry-monitor
  (Task 4.3 in Plan v3).
- **OIB validation at serialize() vs submit().** Validates early (offline) but couples
  format and validation logic. Accepted trade-off: early errors are better than late ones.

---

## 5. References

| Reference                                           | Path                                                                                             | Lines   |
| --------------------------------------------------- | ------------------------------------------------------------------------------------------------ | ------- |
| `EInvoiceAdapter` interface                         | `apps/api/src/main/kotlin/no/alai/bilko/einvoice/EInvoiceTypes.kt`                               | 200–224 |
| `CanonicalInvoice` definition                       | `apps/api/src/main/kotlin/no/alai/bilko/einvoice/EInvoiceTypes.kt`                               | 141–156 |
| `AdapterLifecycleState` enum                        | `apps/api/src/main/kotlin/no/alai/bilko/einvoice/EInvoiceTypes.kt`                               | 22–26   |
| `AdapterErrorCode` enum + `AdapterException`        | `apps/api/src/main/kotlin/no/alai/bilko/adapter/AdapterTypes.kt`                                 | 1–41    |
| HR reference impl — full file                       | `apps/api/src/main/kotlin/no/alai/bilko/country/hr/StorecoveHrFiskEInvoiceAdapter.kt`            | 1–777   |
| `StorecoveMetrics` (Micrometer counters)            | `apps/api/src/main/kotlin/no/alai/bilko/country/hr/StorecoveMetrics.kt`                          | 1–73    |
| `StorecoveApiClient` (credentials + base URL)       | `StorecoveHrFiskEInvoiceAdapter.kt`                                                              | 77–179  |
| `StorecoveOibValidator` (ISO 7064 MOD 11,10)        | `StorecoveHrFiskEInvoiceAdapter.kt`                                                              | 194–225 |
| `StorecoveErrorMapper` (HTTP → AdapterErrorCode)    | `StorecoveHrFiskEInvoiceAdapter.kt`                                                              | 469–515 |
| PII sanitize helper (`sanitizeForLog`)              | `StorecoveHrFiskEInvoiceAdapter.kt`                                                              | 24–59   |
| `HrUblBuilder` (UBL 2.1 offline build)              | `StorecoveHrFiskEInvoiceAdapter.kt`                                                              | 241–387 |
| `StorecovePayloadBuilder` (wrap JSON + dedup D2)    | `StorecoveHrFiskEInvoiceAdapter.kt`                                                              | 418–450 |
| ADR-019 §2.4 (AdapterConfig table)                  | `docs/architecture/ADR-019-INTEGRATION-ADAPTER-REGISTRY.md`                                      | §2.4    |
| Plan v3 §4d HR critical path (sandbox verification) | `~/system/specs/bilko-multi-market-architecture-plan-v3-2026-05-11.md`                           | 147–176 |
| Plan v3 §4b ADR-016 requirement                     | `~/system/specs/bilko-multi-market-architecture-plan-v3-2026-05-11.md`                           | 125–126 |
| ADR-bilko-003 §Layer 2 (EInvoice serialization)     | `~/system/specs/bilko-multi-market-architecture-plan/ADR-bilko-003-market-abstraction-layers.md` | 103–117 |

---

## 6. Approval

**Status:** Accepted

**Unblocks:**

- Phase 1H Task 1H.2: `PluginHR.generateEInvoiceXml()` delegation to `StorecoveHrFiskEInvoiceAdapter`
- Phase 1H Task 1H.4: DI wiring — lifecycle state check before submit/pollStatus dispatch
- Phase 1H Task 1H.6: Storecove submit() activation (after MC #8675)
- ADR-019: Integration Adapter Registry — `AdapterConfig` table and secret taxonomy

| Role                             | Sign                          | Date       |
| -------------------------------- | ----------------------------- | ---------- |
| Finverge — Markos Zachariadis    | Signed                        | 2026-05-13 |
| Architecture Lead (Petter Graff) | Signed                        | 2026-05-13 |
| CEO (Alem Bašić)                 | Not required for contract ADR | —          |

---

## 7. Document History

| Date       | Author                            | Change                                                                                                                                                                                                                                                                                                                                                                 |
| ---------- | --------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 2026-05-11 | Markos Zachariadis / Petter Graff | v1 — Phase 0' initial (MC #100362)                                                                                                                                                                                                                                                                                                                                     |
| 2026-05-13 | Petter Graff                      | v2 — MC #100585: Full lifecycle state machine with explicit transition criteria; sandbox validation matrix (5 invoice types for HR-FISK Storecove); NOT_IMPLEMENTED transition rules; GCP Secret Manager taxonomy with HR+RS secret paths; HTTP 503 mapping for NOT_IMPLEMENTED; HALT items D3/D4 documented; StorecoveMetrics and StorecoveApiClient cited explicitly |
Architecture Decision Records

ADR-017: RLS Multi-Tenancy Migration

# ADR-017 — RLS Multi-Tenancy Migration

**Status:** Accepted — CEO Signed 2026-05-11 (Alem Bašić). Phase 2A V17 Flyway PERMISSIVE migration authorized for stage execution. Phase 2C RESTRICTIVE flip remains gated on Securion audit + 30-day soak per §4 schedule.
**Date:** 2026-05-11
**Author:** Bruce Momjian (Database Architecture, CodeCraft)
**Architecture Review:** Petter Graff (CodeCraft)
**Decision-maker:** CEO Alem Bašić — SIGNED 2026-05-11 ("ok adr17 odobreno") via session f73dafab
**Mehanik clearance:** /tmp/mehanik-cleared-100362
**MC Task:** #100362 (Phase 0' ADR Consolidation)
**Promoted from:** ADR-bilko-001 draft (`~/system/specs/bilko-multi-market-architecture-plan/ADR-bilko-001-multi-tenant-architecture.md`)
**Cross-references:**

- ADR-023 (why single DB remains correct — §6 supersession triggers not fired; §2 context)
- ADR-015 (TaxJurisdiction enum drives `country_code` column CHECK values)
- ADR-bilko-001 (ancestor draft, fully absorbed by this ADR — do not reference ancestor)
- ADR-bilko-003 §Layer 3 (versioned CoA data model)
- Plan v3 §4a (Option D not triggered), §4c (RLS timing — PERMISSIVE before Phase 1H merge)
- `~/system/specs/bilko-multi-market-architecture-plan-v3-2026-05-11.md`

---

## 1. Context

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

| Component               | State                                                                                                                                                          |
| ----------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Database                | `bilko-demo-db`, Cloud SQL PostgreSQL 15, europe-north1                                                                                                        |
| Flyway migrations       | V1..V15 applied                                                                                                                                                |
| Row-Level Security      | NOT enabled — zero RLS policies on any table                                                                                                                   |
| Tenant isolation        | Application-layer only: `WHERE org_id = :principalOrgId` clauses                                                                                               |
| `organizations.country` | Column exists; values `'RS'`, `'HR'`, `'BA'`; NOT NULL constraint absent                                                                                       |
| Cross-tenant leak       | Confirmed: PUT `/api/v1/invoices/{id}` and GET `/api/v1/invoices/{id}/pdf` with cross-tenant JWT return HTTP 500 (test drift memo 2026-05-10, Round 12.1/12.5) |

The current application-layer scoping (ADR-005) is the sole isolation mechanism. A single
missing `WHERE org_id` clause in any new route — or a refactoring that silently drops it — is
a cross-tenant data exposure. This is not theoretical: Round 12 probes confirmed it in two
existing routes.

### 1.2 Why Single Database Remains Correct (ADR-023 §6 Check)

ADR-023 §6 defines the conditions that would trigger migration to Option D (per-country DBs).
All five conditions are unmet as of 2026-05-11 (Plan v3 §4a lines 100–108):

- Paying customers in 2+ markets: 0 — NOT triggered
- Regulatory request for per-country data extract: none received — NOT triggered
- HR-FISK kernel-level coupling: Storecove API path requires no kernel isolation — NOT triggered
- p95 query latency > 500ms from cross-country noise: 0 paying customers — NOT triggered
- 2 customers complain about cross-country data visibility: 0 customers — NOT triggered

Option D costs +$60/month infra and 2–4 weeks engineering per market with no customer-facing
benefit today. **This ADR is explicitly compatible with Option D migration** — RLS policies
are portable to separate databases. If Option D triggers, the same policy DDL applies
to each per-country DB with zero changes.

### 1.3 Why RLS Cannot Wait Until Post-HR GA

Plan v3 §4c (lines 135–145): the cross-tenant 500 leaks are a live security defect.
With 0 paying customers today it is unexploited — but a second registered organization
(required for HR demo) creates an immediately exploitable state.

RLS PERMISSIVE mode (Phase 2A) imposes zero user-facing change and zero risk of service
disruption. The existing `WHERE org_id` middleware still fires, and RLS fires alongside
it. Both must pass for data to be returned. A latent policy gap is caught by the
application layer rather than exposing data to the wrong tenant.

**CEO sign is required before Phase 2A Flyway migrations run on stage** — not before
this ADR document is accepted. The ADR records the decision; the sign unblocks execution.

---

## 2. Decision

**Option C is adopted: Shared codebase, shared deployment, shared database, with
PostgreSQL Row-Level Security enforcing tenant isolation.**

This is the unanimous recommendation from the 5-agent architecture review (ADR-bilko-001 §framing,
line 28–30). One codebase. One Cloud Run deployment. One PostgreSQL instance with RLS.

### 2.1 Binding Constraints

1. `Organization.taxJurisdiction` (`TaxJurisdiction` enum `{HR, RS, BA_FED, BA_RS}` per ADR-015) is
   the primary discriminator for jurisdiction-specific behaviour.
2. `Organization.id` (UUID) is the primary tenant discriminator for data isolation.
3. RLS policies enforce data isolation at the database layer. Application code MUST NOT
   rely solely on `WHERE org_id = :id` clauses (ADR-005 flaw — being retired by Phase 2C).
4. The `country_code` column on `organizations` is NOT NULL with CHECK constraint
   `IN ('HR', 'RS', 'BA_FED', 'BA_RS')` — enforced by Flyway V16 (Phase 1H Task 1H.1).
5. EU data residency: Current `bilko-demo-db` is in Cloud SQL `europe-north1` (Finland).
   This IS within EU/EEA — GDPR Article 44 satisfied. Frankfurt migration (eu-central-1)
   is not required to unblock HR GA (Plan v3 §4d lines 179–183).

### 2.2 Three-Phase Migration Path

The migration is split into three phases to ensure zero service disruption and a safe
rollback path at each step.

#### Phase 2A — PERMISSIVE RLS (parallel with Phase 1H, target: end of Week 2)

**Goal:** RLS policies created and attached, set to PERMISSIVE. Existing application-layer
scoping continues to operate. Both layers must pass — RLS is a second check, not a
replacement.

**Who signs this off:** CEO Alem Bašić (this ADR signature) — required before any
Phase 2A Flyway migrations run on the stage database.

**DDL — PERMISSIVE policies (Flyway V17):**

```sql
-- V17__rls_permissive.sql
-- ZAKON: CEO sign required before this migration runs on stage.
-- Apply PERMISSIVE RLS on core tables. Application-layer WHERE org_id
-- clauses remain active. Both must pass.

-- Enable RLS on tables
ALTER TABLE organizations       ENABLE ROW LEVEL SECURITY;
ALTER TABLE invoices            ENABLE ROW LEVEL SECURITY;
ALTER TABLE invoice_items       ENABLE ROW LEVEL SECURITY;
ALTER TABLE expenses            ENABLE ROW LEVEL SECURITY;
ALTER TABLE transactions        ENABLE ROW LEVEL SECURITY;
ALTER TABLE bank_transactions   ENABLE ROW LEVEL SECURITY;
ALTER TABLE bank_accounts       ENABLE ROW LEVEL SECURITY;
ALTER TABLE accounts            ENABLE ROW LEVEL SECURITY;
ALTER TABLE contacts            ENABLE ROW LEVEL SECURITY;

-- PERMISSIVE policy: organization-scoped isolation
-- current_setting() reads the app.current_org_id session variable
-- set by the Ktor connection pool before each query (connection middleware).

CREATE POLICY org_isolation ON invoices
    AS PERMISSIVE
    FOR ALL
    TO bilko_app              -- application role (NOT superuser)
    USING (org_id = current_setting('app.current_org_id')::uuid);

CREATE POLICY org_isolation ON invoice_items
    AS PERMISSIVE
    FOR ALL
    TO bilko_app
    USING (
        invoice_id IN (
            SELECT id FROM invoices
            WHERE org_id = current_setting('app.current_org_id')::uuid
        )
    );

CREATE POLICY org_isolation ON expenses
    AS PERMISSIVE
    FOR ALL
    TO bilko_app
    USING (org_id = current_setting('app.current_org_id')::uuid);

CREATE POLICY org_isolation ON transactions
    AS PERMISSIVE
    FOR ALL
    TO bilko_app
    USING (org_id = current_setting('app.current_org_id')::uuid);

CREATE POLICY org_isolation ON bank_transactions
    AS PERMISSIVE
    FOR ALL
    TO bilko_app
    USING (
        bank_account_id IN (
            SELECT id FROM bank_accounts
            WHERE org_id = current_setting('app.current_org_id')::uuid
        )
    );

CREATE POLICY org_isolation ON bank_accounts
    AS PERMISSIVE
    FOR ALL
    TO bilko_app
    USING (org_id = current_setting('app.current_org_id')::uuid);

CREATE POLICY org_isolation ON accounts
    AS PERMISSIVE
    FOR ALL
    TO bilko_app
    USING (org_id = current_setting('app.current_org_id')::uuid);

CREATE POLICY org_isolation ON contacts
    AS PERMISSIVE
    FOR ALL
    TO bilko_app
    USING (org_id = current_setting('app.current_org_id')::uuid);

-- BYPASS for migrations and admin tooling (Flyway runs as bilko_admin)
ALTER TABLE invoices         FORCE ROW LEVEL SECURITY;
ALTER TABLE expenses         FORCE ROW LEVEL SECURITY;
ALTER TABLE transactions     FORCE ROW LEVEL SECURITY;
ALTER TABLE bank_transactions FORCE ROW LEVEL SECURITY;
ALTER TABLE bank_accounts    FORCE ROW LEVEL SECURITY;
ALTER TABLE accounts         FORCE ROW LEVEL SECURITY;
ALTER TABLE contacts         FORCE ROW LEVEL SECURITY;
-- Flyway runs as bilko_admin (superuser bypasses RLS by default).
-- Explicit FORCE is belt-and-suspenders — admin role grants BYPASSRLS if needed.

-- Set connection middleware (Kotlin Exposed / HikariCP):
-- On each connection checkout:
--   SET LOCAL app.current_org_id = '<org_uuid_from_jwt>';
-- On connection return to pool:
--   SET LOCAL app.current_org_id = '';  -- or reset_config('app.current_org_id', true)
```

**Verification after Phase 2A:**

```sql
-- Rogue-role test (Proveo E2E + Securion audit):
SET ROLE bilko_app;
SET LOCAL app.current_org_id = '<hr_org_uuid>';
SELECT count(*) FROM invoices;  -- must return only HR org rows
SET LOCAL app.current_org_id = '<rs_org_uuid>';
SELECT count(*) FROM invoices;  -- must return only RS org rows
-- Cross-tenant access attempt:
SET LOCAL app.current_org_id = '<hr_org_uuid>';
SELECT * FROM invoices WHERE org_id = '<rs_org_uuid>';  -- must return 0 rows (PERMISSIVE blocks)
```

#### Phase 2B — Audit Log Partitioning (post-HR GA)

**Goal:** Partition the `logged_actions` audit table by `country_code` to enable
per-jurisdiction GDPR data extraction requests and enforce per-jurisdiction retention.

```sql
-- V18__audit_log_partitioning.sql (Phase 2B — post-HR GA)

-- Declarative partitioning by country_code
CREATE TABLE logged_actions_partitioned (
    LIKE logged_actions INCLUDING ALL
) PARTITION BY LIST (country_code);

CREATE TABLE logged_actions_hr     PARTITION OF logged_actions_partitioned
    FOR VALUES IN ('HR');
CREATE TABLE logged_actions_rs     PARTITION OF logged_actions_partitioned
    FOR VALUES IN ('RS');
CREATE TABLE logged_actions_ba_fed PARTITION OF logged_actions_partitioned
    FOR VALUES IN ('BA_FED');
CREATE TABLE logged_actions_ba_rs  PARTITION OF logged_actions_partitioned
    FOR VALUES IN ('BA_RS');

-- Retention policy enforcement (aligned with CountryPlugin.getRetentionRules()):
-- HR: 11 years (Zakon o računovodstvu NN 78/2015, čl. 10)
-- RS/BA: 10 years
-- Implemented as pg_cron job deleting rows WHERE action_tstamp_tx < now() - interval '11 years'
-- per partition.

-- country_code column backfilled from organizations.country via:
-- UPDATE logged_actions SET country_code = o.country
-- FROM organizations o WHERE o.id = logged_actions.org_id;
```

RLS policy for `logged_actions` (applied in Phase 2B):

```sql
CREATE POLICY org_isolation ON logged_actions_partitioned
    AS PERMISSIVE
    FOR ALL
    TO bilko_app
    USING (org_id = current_setting('app.current_org_id')::uuid);
```

#### Phase 2C — RESTRICTIVE + Retire Application-Layer Scoping (post-Securion Audit)

**Goal:** Convert PERMISSIVE policies to RESTRICTIVE. Remove ADR-005 application-layer
`WHERE org_id` middleware. RLS is the sole isolation mechanism.

**Gate conditions (all must be true before Phase 2C begins):**

1. Securion audit of Phase 2A policies completed — no critical findings
2. Automated rogue-role test suite passing in CI (Proveo — see Phase 2A verification above)
3. Zero cross-tenant RLS bypass incidents on stage for 30 consecutive days
4. CEO explicit sign-off for Phase 2C

```sql
-- V19__rls_restrictive.sql (Phase 2C — post Securion audit)
-- Convert PERMISSIVE → RESTRICTIVE on all tables
-- This is the point of no return: application layer WHERE org_id is retired after this.

DROP POLICY org_isolation ON invoices;
CREATE POLICY org_isolation ON invoices
    AS RESTRICTIVE
    FOR ALL
    TO bilko_app
    USING (org_id = current_setting('app.current_org_id')::uuid)
    WITH CHECK (org_id = current_setting('app.current_org_id')::uuid);

-- Same pattern for expenses, transactions, bank_transactions, bank_accounts,
-- accounts, contacts, invoice_items (repeat for each table).
```

### 2.3 Versioned Chart of Accounts Table

The `chart_of_accounts` table stores jurisdiction-specific CoA entries with time-ranged
validity. This supports:

- Pravilnik revisions without code changes (ADR-bilko-003 §Layer 3, lines 122–143)
- Historical invoice accuracy (rate in force at transaction date, not current rate)
- `CountryPlugin.getChartOfAccountsDefaults()` seeding on org creation (ADR-015 §2.2)

```sql
-- Part of Flyway V17 or separate V17b (Phase 2A / 1H parallel)

CREATE TABLE chart_of_accounts (
    id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    jurisdiction    VARCHAR(8) NOT NULL,    -- TaxJurisdiction enum value: 'HR', 'RS', 'BA_FED', 'BA_RS'
    code            VARCHAR(16) NOT NULL,   -- e.g. '1300' (HR Kontni Plan), '204' (RS Pravilnik)
    name            VARCHAR(256) NOT NULL,
    account_type    VARCHAR(16) NOT NULL    -- ASSET, LIABILITY, EQUITY, INCOME, EXPENSE
                    CHECK (account_type IN ('ASSET', 'LIABILITY', 'EQUITY', 'INCOME', 'EXPENSE')),
    vat_treatment   VARCHAR(64),            -- e.g. 'STANDARD_RATE', 'EXEMPT', null for non-VAT accounts
    valid_from      DATE NOT NULL,
    valid_to        DATE,                   -- NULL = currently valid
    version         INT NOT NULL DEFAULT 1, -- increments per Pravilnik revision
    notes           TEXT,                   -- statutory reference e.g. "NN 78/2015, čl. 5"
    created_at      TIMESTAMPTZ NOT NULL DEFAULT now(),
    UNIQUE (jurisdiction, code, valid_from)
);

CREATE INDEX idx_coa_jurisdiction_date
    ON chart_of_accounts (jurisdiction, valid_from, valid_to);

-- Query pattern: entries valid on a given transaction date
-- SELECT * FROM chart_of_accounts
-- WHERE jurisdiction = $1
--   AND valid_from <= $2
--   AND (valid_to IS NULL OR valid_to > $2)
-- ORDER BY code;

-- When Croatia raises PDV from 25% to 27% on 2027-01-01:
-- INSERT INTO chart_of_accounts (jurisdiction, code, name, account_type, vat_treatment, valid_from, version)
-- VALUES ('HR', '2400', 'PDV po stopi 27%', 'LIABILITY', 'STANDARD_RATE', '2027-01-01', 2);
-- UPDATE chart_of_accounts SET valid_to = '2026-12-31'
-- WHERE jurisdiction = 'HR' AND code = '2400' AND valid_to IS NULL AND version = 1;
-- No code change required.
```

**Seeding:** `CountryPlugin.getChartOfAccountsDefaults()` returns the list of entries
that Flyway data migrations insert into `chart_of_accounts` for each jurisdiction.
Flyway V18 (Phase 1H — separate from V17 RLS) seeds HR Kontni Plan entries.

### 2.4 Exchange Rate Precision Upgrade

**Current precision (CLAUDE.md database rules):** `NUMERIC(19,4)` for ALL monetary amounts.

**Upgrade required for FX rate columns specifically:**

Exchange rates require higher precision than invoice monetary amounts. Using `NUMERIC(19,4)`
for an exchange rate means EUR/RSD at 117.2350 is representable, but EUR/BAM at
1.95583 is stored as 1.9558 — a systematic rounding error that compounds across large
invoice volumes and cross-currency reconciliation.

**Decision:** FX rate columns upgrade to `NUMERIC(20,10)`. Monetary amount columns
(invoice totals, line amounts, tax amounts) remain `NUMERIC(19,4)`.

```sql
-- V17c__exchange_rate_precision.sql (Phase 2A parallel)

ALTER TABLE exchange_rates
    ALTER COLUMN rate TYPE NUMERIC(20,10);  -- was NUMERIC(19,4)

-- If an exchange_rate_history or similar snapshot table exists:
-- ALTER TABLE exchange_rate_history
--     ALTER COLUMN rate TYPE NUMERIC(20,10);

-- NEVER change invoice_items.unit_price, invoice_items.line_total,
-- transactions.amount, etc. — those remain NUMERIC(19,4).
-- Only rate/exchange_rate columns receive this upgrade.
```

**Invariant:** All monetary arithmetic (invoice totals, tax calculations, double-entry
postings) remains at `NUMERIC(19,4)`. The precision upgrade is scoped to the FX
rate storage layer only. Rounding when applying FX rates to amounts: round half-even
(banker's rounding) to 4 decimal places after multiplication.

---

## 3. Connection Middleware — Setting `app.current_org_id`

The RLS policies use `current_setting('app.current_org_id')::uuid`. This session
variable must be set on every database connection before any query executes.

**Pattern (Kotlin / Exposed / HikariCP):**

```kotlin
// apps/api/src/main/kotlin/no/alai/bilko/db/OrgContextInterceptor.kt (Phase 2A NEW)

/**
 * Sets the PostgreSQL session variable `app.current_org_id` to the authenticated
 * org's UUID before any database access.
 *
 * Called from the Ktor routing pipeline after JWT validation, before the
 * database transaction opens.
 *
 * Must reset after the request completes — use try/finally or Ktor plugin lifecycle.
 */
fun setOrgContext(orgId: UUID) {
    transaction {
        exec("SET LOCAL app.current_org_id = '${orgId}'")
    }
}

fun clearOrgContext() {
    transaction {
        exec("RESET app.current_org_id")
        // or: exec("SET LOCAL app.current_org_id = ''")
    }
}
```

**Failure mode:** If `app.current_org_id` is not set, `current_setting('app.current_org_id')`
throws an error in PostgreSQL (by default). To make it return NULL instead (for Flyway
admin connections that do not set the variable):

```sql
-- In V17 migration, set default:
ALTER DATABASE bilko_demo SET app.current_org_id = '';
```

And in the policy, guard against empty string:

```sql
USING (
    CASE WHEN current_setting('app.current_org_id', true) = ''
         THEN false  -- deny if not set
         ELSE org_id = current_setting('app.current_org_id', true)::uuid
    END
)
```

The `true` parameter to `current_setting()` makes it return NULL rather than throw
when the variable is not set.

---

## 4. Migration Schedule

| Phase      | Flyway Version                                      | Target                      | Blocking                                    |
| ---------- | --------------------------------------------------- | --------------------------- | ------------------------------------------- |
| Phase 1H.1 | V16: `organizations.country` NOT NULL + CHECK       | HR enum expansion (ADR-015) | ADR-015 accepted                            |
| Phase 2A   | V17: PERMISSIVE RLS + CoA table + FX rate precision | Stage only                  | CEO sign (this ADR)                         |
| Phase 2A   | V17 seed: HR Kontni Plan data                       | Stage only                  | V17 + PluginHR.getChartOfAccountsDefaults() |
| Phase 2B   | V18: audit log partitioning                         | Post-HR GA                  | Securion review                             |
| Phase 2C   | V19: RESTRICTIVE + retire ADR-005 app scoping       | Post-Securion audit         | Securion audit pass + CEO sign              |

All migrations use Flyway's expand/contract pattern. No migration modifies data in a way
that cannot be reversed by a subsequent compensating migration. Backward compatibility
is required across all rolling deployments.

---

## 5. Consequences

### 5.1 Positive

1. **Defence in depth.** Even if a developer introduces a missing `WHERE org_id` in a new
   route, RLS at the database layer prevents cross-tenant data exposure.
2. **GDPR jurisdiction extraction.** With `country_code` on `logged_actions` (Phase 2B),
   a request from Croatian DPA for "all data held on Croatian entities" is a single
   partition query, not a full-table scan with a filter.
3. **Audit surface.** Securion can review one set of RLS policies rather than auditing
   every application route for correct scoping.
4. **Option D readiness.** If ADR-023 §6 triggers (e.g., first paying HR customer), the
   same RLS DDL applies to the per-country databases without change. Migration path is
   not blocked by this ADR.

### 5.2 Negative

1. **Connection middleware requirement.** Every DB connection must set `app.current_org_id`
   before any query. Forgetting this in a new service or background job will cause all
   queries to return 0 rows (PERMISSIVE) or error (RESTRICTIVE). Mitigated by integration
   tests that verify the context middleware fires.
2. **Flyway admin bypass.** Flyway and admin tooling must run as a role that bypasses RLS
   (`bilko_admin` with BYPASSRLS). This role must be kept tightly restricted — it is
   a privilege escalation path.
3. **Phase 2A adds overhead.** Each query now evaluates an additional predicate. At current
   scale (0 paying customers) the overhead is immeasurable. Monitor p95 query latency
   after Phase 2A migration on stage.

### 5.3 Risks

1. **GDPR data residency.** Croatian entity data in Cloud SQL europe-north1 (Finland) is
   legally compliant (EU/EEA). If a future HR DPA contract specifies Frankfurt, a regional
   migration is required. This ADR does not block that migration.
2. **RLS policy gap.** An incorrect USING clause (e.g., JOIN condition that broadens
   the scope) could expose cross-tenant data. **Mitigation:** Securion audit before
   Phase 2C (RESTRICTIVE), automated rogue-role test in CI.
3. **Migration synchronization.** A Flyway migration failure mid-run leaves all markets
   degraded. All V17+ migrations must be backward-compatible and use expand/contract
   pattern. If V17 fails, rollback is: `DROP POLICY` + `ALTER TABLE ... DISABLE ROW LEVEL SECURITY`.

---

## 6. References

| Reference                                                   | Path                                                                                             | Lines   |
| ----------------------------------------------------------- | ------------------------------------------------------------------------------------------------ | ------- |
| ADR-bilko-001 (ancestor draft, absorbed by this ADR)        | `~/system/specs/bilko-multi-market-architecture-plan/ADR-bilko-001-multi-tenant-architecture.md` | 1–162   |
| ADR-bilko-003 §Layer 3 (versioned CoA model)                | `~/system/specs/bilko-multi-market-architecture-plan/ADR-bilko-003-market-abstraction-layers.md` | 122–143 |
| ADR-023 §6 (single-DB migration triggers — not fired)       | `docs/architecture/ADR-023-TRANSITIONAL-MULTI-MARKET-ROUTING.md`                                 | 166–176 |
| Plan v3 §4a (Option D not triggered — evidence)             | `~/system/specs/bilko-multi-market-architecture-plan-v3-2026-05-11.md`                           | 100–108 |
| Plan v3 §4c (RLS timing — PERMISSIVE before Phase 1H)       | `~/system/specs/bilko-multi-market-architecture-plan-v3-2026-05-11.md`                           | 135–145 |
| Plan v3 §4d (EU data residency does not block HR GA)        | `~/system/specs/bilko-multi-market-architecture-plan-v3-2026-05-11.md`                           | 179–183 |
| ADR-015 §2.1 (TaxJurisdiction enum — `country_code` values) | `docs/architecture/ADR-015-FOUR-JURISDICTION-PLUGIN.md`                                          | §2.1    |
| Test drift memo (cross-tenant 500 leaks, Round 12.1/12.5)   | `~/.claude/projects/-Users-makinja/memory/project_bilko_test_strategy_drift_2026-05-10.md`       | —       |

---

## 7. Approval

**Architecture status:** Accepted (Phase 0' ADR consolidation)
**CEO sign status:** SIGNED 2026-05-11 — Phase 2A V17 Flyway PERMISSIVE migration authorized for stage. Phase 2C RESTRICTIVE flip remains gated on Securion audit + 30-day soak per §4 schedule.

This ADR records the architectural decision. The CEO signature below is the gate for
execution of Phase 2A database migrations. It is not a gate for writing this document
or for Phase 1H code work (CountryPlugin, PluginHR, DI wiring).

| Role                                  | Sign                                                          | Date       |
| ------------------------------------- | ------------------------------------------------------------- | ---------- |
| Architecture Lead (Petter Graff)      | Signed                                                        | 2026-05-11 |
| Database Architecture (Bruce Momjian) | Signed                                                        | 2026-05-11 |
| CEO (Alem Bašić)                      | **SIGNED — session f73dafab, transcript "ok adr17 odobreno"** | 2026-05-11 |

---

## 8. Document History

| Date       | Author                       | Change                                                                                                                                                                                                                                                      |
| ---------- | ---------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 2026-04-22 | ALAI / ADR-bilko-001         | Initial draft (multi-tenant architecture options analysis)                                                                                                                                                                                                  |
| 2026-05-11 | Bruce Momjian / Petter Graff | Promoted from ADR-bilko-001 draft; ID changed to ADR-017; DDL examples added; versioned CoA DDL added; NUMERIC(20,10) FX precision noted; Phase 2B audit log partitioning added; connection middleware pattern added; CEO sign gate formalised. MC #100362. |
| 2026-05-11 | John (AI Director)           | CEO Alem Bašić signed ADR-017 via session f73dafab ("ok adr17 odobreno"). Phase 2A V17 Flyway PERMISSIVE migration authorized for stage. Status header + §7 approval table updated. Unblocks Bruce Momjian dispatch for Phase 2A.                           |
Architecture Decision Records

ADR-019: Integration Adapter Registry

# ADR-019 — Integration Adapter Registry

**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)
**Cross-references:**

- ADR-015 (CountryPlugin — plugin selects adapters for its market; plugin version compatibility)
- ADR-016 (EInvoiceAdapter — one of the 7 adapter categories; lifecycle states formalised here)
- ADR-023 (routing — market resolved at edge before adapter dispatch)
- `apps/api/src/main/kotlin/no/alai/bilko/country/hr/StorecoveHrFiskEInvoiceAdapter.kt` (reference impl)
- `apps/api/src/main/kotlin/no/alai/bilko/einvoice/EInvoiceTypes.kt` (AdapterLifecycleState on disk)
- Plan v3 §6 Phase 0' Task 0'4 — `~/system/specs/bilko-multi-market-architecture-plan-v3-2026-05-11.md`

---

## 1. Context

### 1.1 Problem: Seven Integration Surfaces, No Governance

Bilko integrates with external systems across seven functional domains. As of 2026-05-11,
only one adapter exists (`StorecoveHrFiskEInvoiceAdapter`). Without a registry and governance
model, adding the second adapter (SEF for RS) and every subsequent adapter will produce:

1. Inconsistent error handling — platform-native exceptions leaking across boundaries
2. No feature-flag mechanism — a broken SEF adapter takes down all RS users
3. Secret sprawl — `STORECOVE_API_KEY` as an env var pattern, but no taxonomy when
   there are 7 adapters × 4 markets × 3 environments = up to 84 secrets
4. No observability standard — each adapter invents its own logging and metrics
5. No lifecycle discipline — adapters deployed to production without sandbox verification

### 1.2 Reference Implementation Patterns

`StorecoveHrFiskEInvoiceAdapter.kt` already demonstrates all the patterns this ADR
formalises. This ADR makes those patterns enforceable for all future adapters:

| Pattern                                          | StorecoveHrFiskEInvoiceAdapter          | ADR-019 makes it |
| ------------------------------------------------ | --------------------------------------- | ---------------- |
| PII redaction before logging                     | Lines 24–59 (`sanitizeForLog`)          | Mandatory        |
| `AdapterException` only (no platform exceptions) | Lines 469–516 (`StorecoveErrorMapper`)  | Mandatory        |
| Per-adapter Prometheus metrics                   | Lines 537–540 (`StorecoveMetrics`)      | Mandatory        |
| Lifecycle state field                            | Lines 547–548 (`lifecycleState = STUB`) | Mandatory        |
| Idempotency key on submit                        | Lines 591–600 (D5 comment)              | Mandatory        |
| Credentials NOT required for serialize()         | Lines 567–571                           | Mandatory        |
| Startup credential validation flag               | Lines 83–138                            | Recommended      |

---

## 2. Decision

### 2.1 Seven Adapter Categories

Every external integration belongs to exactly one of the following categories.
Each category is a Kotlin interface in `apps/api/src/main/kotlin/no/alai/bilko/adapter/`.

| Category | Interface                | Purpose                                                                    | Markets                                                      |
| -------- | ------------------------ | -------------------------------------------------------------------------- | ------------------------------------------------------------ |
| 1        | `EInvoiceAdapter`        | E-invoice serialization + fiscal platform submission                       | HR (Storecove), RS (SEF), BA-FED (CPF), BA-RS (UINO)         |
| 2        | `CompanyRegistryAdapter` | Company data lookup (name, address, tax status) from government registries | HR (FINA), RS (APR), BA (stub)                               |
| 3        | `BankStatementAdapter`   | Bank statement import (MT940, CAMT.053, PSD2 AISP)                         | All markets — via Tok Open Banking platform                  |
| 4        | `ExchangeRateAdapter`    | FX rate feed (daily/live)                                                  | All markets (ECB primary, HNB for HR, NBS for RS)            |
| 5        | `TaxFilingAdapter`       | Electronic VAT/CIT return submission to tax authority                      | HR (ePorezna), RS (ePorezi), BA (TBD)                        |
| 6        | `FiscalDeviceAdapter`    | Fiscal receipt device or cloud fiscal service                              | HR (Fiskalizacija cloud cert), RS (LPFR chip card), BA (TBD) |
| 7        | `QESSigningAdapter`      | Qualified Electronic Signature for invoice signing                         | HR (FINA QES), RS (stub), BA (stub)                          |

**Current implementation status:**

- `EInvoiceAdapter`: `StorecoveHrFiskEInvoiceAdapter` (HR, STUB lifecycle)
- All other categories: NOT YET IMPLEMENTED

### 2.2 Common Interface Contract

Every adapter interface extends a common `BilkoAdapter` base:

```kotlin
package no.alai.bilko.adapter

import no.alai.bilko.country.TaxJurisdiction
import no.alai.bilko.einvoice.AdapterLifecycleState

/**
 * Base contract for all Bilko integration adapters.
 *
 * Every adapter implementation MUST:
 * 1. Expose [jurisdiction] and [lifecycleState] as first-class properties.
 * 2. Throw only [AdapterException] — NEVER platform-native exceptions.
 * 3. Pass all log writes through [sanitizeForLog] (defined per-adapter for PII fields).
 * 4. Record Prometheus metrics on every external call (see §2.6).
 * 5. Not require credentials for read-only / serialization operations.
 */
interface BilkoAdapter {
    val jurisdiction: TaxJurisdiction
    val lifecycleState: AdapterLifecycleState
    val adapterVersion: String  // Semantic version string, e.g. "1.0.0"
}
```

### 2.3 AdapterException — Canonical Error Contract

All adapters throw `AdapterException` and nothing else. This exception type is the
single crossing point from adapter space to core service space.

```kotlin
package no.alai.bilko.adapter

import no.alai.bilko.country.TaxJurisdiction

/**
 * Canonical adapter error. The ONLY exception type that crosses the adapter boundary.
 *
 * INVARIANT: Core services catch AdapterException only. They MUST NOT catch
 * platform-native exceptions (Ktor ResponseException, HttpRequestTimeoutException,
 * java.net.SocketTimeoutException, etc.). Map those to AdapterException in the adapter.
 *
 * [retryable]: if true, caller may retry with exponential backoff.
 * [rawPayload]: sanitized (PII-redacted) raw response body for audit. NEVER raw.
 */
data class AdapterException(
    val code: AdapterErrorCode,
    val market: TaxJurisdiction,
    val retryable: Boolean,
    val rawPayload: String,
    override val message: String = code.name,
    override val cause: Throwable? = null,
) : RuntimeException(message, cause)

/**
 * Canonical error codes — adapter-independent.
 *
 * Adapters map platform-specific HTTP status codes and error bodies to these codes.
 * See StorecoveErrorMapper (lines 469–516) for the HR reference mapping.
 */
enum class AdapterErrorCode {
    // Validation errors — not retryable
    VALIDATION_SCHEMA_ERROR,       // Invalid document structure (HTTP 400/422)
    VALIDATION_BUSINESS_RULE,      // Business rule violation (e.g., invalid OIB, non-EUR currency)
    VALIDATION_DUPLICATE_DOCUMENT, // Idempotency conflict (HTTP 409)

    // Authentication/authorisation — not retryable
    AUTH_INVALID_CREDENTIALS,      // API key invalid / token expired / certificate rejected

    // Platform errors — retryable
    PLATFORM_RATE_LIMITED,         // HTTP 429 — back off and retry
    PLATFORM_MAINTENANCE,          // HTTP 503 — platform in scheduled maintenance
    PLATFORM_INTERNAL_ERROR,       // HTTP 5xx — transient platform error

    // Network errors — retryable
    NETWORK_TIMEOUT,               // Connection or read timeout
    NETWORK_UNREACHABLE,           // DNS resolution failure or TCP refused

    // Implementation status — not retryable
    NOT_IMPLEMENTED,               // Adapter is in STUB lifecycle state
    UNKNOWN,                       // Unmapped error; always log rawPayload for triage
}
```

**Mapping rule for new adapters:** Every HTTP status code the platform can return MUST
have a mapping to an `AdapterErrorCode`. Use `UNKNOWN` only as a catch-all, never as
the primary mapping for a known status code. See `StorecoveErrorMapper` (lines 469–516
in `StorecoveHrFiskEInvoiceAdapter.kt`) as the reference pattern.

### 2.4 AdapterConfig — DB-Level Feature Flag

Every adapter is gated by an `AdapterConfig` row. An adapter MUST NOT execute any
network call if its `AdapterConfig.enabled = false`. This allows disabling a broken
adapter without redeployment.

```sql
-- V20__adapter_config.sql (Phase 1H — during Phase 2A window)

CREATE TABLE adapter_config (
    id           UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    market       VARCHAR(8) NOT NULL,
                 -- TaxJurisdiction enum value: 'HR', 'RS', 'BA_FED', 'BA_RS'
    adapter_type VARCHAR(32) NOT NULL,
                 -- Matches the 7 categories: 'EINVOICE', 'COMPANY_REGISTRY',
                 -- 'BANK_STATEMENT', 'EXCHANGE_RATE', 'TAX_FILING',
                 -- 'FISCAL_DEVICE', 'QES_SIGNING'
    enabled      BOOLEAN NOT NULL DEFAULT FALSE,
    reason       TEXT,
                 -- Why disabled, e.g. "MC #8675 pending — Storecove account not activated"
                 -- Required when enabled=false.
    updated_at   TIMESTAMPTZ NOT NULL DEFAULT now(),
    updated_by   TEXT NOT NULL DEFAULT 'system',  -- MC task ID or admin user
    CONSTRAINT pk_adapter_config UNIQUE (market, adapter_type)
);

-- Seed: all adapters start disabled
INSERT INTO adapter_config (market, adapter_type, enabled, reason, updated_by)
VALUES
    ('HR', 'EINVOICE',         false, 'MC #8675 — Storecove account pending', 'MC-100362'),
    ('HR', 'COMPANY_REGISTRY', false, 'Not implemented — Phase 1S', 'MC-100362'),
    ('HR', 'BANK_STATEMENT',   false, 'Tok AISP integration pending', 'MC-100362'),
    ('HR', 'EXCHANGE_RATE',    false, 'ECB feed not configured', 'MC-100362'),
    ('HR', 'TAX_FILING',       false, 'ePorezna integration Phase 2 scope', 'MC-100362'),
    ('HR', 'FISCAL_DEVICE',    false, 'Fiskalizacija cert not configured', 'MC-100362'),
    ('HR', 'QES_SIGNING',      false, 'FINA QES Phase 2 scope', 'MC-100362'),
    ('RS', 'EINVOICE',         false, 'SEF adapter Phase 1S scope', 'MC-100362'),
    ('BA_FED', 'EINVOICE',     false, 'CPF platform TBD ~2027', 'MC-100362'),
    ('BA_RS', 'EINVOICE',      false, 'UINO platform TBD', 'MC-100362');
-- (Remaining BA/RS adapter rows follow same pattern)

-- Admin can enable without redeploy:
-- UPDATE adapter_config SET enabled = true, reason = NULL, updated_by = 'MC-8675-DONE'
-- WHERE market = 'HR' AND adapter_type = 'EINVOICE';
```

**Kotlin enforcement pattern:**

```kotlin
// In the adapter registry (apps/api/src/main/kotlin/no/alai/bilko/adapter/AdapterRegistry.kt)
fun requireEnabled(market: TaxJurisdiction, adapterType: String) {
    val config = adapterConfigRepository.find(market, adapterType)
        ?: throw AdapterException(
            code = AdapterErrorCode.NOT_IMPLEMENTED,
            market = market,
            retryable = false,
            rawPayload = "",
            message = "No AdapterConfig row for ($market, $adapterType) — run Flyway V20"
        )
    if (!config.enabled) {
        throw AdapterException(
            code = AdapterErrorCode.NOT_IMPLEMENTED,
            market = market,
            retryable = false,
            rawPayload = "",
            message = "Adapter ($market, $adapterType) is disabled: ${config.reason}"
        )
    }
}
```

### 2.5 Lifecycle States and Transition Criteria

Formalised from `AdapterLifecycleState` enum in `EInvoiceTypes.kt` lines 22–26,
and from ADR-016 §2.3. Applies to ALL adapter categories.

```
STUB ──────────────────► SANDBOX_VERIFIED ──────────────────► PRODUCTION
```

**STUB** (initial state for all adapters):

- Compiles and registers successfully
- All network methods throw `AdapterException(code=NOT_IMPLEMENTED)`
- `serialize()` / read-only operations may work (e.g., HR serialize works in STUB)
- `AdapterConfig.enabled` is `false`

**SANDBOX_VERIFIED** transition criteria (all must be true):

- Minimum 5 distinct happy-path test cases pass against the real sandbox platform
  (not mocked — real submission IDs, real response payloads)
- All test case submission IDs are archived in BookStack as evidence
- Error mapping verified: at least HTTP 400, 401, 409, 429, 503, 5xx all produce
  correct `AdapterErrorCode` values (not `UNKNOWN`)
- Proveo sign-off with evidence file path in MC task
- `AdapterConfig.enabled` can be set to `true` after this point

**PRODUCTION** transition criteria (all must be true):

- `SANDBOX_VERIFIED` already achieved
- Securion review of adapter error handling, PII sanitization, and idempotency key
  implementation — no critical findings
- 30 consecutive days on stage Cloud Run with:
  - Zero `PLATFORM_INTERNAL_ERROR` alerts
  - Zero cross-market routing errors
  - `bilko_integration_request_total{status="error"}` < 1% of total requests
- CEO approval for production activation
- `AdapterConfig.enabled = true` in production DB (separate row from stage DB)

### 2.6 Secret Taxonomy

Runtime secrets follow the pattern `Bilko/{env}/{market}/{secret-name}`.

**Env first, not market first.** This ensures that all production secrets are under
`Bilko/production/` and can be granted/revoked as a unit for environment promotion.

```
Bilko/
  production/
    HR/
      STORECOVE_API_KEY
      STORECOVE_LEGAL_ENTITY_ID
      FINA_QES_CERTIFICATE         (Phase 2)
      EPOREZNA_CLIENT_SECRET       (Phase 2)
    RS/
      SEF_API_KEY                  (Phase 1S)
      LPFR_DEVICE_CERT             (Phase 2)
    BA_FED/
      CPF_API_KEY                  (Phase 1B — pending platform launch)
    BA_RS/
      UINO_API_KEY                 (Phase 1B — pending platform launch)
  stage/
    HR/
      STORECOVE_API_KEY
      STORECOVE_LEGAL_ENTITY_ID
    RS/
      SEF_API_KEY
    ...
  local/
    HR/
      STORECOVE_API_KEY            (developer sandbox credentials only)
    ...
```

**Secret resolution hierarchy:**

1. Runtime: GCP Secret Manager (current) — accessed via `SecretResolver` interface
2. Break-glass: Vaultwarden (`vault.basicconsulting.no`) — human access only, NOT runtime source
3. Local dev: `.env.local` file (`.gitignore`'d) — NEVER committed

```kotlin
// apps/api/src/main/kotlin/no/alai/bilko/adapter/SecretResolver.kt

/**
 * Abstracts secret retrieval behind a testable interface.
 *
 * Production implementation: GCP Secret Manager.
 * Test implementation: environment variables / in-memory map.
 *
 * Secret path convention: Bilko/{env}/{market}/{secret-name}
 */
interface SecretResolver {
    /**
     * Resolves a secret value by its canonical path.
     *
     * @param path e.g. "Bilko/production/HR/STORECOVE_API_KEY"
     * @return Secret value, or null if not found.
     * @throws AdapterException(AUTH_INVALID_CREDENTIALS) if path exists but value is empty/blank.
     */
    fun resolve(path: String): String?

    /**
     * Convenience method: builds canonical path and resolves.
     * @param env "production" | "stage" | "local"
     * @param market TaxJurisdiction enum value as string
     * @param secretName The specific secret name
     */
    fun resolve(env: String, market: String, secretName: String): String? =
        resolve("Bilko/$env/$market/$secretName")
}
```

**Vaultwarden is NOT the runtime secret source.** Vaultwarden is the human break-glass
vault for emergency access. Do not write Kotlin code that reads from Vaultwarden at
runtime. GCP Secret Manager is the runtime source.

### 2.7 Observability Mandate

Every adapter MUST emit the following for every network call:

**Structured log line (one per call):**

```
level=INFO market=HR integration=EINVOICE env=production org_id=<uuid>
  action=submit status=SUCCESS duration_ms=234 submission_id=<guid>
```

Required fields: `market`, `integration`, `env`, `org_id`. Optional but recommended:
`duration_ms`, `submission_id`, `attempt` (for retries).

**NEVER log:**

- OIB, PIB, JIB (tax IDs)
- IBAN
- `document_data` (invoice XML body)
- `api_key`, `api_secret`

Use `sanitizeForLog()` (pattern from `StorecoveHrFiskEInvoiceAdapter.kt` lines 24–59)
before any log write that touches a response body.

**Prometheus metrics (one counter per adapter):**

```kotlin
// apps/api/src/main/kotlin/no/alai/bilko/adapter/AdapterMetrics.kt

/**
 * Prometheus counter for all adapter network calls.
 *
 * Labels: market, integration, status (SUCCESS | ERROR | NOT_IMPLEMENTED | TIMEOUT)
 *
 * Example PromQL for HR e-invoice error rate:
 *   rate(bilko_integration_request_total{market="HR",integration="EINVOICE",status="ERROR"}[5m])
 *   /
 *   rate(bilko_integration_request_total{market="HR",integration="EINVOICE"}[5m])
 */
// bilko_integration_request_total{market, integration, status}
// bilko_integration_request_duration_seconds{market, integration, status}
```

Per-(market, integration) alert rule:

- Error rate > 10% over 5 minutes: PAGE (PagerDuty or Slack alert)
- Error rate > 25% over 1 minute: CRITICAL (adapter auto-disabled via `AdapterConfig`)

### 2.8 Adapter Versioning

Each adapter declares `val adapterVersion: String` (semantic version, e.g., `"1.0.0"`).
The corresponding `CountryPlugin` implementation declares the minimum adapter version
it requires:

```kotlin
// In PluginHR:
companion object {
    const val MIN_EINVOICE_ADAPTER_VERSION = "1.0.0"
}

// Startup check in DI.kt:
val adapter = StorecoveHrFiskEInvoiceAdapter()
require(semVer(adapter.adapterVersion) >= semVer(PluginHR.MIN_EINVOICE_ADAPTER_VERSION)) {
    "PluginHR requires EInvoiceAdapter >= ${PluginHR.MIN_EINVOICE_ADAPTER_VERSION}, got ${adapter.adapterVersion}"
}
```

Adapters are versioned independently of the `CountryPlugin`. Breaking changes to
an adapter interface (e.g., new required parameter in `submit()`) require a major
version bump and a coordinated plugin + adapter update.

### 2.9 Idempotency Requirements

**All submit-type methods in all adapters MUST include an idempotency key.**

The idempotency key format is adapter-specific, but the value MUST be derived
deterministically from the invoice or entity content — never a random UUID.

| Adapter              | Method     | Idempotency key derivation                                           |
| -------------------- | ---------- | -------------------------------------------------------------------- |
| EInvoiceAdapter (HR) | `submit()` | `SHA-256(invoice.id + invoice.invoiceNumber)` — matches Storecove D5 |
| EInvoiceAdapter (RS) | `submit()` | `SHA-256(invoice.id + invoice.invoiceNumber)` (same pattern)         |
| TaxFilingAdapter     | `submit()` | `SHA-256(filing.periodStart + filing.periodEnd + org.taxId)`         |
| QESSigningAdapter    | `sign()`   | `SHA-256(document.contentHash + signer.taxId)`                       |

**Rationale:** A network timeout after the platform receives the request but before
the response arrives will cause the client to retry. Without idempotency, this creates
a duplicate document. Storecove returns HTTP 409 on duplicate `document_id` (D2 in
`StorecovePayloadBuilder.wrap()` lines 420–436) — the pattern must be replicated.

---

## 3. Implementation Path

| Phase    | Task                                                         | Deliverable                                                     | Status              |
| -------- | ------------------------------------------------------------ | --------------------------------------------------------------- | ------------------- |
| Phase 0' | This ADR written to disk                                     | `ADR-019-INTEGRATION-ADAPTER-REGISTRY.md`                       | DONE                |
| Phase 1H | `AdapterException` + `AdapterErrorCode` formalized           | `adapter/AdapterException.kt` (already exists — verify package) | Verify existing     |
| Phase 1H | `AdapterConfig` Flyway migration (V20)                       | `V20__adapter_config.sql`                                       | BLOCKED BY Phase 2A |
| Phase 1H | `SecretResolver` interface + GCP impl                        | `adapter/SecretResolver.kt` + `GcpSecretResolver.kt`            | Phase 1H.4+         |
| Phase 1H | `AdapterRegistry` + `requireEnabled()` check                 | `adapter/AdapterRegistry.kt`                                    | Phase 1H.4+         |
| Phase 1H | Prometheus metrics wired in `StorecoveHrFiskEInvoiceAdapter` | `StorecoveMetrics.kt` (skeleton exists)                         | Phase 1H.2+         |
| Phase 1S | SEF RS EInvoiceAdapter                                       | `country/rs/SefRsEInvoiceAdapter.kt`                            | Post-HR GA          |
| Phase 1B | CPF BA-FED + UINO BA-RS stubs                                | `country/ba/Cpf*`, `country/ba/Uino*`                           | Post-RS GA          |

---

## 4. Consequences

### 4.1 Positive

- **Zero platform exception leakage.** `AdapterException` as the only crossing type means
  core services have one error handler for all 7 × 4 = 28 potential adapter instances.
- **Hot disable without redeploy.** `AdapterConfig.enabled = false` disables a broken
  adapter in < 1 minute (DB write). No restart required. Incident response time drops
  from minutes (redeploy) to seconds (DB update).
- **Observability from day one.** Every adapter emits standardized metrics. Error rate
  alerts fire before users report issues.
- **Secret hygiene.** Env-first taxonomy (`Bilko/{env}/{market}/{secret}`) makes
  environment promotion (stage → production) a structured operation, not an ad-hoc
  copy. Break-glass access is separated from runtime access.

### 4.2 Negative

- **Adapter scaffolding cost.** Each new adapter requires implementing the full
  interface contract, `AdapterConfig` rows, `SecretResolver` wiring, and Prometheus
  metrics. Estimate: 1–2 days for a new adapter in STUB state.
- **AdapterConfig is a deployment dependency.** The application fails at startup if
  `adapter_config` rows do not exist. Flyway V20 must run before the application
  version that adds `requireEnabled()` checks.

### 4.3 Risks

- **`AdapterConfig` DB unavailable.** If the database is unreachable, `requireEnabled()`
  fails, blocking all adapters. Mitigation: cache `AdapterConfig` in-memory at startup
  with a TTL of 5 minutes. Use cached state if DB is unreachable.
- **Metrics cardinality.** High `org_id` cardinality in metrics labels would cause
  Prometheus memory issues. The observability mandate specifies `org_id` in log lines,
  NOT in Prometheus labels. Labels are `market`, `integration`, `status` only — bounded
  cardinality.

---

## 5. References

| Reference                                        | Path                                                                                  | Lines   |
| ------------------------------------------------ | ------------------------------------------------------------------------------------- | ------- |
| `AdapterLifecycleState` enum (on disk)           | `apps/api/src/main/kotlin/no/alai/bilko/einvoice/EInvoiceTypes.kt`                    | 22–26   |
| `StorecoveErrorMapper` (error mapping reference) | `apps/api/src/main/kotlin/no/alai/bilko/country/hr/StorecoveHrFiskEInvoiceAdapter.kt` | 469–516 |
| `StorecoveMetrics` (metrics reference)           | `apps/api/src/main/kotlin/no/alai/bilko/country/hr/StorecoveHrFiskEInvoiceAdapter.kt` | 537–540 |
| PII sanitization reference                       | `apps/api/src/main/kotlin/no/alai/bilko/country/hr/StorecoveHrFiskEInvoiceAdapter.kt` | 24–59   |
| Idempotency key (D5) reference                   | `apps/api/src/main/kotlin/no/alai/bilko/country/hr/StorecoveHrFiskEInvoiceAdapter.kt` | 94–98   |
| `StorecovePayloadBuilder` (D2 document_id)       | `apps/api/src/main/kotlin/no/alai/bilko/country/hr/StorecoveHrFiskEInvoiceAdapter.kt` | 420–436 |
| ADR-016 §2.3 (lifecycle states — EInvoice)       | `docs/architecture/ADR-016-EINVOICE-ADAPTER.md`                                       | §2.3    |
| ADR-015 §2.4 (DI registration pattern)           | `docs/architecture/ADR-015-FOUR-JURISDICTION-PLUGIN.md`                               | §2.4    |
| Plan v3 §6 Task 0'4 acceptance criteria          | `~/system/specs/bilko-multi-market-architecture-plan-v3-2026-05-11.md`                | 279–290 |

---

## 6. Approval

**Status:** Accepted
**Unblocks:**

- Phase 1H: `AdapterConfig` Flyway V20 migration
- Phase 1H: `SecretResolver` GCP implementation
- Phase 1H: Prometheus metrics wiring in `StorecoveHrFiskEInvoiceAdapter`
- Phase 1S: SEF RS adapter scaffolding (knows the contract to implement against)
- Phase 1B: CPF/UINO BA adapter stubs

| Role                             | Sign                                  | Date       |
| -------------------------------- | ------------------------------------- | ---------- |
| Architecture Lead (Petter Graff) | Signed                                | 2026-05-11 |
| CEO (Alem Bašić)                 | Not required for registry pattern ADR | —          |

---

## 7. Document History

| Date       | Author       | Change                                            |
| ---------- | ------------ | ------------------------------------------------- |
| 2026-05-11 | Petter Graff | Initial — Phase 0' ADR consolidation (MC #100362) |
Architecture Decision Records

ADR-020: Backend Canonical — Deprecate api-kotlin

# ADR-020: Canonical Backend is `backend/` — Deprecate `apps/api-kotlin/`

**Status:** Accepted
**Date:** 2026-04-28
**Author:** ALAI, 2026
**Related:** ADR-009 (superseded), ADR-011, ADR-015, ADR-016, ADR-017, ADR-018, ADR-019

> **MAJOR PATH UPDATE (2026-04-29):** `backend/` → `apps/api/` (canonical Kotlin/Ktor location now). `apps/api-legacy/` → `.archive/api-legacy/`. The deprecation of `api-kotlin/` in this ADR was executed in MC #10034 (deleted as `apps/api-kotlin-abandoned`). See ADR-021.

---

## Context

### The Dual-Backend Incident

As of 2026-04-27, the Bilko repository contained two parallel, independent Kotlin/Ktor backends
serving identical purposes — an architectural anomaly discovered during forensic audit MC #9892.

**Timeline — git-verified (SHA + date + author):**

| SHA       | Date       | Author  | Event                                                                                                                                                             |
| --------- | ---------- | ------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `5f97eff` | 2026-03-04 | John AI | Earliest backup commit — `backend/` fully present with `build.gradle.kts`, package `no.alai.bilko`, Kotlin 2.3.0 / Ktor 3.4.0 / JVM 25                            |
| `e23ade3` | 2026-03-19 | Makinja | Security headers plugin and security audit added to `backend/`                                                                                                    |
| `6b76981` | 2026-04-10 | Makinja | CI fixes applied to `backend/`                                                                                                                                    |
| `6c71a79` | 2026-04-14 | Makinja | `apps/api-kotlin/` created — "Complete Kotlin/Ktor backend — Auth, Invoices, Clients, Expenses, Health" — package `io.bilko`, Kotlin 2.1.20 / Ktor 3.1.2 / JVM 21 |
| `f66ddec` | 2026-04-14 | Makinja | GCP Terraform + CI added (lands in both directories)                                                                                                              |
| `ee27c6b` | 2026-04-15 | Makinja | `apps/api-kotlin/` scaffold finalised — 10 feature modules, 17 source files, titled "migration scaffold"                                                          |

**Result:** `backend/` was first on 2026-03-04 (6 weeks before `apps/api-kotlin/`). A generic
builder agent (dispatched without Mehanik gate clearance) created `apps/api-kotlin/` on
2026-04-14 while `backend/` was already the active implementation. From 2026-04-15 onward,
`apps/api-kotlin/` received no further commits. `backend/` continued active development with
commits through 2026-04-23.

### Background — Why This Happened

CEO decision (2026-03-17, ALAI/CLAUDE.md) mandated migration of all products from Express/TS to
Kotlin/Ktor. Migration task MC #5125 ("Bilko backend: Express/TS → Kotlin/Ktor") was created but
not formally unblocked. The Phase 1 Track A execution document
(`docs/bookstack-sync/phase1-track-a-execution.md`, lines 241 and 299) designated
`apps/api-kotlin/` as the FUTURE migration target — explicitly stating:

> "Do NOT start Track B until MC #5125 unblocks. All Track B work goes into `apps/api-kotlin/`."

However, a generic builder agent was dispatched into `apps/api-kotlin/` on 2026-04-14 before
MC #5125 was formally unblocked and before Mehanik clearance was obtained. This created an
unauthorized parallel scaffold while the actual canonical domain implementation continued to grow
in `backend/`.

### State at Time of Discovery (MC #9892, 2026-04-27)

**`backend/`:**

- Package: `no.alai.bilko`
- Kotlin 2.3.0 / Ktor 3.4.0 / JVM 25
- 51 `.kt` source files + 3 test files
- Full domain: HR-FISK, SEF, EInvoice, AdapterException (14 codes), Koin DI, Redis, Apache PDFBox, Sentry, EmailService, SecurityHeaders, CORS, RateLimit
- All ADR-015 through ADR-019 reference `no.alai.bilko` paths inside `backend/`
- Last commit: 2026-04-23 (John, active)

**`apps/api-kotlin/`:**

- Package: `io.bilko`
- Kotlin 2.1.20 / Ktor 3.1.2 / JVM 21
- 17 `.kt` source files + 3 test files
- Skeleton only: Auth, DB tables, feature route scaffolds
- Missing: HR-FISK, SEF, EInvoice adapter interface, AdapterException, Koin DI, Redis, PDFBox, Sentry, EmailService, RateLimit, CORS
- Last commit: 2026-04-15 `ee27c6b` (Makinja, **13 days stale** at discovery)
- NOT deployed (confirmed by `docs/evidence/9386/verification.json` line 36 and `docs/evidence/9398/verify-cookie-fix.js` line 76)
- NOT referenced in any architecture document

### Prior Audit Gap

The preliminary architecture audit (2026-04-27) saw `apps/api-kotlin/` in the directory listing
and noted: _"need to confirm these are indeed empty/removed"_ — but did not follow through with a
`find` verification. The tool-first discipline (ZAKON NULA) required explicit verification before
any assumption about directory contents. The audit concluded without detecting the 17 active
Kotlin files, full auth module, and complete Gradle build in `apps/api-kotlin/`.

---

## Decision

**`backend/` is the canonical Kotlin/Ktor backend for Bilko.**

**`apps/api-kotlin/` is deprecated and will be archived as of MC #9894.**

All present and future development of the Bilko API occurs in `backend/`. The `io.bilko`
package namespace is abandoned. The `no.alai.bilko` namespace (established 2026-03-04) is
permanent for the Kotlin backend.

---

## Rationale

Three hard facts — all git-verified, zero assumptions:

**Fact 1 — Scale disparity (51 vs 17 files).**
`backend/` contains 51 Kotlin source files. `apps/api-kotlin/` contains 17. More critically,
the qualitative gap is larger than the count suggests: `backend/` contains every domain-specific
component (fiscal adapters, error registry, DI wiring, PDF generation, rate limiting, Sentry
telemetry). `apps/api-kotlin/` contains only the routing skeleton.

**Fact 2 — All ADR paths reference `backend/`.**
ADR-014 through ADR-019 — every architecture decision record written for this product — reference
`no.alai.bilko` paths inside `backend/`. ADR-019 was explicitly verified against
`backend/src/main/kotlin/no/alai/bilko/adapter/AdapterException.kt`. Zero architecture documents
reference `io.bilko` or `apps/api-kotlin/`. An ADR is a commitment. Reversing ADR-015 through
ADR-019 evidence chains to point at `apps/api-kotlin/` would be rework with no technical benefit.

**Fact 3 — Version inversion confirms direction of travel.**
`apps/api-kotlin/` is pinned to Kotlin 2.1.20 / Ktor 3.1.2 — the versions specified in the
original Phase 1 Track A scaffold spec. `backend/` runs Kotlin 2.3.0 / Ktor 3.4.0 / JVM 25 —
current as of 2026-04-28. This is not a coincidence: `apps/api-kotlin/` was scaffolded once to a
fixed spec and never updated. `backend/` was actively maintained and upgraded. The version delta
tells the entire story: one codebase is alive, the other is frozen at its creation point.

---

## Consequences

### For Lane B BLOCKER Tasks (#9852 / #9853 / #9854 / #9855)

All Lane B backend tasks are unblocked against `backend/` as the target. No work should be
directed to `apps/api-kotlin/`. Any task whose scope referenced `apps/api-kotlin/` or `io.bilko`
must be updated to reference `backend/` and `no.alai.bilko` before execution begins.

### For MC #5125 (Bilko Express → Kotlin Migration)

MC #5125 was the formal trigger for the Kotlin migration and the stated prerequisite for any
work in `apps/api-kotlin/`. With this ADR:

- `backend/` fulfills the Kotlin/Ktor migration requirement — the migration is structurally
  complete at the backend layer.
- MC #5125 may be closed (DONE) once BUILD-BLUEPRINT.md is updated (MC #9897) to document
  `backend/` as the canonical backend and the Express legacy (`apps/api-legacy/`) as
  deprecated.
- The specific Track A intent ("apps/api-kotlin/ is the migration landing zone") is superseded
  by this ADR. Track B work proceeds in `backend/` directly.

### For BUILD-BLUEPRINT.md

BUILD-BLUEPRINT.md §3 currently documents `apps/api/` (now `apps/api-legacy/`) as the backend
and makes no mention of either `backend/` or `apps/api-kotlin/`. This is pre-migration
documentation. MC #9897 (BUILD-BLUEPRINT update) must:

1. Replace the backend section to reference `backend/` (package `no.alai.bilko`, Kotlin 2.3.0,
   Ktor 3.4.0)
2. Document the directory structure: `backend/` lives outside Turborepo workspace (independent
   Gradle + GCP Cloud Run deploy)
3. Update build commands to `cd backend && ./gradlew run`
4. Mark `apps/api-legacy/` as deprecated with a pointer to its decommission timeline

### For DEPLOY-MAP.md

DEPLOY-MAP.md currently records bilko-api as "Manual only (Kotlin TBD)". After Dockerfile work
(MC #9898), this entry must be updated to reflect: source = `backend/`, build = Docker
multi-stage, deploy target = GCP Cloud Run via `gcp-deploy.yml`.

### Negative Consequences

1. **One-time porting effort.** `apps/api-kotlin/` contains a more rigorous implementation of
   refresh token rotation (`features/auth/AuthRepository.rotateRefreshToken()`). This pattern
   should be reviewed against `backend/` before archiving — MC #9895 covers this comparison.

2. **Track A plan invalidated.** The Phase 1 Track A execution document explicitly designated
   `apps/api-kotlin/` as the future target. That plan is now superseded. The document must be
   annotated with a pointer to this ADR to prevent future agents from acting on stale guidance.

3. **Feature-sliced architecture not adopted.** `apps/api-kotlin/` used a feature-sliced layout
   (`features/auth/`, `features/invoices/`). `backend/` uses a layered layout (`routes/`,
   `services/`, `auth/`). The architectural pattern debate is resolved in favor of the layered
   approach by inertia — 51 files are not being reorganized. This is a deliberate trade-off:
   stability over structural preference.

---

## Lessons Learned

### Lesson 1 — Premature Scaffold Incident

The Track A document stated that `apps/api-kotlin/` should NOT be built until MC #5125 formally
unblocked. A generic builder agent built it anyway (2026-04-14). This is the root cause of the
entire incident. The constraint in writing was not sufficient — it required a hard gate (Mehanik)
enforcing it programmatically.

**Fix:** Mehanik pre-dispatch gate (activated 2026-04-25, MC #9274) is the structural remedy.
No backend task may be dispatched without Mehanik clearance that checks: task MC ID exists,
BUILD-BLUEPRINT.md read, scope ceiling verified, CI green if deploy.

### Lesson 2 — "Probably Empty" Hallucination in Audit

The preliminary audit saw `apps/api-kotlin/` in the `ls` output and wrote "need to confirm these
are indeed empty/removed" — then concluded without verifying. The correct tool-first discipline
(ZAKON NULA) required a `find apps/api-kotlin/src -name "*.kt"` call before any assumption about
directory state. A single verification command would have revealed 17 Kotlin files and flagged
the duplicate immediately.

**Fix:** Any directory flagged as "need to confirm" in an audit is an open obligation, not a
closed finding. Audits are not complete until all flagged items are machine-verified.
Post-audit review by a second agent (MC #9892 forensic) should be standard for architecture-level
audits on active codebases.

### Lesson 3 — ZAKON NULA Violation (Tool-First)

John dispatched the backend-dev agent to `apps/api-kotlin/` without reading BUILD-BLUEPRINT.md,
without running `node ~/system/tools/mc.js show 5125`, and without querying the existing project
structure. Had BUILD-BLUEPRINT.md been read first, it would have been apparent that `backend/`
was the active implementation and that `apps/api-kotlin/` was the designated (but not yet active)
future target — a distinction requiring a human (Alem) decision, not an agent initiative.

**Fix:** ZAKON NULA (CLAUDE.md) is enforced by the Mehanik pre-dispatch hook. The hook requires
tool-verified project state before clearing any build dispatch.

### Lesson 4 — ZAKON #1 Violation (Specialist Routing)

MC #5125 is a complex backend migration (Express/TS → Kotlin/Ktor, domain logic, multi-market
fiscal adapters, DI framework selection). This requires a **specialist** — CodeCraft (Petter Graff
/ Hadi Hariri), not a generic builder agent. CLAUDE.md §5 is unambiguous: "Never generic
builder/minion. Route to the right company." Generic agents lack the architectural judgment to
navigate this class of decision (where does the backend live? which package namespace? which
version pins?).

**Fix:** Complex backend migrations are categorically CodeCraft work. If the specialist routing
table in CLAUDE.md is unclear for a given task, the correct action is to ask John, not to default
to a generic pool. The Mehanik gate now enforces specialist routing as part of its clearance
criteria.

---

## Migration Path

The following tasks (C2–C6, MC #9894–#9898) execute the deprecation and consolidation. All tasks
have Mehanik-cleared MC IDs. Sequencing matters: C2 (archive) must complete before C3 (port
auth) to avoid confusion about which directory to edit.

### C2 — Archive `apps/api-kotlin/` (MC #9894)

**Owner:** CodeCraft | **Effort:** S (1h)

1. Rename `apps/api-kotlin/` to `apps/api-kotlin-abandoned/`.
2. Add `README.md` at the root of the renamed directory:
   ```
   DEPRECATED 2026-04-28 — see ADR-020
   This directory is the abandoned migration scaffold created 2026-04-14 to 2026-04-15.
   The canonical Kotlin backend is /backend/ (no.alai.bilko, Kotlin 2.3.0, Ktor 3.4.0).
   Do not edit this directory. It will be deleted after 2026-05-28.
   ```
3. Verify `turbo.json` and root `package.json` workspaces do NOT include `apps/api-kotlin` or
   `apps/api-kotlin-abandoned` (Turborepo workspace scope must not resolve against it).
4. Annotate `docs/bookstack-sync/phase1-track-a-execution.md` lines 241 and 299 with:
   `[SUPERSEDED by ADR-020 — apps/api-kotlin abandoned, backend/ is canonical]`.

### C3 — Port Auth Improvements to `backend/` (MC #9895)

**Owner:** CodeCraft | **Effort:** M (4h)

Compare `apps/api-kotlin-abandoned/features/auth/AuthRepository.kt` (specifically
`rotateRefreshToken()` and the ThreadLocal side-channel pattern) against
`backend/src/main/kotlin/no/alai/bilko/auth/AuthService.kt`. If the abandoned version is more
rigorous, port the improvement. Do not port file structure or package names.

Scope: auth only. No feature modules, no table objects, no routing changes.

### C4 — Update BUILD-BLUEPRINT.md (MC #9897)

**Owner:** CodeCraft | **Effort:** S (2h)

See Consequences section above for mandatory content. In addition, add an explicit architectural
note: "`backend/` lives outside the Turborepo workspace by design. It is a standalone Gradle
project with its own GCP Cloud Run deploy pipeline. Do not move it inside `apps/`."

### C5 — Add Dockerfile to `backend/` + Update DEPLOY-MAP.md (MC #9898)

**Owner:** FlowForge | **Effort:** M (4h)

1. Port `apps/api-kotlin-abandoned/Dockerfile` (JVM 21, multi-stage, non-root user, health check)
   to `backend/Dockerfile`. Upgrade base image from JVM 21 to JVM 25 (matching `backend/` JVM
   target).
2. Verify fat JAR output name: `bilko-api.jar` (check `build.gradle.kts` shadowJar config).
3. Local build validation: `docker build -t bilko-api-test ./backend` must succeed.
4. Update DEPLOY-MAP.md bilko-api entry: source = `backend/`, Dockerfile = `backend/Dockerfile`,
   deploy = GCP Cloud Run via `gcp-deploy.yml`.

### C6 — Proveo Verification (MC #9898 gate, Proveo)

**Owner:** Proveo (Angie Jones) | **Effort:** S (2h)

Acceptance criteria:

1. `docker build -t bilko-api ./backend` exits 0.
2. `docker run --rm -p 8080:8080 bilko-api` starts and responds to `GET /health` with HTTP 200.
3. `apps/api-kotlin/` directory no longer exists in repo root (renamed per C2).
4. `turbo.json` workspaces grep returns no match for `api-kotlin`.
5. `grep -r "io.bilko" backend/` returns no matches (no namespace contamination).

---

## References

- **MC #9892** — Forensic audit: dual Kotlin backend root-cause analysis
- **MC #9894** — C2: Archive `apps/api-kotlin/`
- **MC #9895** — C3: Port auth improvements to `backend/`
- **MC #9897** — C4: Update BUILD-BLUEPRINT.md
- **MC #9898** — C5/C6: Dockerfile + DEPLOY-MAP.md + Proveo verify
- **MC #5125** — Bilko backend migration: Express/TS → Kotlin/Ktor (to be closed after MC #9897)
- **ADR-015** — Four-Jurisdiction Plugin Architecture (references `no.alai.bilko`)
- **ADR-016** — E-Invoice Adapter and UBL 2.1 Canonical Model (references `no.alai.bilko`)
- **ADR-017** — RLS Multi-Tenancy (references `no.alai.bilko`)
- **ADR-018** — Market Locale Separation (references `no.alai.bilko`)
- **ADR-019** — Integration Adapter Registry (explicitly verified against `backend/` path)
- **docs/bookstack-sync/phase1-track-a-execution.md** — Phase 1 Track A intent document (lines 241, 299 superseded by this ADR)
- **Forensic reports** — `/tmp/bilko-dual-backend-da.md`, `/tmp/bilko-dual-backend-petter.md`

---

## Approval

**Accepted:** 2026-04-28
**Executed by:** ALAI, 2026
**Execution tasks:** MC #9894, #9895, #9897, #9898
Architecture Decision Records

ADR-021: Bilko Blueprint Section 15 Realignment

# ADR-021: Bilko Blueprint Section 15 Realignment

Status: Accepted
Date: 2026-04-29
Authors: ALAI (CEO Alem Basic, AI Director John)
Context: MC #10034 Phases 6-7

## Context

Bilko had drifted from ALAI Universal Blueprint Section 15. Audit revealed: 384 dirty WT entries
(now archived), 29 unmerged branches (now archive tags), 3 sibling worktree clones, backend at
`backend/` (blueprint expects `apps/api/`), country packages named `country-*` (blueprint expects
`domain-*`), root contained ~33 entries (blueprint allows ~10).

CEO approved scorched-earth-lite cleanup 2026-04-28. PR #1 + PR #2 fixed CI prerequisite.
FlowForge executed Phases 0-5.

## Decision

Full Section 15 realignment in Phases 6-7:

- DELETE: `apps/api-kotlin-abandoned/`, `scratch-api/` (untracked build artifacts)
- MOVE: `backend/` → `apps/api/` (Kotlin/Ktor canonical backend)
- RENAME: `apps/api/` (Express) → `apps/api-express/` (Express active, migration pending)
- ARCHIVE: `apps/api-legacy/` → `.archive/api-legacy/`
- SKIP: `landing/` → `apps/landing/` — no git-tracked files in landing/ (only node_modules, out)
- MOVE: `cloudbuild.yaml` → `infrastructure/gcp/cloudbuild.yaml`
- MOVE: `docker-compose.test.yml` → `infrastructure/docker/docker-compose.test.yml`
- MOVE: `.ci/config.yml` → `scripts/ci/alai-ci-config.yml`
- MOVE: `ci/stubs/` → `tools/ci-stubs/`
- MOVE: `audit-2026-04-28/` → `docs/audits/2026-04-28/`
- MOVE: `COMPLAINT-REPORT-2026-02-20.md` → `docs/audits/`
- MOVE: `DEPLOY-MAP.md` → `docs/DEPLOY-MAP.md`
- MOVE: `figma-plugin/` → `tools/figma-plugin/`
- MOVE: `design/` → `docs/design/design/` (double-nested by git mv into existing docs/design/)
- MOVE: `branding/` → `docs/branding/branding/` (double-nested similarly)
- RENAME: `packages/country-{ba,ba-fed,ba-rs,hr,rs}` → `packages/domain-{ba,ba-fed,ba-rs,hr,rs}`

## Path Reference Updates (Phase 7)

All active code paths updated:

- `infrastructure/docker/Dockerfile.api` — updated `apps/api` → `apps/api-express`, `country-*` → `domain-*`
- `infrastructure/docker/Dockerfile.api-legacy` — updated `apps/api-legacy` → `.archive/api-legacy`, `country-*` → `domain-*`
- `apps/web/Dockerfile` — updated `apps/api-legacy` → `apps/api-express`, `packages/country-*` → `packages/domain-*`
- `apps/api-express/package.json` — renamed to `@bilko/api-express`, deps `@bilko/country-*` → `@bilko/domain-*`
- `apps/api-express/vitest.config*.ts` — all path aliases updated to `domain-*`
- `apps/api-express/src/**/*.ts` — all `@bilko/country-*` imports → `@bilko/domain-*`
- `apps/api-express/tests/**/*.ts` — all `@bilko/country-*` imports → `@bilko/domain-*`
- `packages/domain-*/src/index.ts`, `README.md` — all self-references updated
- `packages/README.md` — updated `country-*` → `domain-*`
- `src/shared/ubl/*.ts` — comment references updated
- `.github/workflows/gcp-deploy.yml.disabled` — path filter `backend/**` → `apps/api/**`
- `BUILD-BLUEPRINT.md` — Section 2 (tech stack), Section 3 (project structure), Section 8 (rules)
- `CLAUDE.md` (root) — project structure and backend status sections
- `.gitleaks.toml` — updated `apps/api-legacy` → `.archive/api-legacy`
- `package-lock.json` — deleted and regenerated clean (no stale `packages/country-*` entries)
- `package.json` (root) — workspaces updated, lint-staged scoped to `apps/web` and `apps/e2e` only

## Consequences

- 17 unmerged branches archived as tags; require cherry-pick MC if reactivated
- All path refs updated atomic with their respective move commits
- `package-lock.json` regenerated clean (lockfile portability ZAKON compliant)
- `gcp-deploy.yml` workflow disabled (`.disabled` suffix); re-enable deferred to Phase 8 validation
- Git history preserved via `git mv` throughout
- `lint-staged` scoped to `apps/web/**` + `apps/e2e/**` only (ESLint configs in `packages/domain-*`
  reference a root `tsconfig.json` that does not exist; scoping prevents false failures)

## Acceptable Remaining Refs

The following historical/comment references were intentionally left as-is:

- `docs/evidence/*/verification.json` — immutable point-in-time MC evidence records
- `apps/api/src/.../CountryService.kt:11` — Kotlin comment documenting what was replaced

## Recovery

- `git checkout archive/bilko-2026-04-28-pre-cleanup` for full pre-cleanup state
- `git stash apply stash@{0}` for the 688 dirty entries (sha 8f18dc36)
- Individual branch restoration: `git checkout archive/2026-04-28/<branch>`

## Deviations from Blueprint

- `RUNBOOK.md` kept in root (mandatory per Section 15 line 1074)
- `BUILD-BLUEPRINT.md`, `CLAUDE.md`, `CHANGELOG.md`, `PIPELINE.md`, `package.json`, `turbo.json`,
  `settings.gradle.kts`, `.env.example*` also kept in root per Section 15
- `packages/database` (current) maps to blueprint `packages/database`
- `packages/core` (Bilko-specific shared code, no direct blueprint analog) kept as-is
- `design/` and `branding/` ended up double-nested (`docs/design/design/`, `docs/branding/branding/`)
  due to `git mv` into pre-existing `docs/design/` and `docs/branding/` subdirectories
- `apps/api-express/` is a deviation from blueprint (which expects only `apps/api/` Kotlin) — this
  is the active Express migration target, kept alive until MC #5125 migration completes

## References

- ALAI-UNIVERSAL-BLUEPRINT.md Section 15
- MC #10027 (closed, superseded by #10034)
- MC #10034 (this work)
- MC #10044 (coverage glob fix, bundled into Phase 5d)
- ADR-020: `docs/architecture/ADR-020-BACKEND-CANONICAL-DEPRECATE-API-KOTLIN.md`

Reviews & Reports

Architecture reviews, team reports, validation

Reviews & Reports

Petter Graff Architecture Review

Drop Fintech Platform — Architecture Review

Reviewer: Petter Graff (Software Architect, CTO Pratexo) Review Date: 2026-02-22 Subject: Drop — Norwegian fintech payment application (remittance + QR payments) Model: PSD2 pass-through (AISP/PISP) — Drop never holds customer funds

NOTE (2026-03-03): This review was conducted against the pre-ADR-014 codebase. SQLite/dual-driver findings are historical — resolved by ADR-014 (PostgreSQL-only + Drizzle ORM).


1. Overall Assessment

Grade: C+ (6.5/10)

Drop has a solid conceptual foundation for a PSD2-regulated fintech application. The architectural documentation is comprehensive, the compliance framework is thoughtfully designed, and the core technical decisions (monolith-first, dual-database abstraction, BankID-only auth) are appropriate for an MVP. However, critical production-readiness gaps exist across security, data integrity, and operational resilience that would prevent regulatory approval and create significant business risk.

What I see here: An engineering team that understands fintech compliance requirements at a documentation level but has not yet built the operational muscle memory to implement them correctly. The Vault Squad analysis is accurate — you have 17 CRITICAL findings that must be fixed before processing a single real transaction.

This is not a failing grade — it's a realistic early-stage fintech build. But you're not production-ready, and pretending otherwise would be dangerous.


2. What's Done Well

2.1 Regulatory Awareness

2.2 Security Fundamentals

2.3 AML/Compliance Framework

The transaction monitoring module (transaction-monitor.ts) has 5 rule types:

Problem: It's never called from any API route (Vault Squad finding BE-C2). Dead code doesn't count.

2.4 Documentation Quality

C4 diagrams, ADRs, and HLD documents are better than 80% of fintech startups. Architecture is understandable, decisions documented with rationale, trade-offs acknowledged.


3. Critical Improvements (Production Blockers)

3.1 Authentication Catastrophe

Finding Impact Source
JWT secret defaults to hardcoded "dev-secret-change-in-production" Anyone can forge tokens if env var missing auth.ts:8
No OIDC state validation CSRF on authentication flow bankid.ts:80-108
No OIDC nonce validation ID token replay attacks bankid.ts:93-101
Cookie missing Secure flag Tokens sent over HTTP in plaintext auth.ts:206

Risk: Complete account takeover possible.

Fix:

// Startup validation
if (process.env.NODE_ENV === 'production' &&
    process.env.JWT_SECRET === 'dev-secret-change-in-production') {
  throw new Error('FATAL: Production requires real JWT_SECRET');
}

// BankID callback — validate state + nonce
const storedState = cookies.get('bankid_state');
const storedNonce = cookies.get('bankid_nonce');
if (state !== storedState) throw new Error('Invalid state');
if (idToken.nonce !== storedNonce) throw new Error('Invalid nonce');

// Cookie flags
cookies.set('drop_token', token, {
  httpOnly: true,
  secure: true,
  sameSite: 'strict',
  maxAge: 1800  // 30min, not 7 days
});

3.2 PISP Call Inside Database Transaction

External HTTP call (30s timeout) while holding database write lock. Under load → deadlocks, data loss, double-charging.

Fix: Two-phase commit:

// Phase 1: Create pending transaction (fast)
const txId = await createPendingTransaction(...);

// Phase 2: Initiate payment OUTSIDE transaction
try {
  const result = await initiateRemittance(...);
  await updateTransactionStatus(txId, result.status);
} catch (error) {
  await updateTransactionStatus(txId, 'failed');
}

3.3 TEXT Timestamps Everywhere

All 30+ timestamp columns use TEXT storing ISO 8601 strings. No timezone awareness, no native date math, ISO 20022 non-compliant.

Fix: Migrate to TIMESTAMPTZ in PostgreSQL migration.

3.4 No Database Connection Management

PostgreSQL pool has no timeout configuration. One slow query exhausts entire pool → cascading failure.

Fix:

const pool = new Pool({
  connectionString: process.env.DATABASE_URL,
  max: 20,
  connectionTimeoutMillis: 5000,
  idleTimeoutMillis: 30000,
  statement_timeout: 30000,
  query_timeout: 30000,
});

3.5 Zero Transaction Monitoring

AML monitoring code exists but is never called from any API route. Hvitvaskingsloven §7 requires real-time monitoring.

Fix: Wire checkTransaction() before PISP initiation. Block critical alerts.

3.6 Missing Open Banking Consent Lifecycle

No ob_consents table. Can't enforce 90-day consent renewal, access frequency limits, or consent revocation.

Fix: Create ob_consents table with consent tracking, expiry, access counting.


4. Strategic Improvements (Scale)

4.1 Network Topology & Failure Modes

No documented failure scenarios or retry strategies. Need failure domain mapping for BankID, PISP, PostgreSQL.

4.2 No Double-Entry Bookkeeping

Single-entry transaction model. Finanstilsynet will ask for general ledger proving credits = debits.

Fix: Add transaction_legs table with debit/credit entries per transaction.

4.3 Missing Composite Indexes

High-frequency queries will full-table-scan at scale.

Fix: Add composite indexes: (user_id, created_at DESC) on transactions, audit_log, notifications.

4.4 No Table Partitioning Strategy

audit_log will reach 90M rows in 5 years. Plan partitioning by month.


5. Architecture Smell Flags

5.1 Rate Limiting Writes to PostgreSQL

Every request writes to PostgreSQL for rate limiting. Should use in-memory (Map/Redis).

5.2 Fire-and-Forget Audit Logging

Critical actions (login, payment, consent) use async audit — should be synchronous for regulatory compliance.

5.3 No Circuit Breaker for External APIs

If PISP API goes down, every request waits 30s then fails. Need circuit breaker (fail fast after 5 failures, retry after 60s).


6. Questions Before Sign-Off

Security & Auth

  1. JWT key rotation process?
  2. BankID downtime fallback?
  3. Session hijacking detection (2 IPs simultaneously)?
  4. Direct database access logging?

Data Integrity

  1. DST transition handling with TEXT timestamps?
  2. Exchange rate locking between quote and execution?
  3. Transaction status if PISP call times out?
  4. Client-generated idempotency key security?

Compliance

  1. How to enforce 90-day AISP consent expiry?
  2. Who reviews AML alerts and how fast?
  3. STR filing integration with Økokrim?
  4. GDPR data export time for user request?

Operations

  1. Database backup RPO/RTO?
  2. Zero-downtime schema migration strategy?
  3. PagerDuty alert rules?
  4. AWS eu-north-1 failover plan?

7. Priority Roadmap

Period Focus Priority
Month 1-2 Security hardening (auth, CSRF, PISP fix, DB timeouts, AML wiring) CRITICAL
Month 3-4 Data integrity (timestamps, ob_consents, double-entry, indexes) HIGH
Month 5-6 Operational resilience (circuit breakers, Multi-AZ, DR runbook) HIGH
Month 7+ Scale preparation (Redis rate limit, read replicas, monitoring) MEDIUM

Final Verdict

"Fintech is not about having fancy architecture diagrams — it's about operational discipline. Every timeout needs a value. Every transaction needs an audit log. Every external call needs a circuit breaker. These aren't optional extras — they're the difference between a production system and a demo. You're 60% there. The remaining 40% is where most fintechs fail."

— Petter Graff, CTO Pratexo

Reviews & Reports

Architecture Validation Report

Architecture Docs Validation Report

Date: 2026-02-21 Scope: All 41 architecture docs in docs/architecture/ Method: 3 critic agents (code, consistency, completeness) + 1 validator agent Source of truth: Actual source code in src/drop-api/, src/drop-app/, src/drop-mobile/


Summary

Metric Count
Total findings submitted 104
Confirmed (real issues) 70
Rejected (false positives) 34
Docs affected 34 of 41

Severity Breakdown

Severity Count Description
HIGH 21 Factual errors, security gaps, regulatory compliance issues
MEDIUM 37 Incorrect details, cross-doc inconsistencies, missing coverage
LOW 12 Minor naming issues, broken cross-refs, cosmetic

Root Cause Clusters

Many findings share a common root cause. Fixing the root cause resolves multiple findings at once:

# Root Cause Findings Fix
1 Next.js version: 16 to 15 CODE-001 thru CODE-004, CODE-030, CODE-042 (6) Global find-replace "Next.js 16" to "Next.js 15"
2 JWT lifetime: 24h/7d to 7d uniform CODE-011, CODE-012, CODE-013, CONS-001, CONS-002, CONS-003 (6) Replace all "24h (web) / 7d (mobile)" with "7d (all clients)"
3 SameSite: strict to Lax CODE-009, CODE-010, CODE-028, CONS-004 (4) Replace "sameSite=strict" to "sameSite=Lax"
4 middleware.ts to middleware/*.ts CODE-015, CODE-025, CODE-026 (3) Fix all "middleware.ts" references to actual file paths
5 Deprecated 410 endpoints still documented CODE-045, COMP-002, CODE-031, CONS-014 (4) Remove/update email+password flows, document 410s
6 Amount units: NOK to ore in DB CODE-008, CONS-019, COMP-020 (3+) Clarify DB stores ore, API converts to/from NOK
7 Deployment: aspirational vs actual CODE-005, CODE-024, CONS-007, CONS-008 (4) Mark AWS/blue-green as planned; document Docker Compose as current
8 Demo mode undocumented CODE-019, CODE-020, CODE-034, CODE-047, COMP-001 (5) Document demo-login flow and SEED_DEMO override
9 Mobile storage: SecureStore to AsyncStorage CODE-014, CONS-005 (2) Fix to AsyncStorage (expo-secure-store is devDep)
10 Data classification scheme mismatch CONS-006 (1) Unify to one taxonomy across security + data architecture

HIGH Severity Findings (21)

H1. Next.js Version Wrong (6 docs)

IDs: CODE-001, CODE-002, CODE-003, CODE-004, CODE-030, CODE-042 Docs: component-overview, system-context, container-diagram, deployment-architecture, ADR-011 Claim: "Next.js 16" Reality: next: ^15.5.12 (src/drop-app/package.json:24) Fix: Replace all "Next.js 16" with "Next.js 15"

H2. Deployment Architecture Aspirational (2 docs)

IDs: CODE-005, CODE-024 Doc: deployment-architecture.md Claim: AWS App Runner, ECR, RDS, Secrets Manager, Fly.io staging Reality: Only docker-compose.yml and docker-compose.production.yml exist. No fly.toml, no apprunner.yaml, no CI/CD. Fix: Mark deployment targets as planned. Document current Docker Compose setup.

H3. Dockerfile 4 Stages, Not 3

ID: CODE-006 Doc: deployment-architecture.md Claim: 3-stage build (deps, builder, runner) Reality: 4 stages: deps, test, builder, runner. Test stage is mandatory quality gate. Evidence: src/drop-app/Dockerfile:15 (test), :48 (builder), :81 (runner) Fix: Document 4-stage pipeline. Highlight mandatory test gate.

H4. Build Tools in Production Image

ID: CODE-007 Doc: deployment-architecture.md Claim: "No build tools in final image" Reality: Runner stage installs python3, make, g++ (Dockerfile:82) Fix: Update doc AND flag as security concern.

H5. Amount Units: NOK vs ore

ID: CODE-008 Doc: flow-qr-payment.md Claim: Amount in response is NOK (e.g., amount: 129) Reality: DB stores ore. nokToOre() at transactions.ts:13. Seed data: 4500000 ore = 45000 NOK. Fix: Document dual representation: API accepts/returns NOK, DB stores ore.

H6. JWT Lifetime Wrong (5 docs)

IDs: CODE-011, CODE-012, CODE-013, CONS-001, CONS-002, CONS-003 Docs: flow-login-authentication, data-architecture, ADR-004, ADR-007 Claim: "24h (web), 7d (mobile)" Reality: signToken(payload, expiresIn='7d') -- uniform 7d. No platform branching. (auth.ts:42) Fix: Replace all "24h web / 7d mobile" with "7d (all clients)"

H7. Data Classification Scheme Mismatch

ID: CONS-006 Docs: data-architecture.md vs security-architecture.md Claim: data-arch uses CRITICAL/HIGH/MEDIUM/LOW; security uses CRITICAL/RESTRICTED/CONFIDENTIAL/INTERNAL/PUBLIC Fix: Adopt one taxonomy across both docs.

H8. Registration Flow is BankID-Only

ID: CONS-014 Docs: component-overview.md, flow-registration-onboarding.md Claim: "4 steps (info, OTP, PIN, success)" Reality: /register returns 410. Users auto-created on first BankID login (bankid.ts:findOrCreateUser). Fix: Update to BankID auto-registration.

H9. Demo Mode Architecture Undocumented

ID: COMP-001 Evidence: auth.ts:131-158 (demo-login), mode.ts (isDemoMode), payments.ts (demo branching) Fix: Create flow-demo-mode.md or add section to existing auth docs.

H10. Token Refresh + 410 Endpoints Undocumented

ID: COMP-002 Evidence: auth.ts:201-210 (POST /refresh), auth.ts:109-128 (three 410 endpoints) Fix: Add refresh flow and deprecated endpoints table.

H11. GDPR Endpoints Undocumented

ID: COMP-003 Evidence: user.ts:132-360 -- POST /objection, /rectification, /restriction Claim in docs: "Manual process" (data-lifecycle.md) Reality: Fully implemented API endpoints with audit logging. Fix: Update data-lifecycle.md. Create GDPR rights matrix.

H12. Withdrawal/Angrerett Flow Undocumented

IDs: COMP-004, COMP-005 Evidence: withdrawal.ts creates withdrawal_requests table at runtime. Fix: Create flow-withdrawal.md. Add table to schema docs.

H13. Error Handling Chain Undocumented

ID: COMP-007 Evidence: error-handler.ts, Sentry (captureError), alerts. app.ts:50 mounts globalErrorHandler. Fix: Add observability section to docs.

H14. Merchant = Admin (Security Gap)

ID: COMP-013 Evidence: admin.ts:14-16 -- isAdmin(role) { return role === 'merchant' } Impact: Any merchant can access audit logs, screening, and file STRs. Fix: Document RBAC model. Flag for security review.

H15. Transaction Reconciliation Missing

ID: COMP-024 Evidence: QR payments INSERT as 'completed' immediately (transactions.ts:459). Remittances as 'processing' with no webhook/polling. Fix: Document the gap. Note production needs async reconciliation.


MEDIUM Severity Findings (37)

ID File Issue Fix
CODE-009 security-architecture.md SameSite=strict, actual Lax Change to Lax
CODE-010 flow-login-authentication.md Cookie: 24h strict, actual 7d Lax Fix both values
CODE-014 security-architecture.md SecureStore, actual AsyncStorage Fix storage name
CODE-015 security-architecture.md middleware.ts, actual middleware/auth.ts + rate-limit.ts Fix file paths
CODE-016 container-diagram.md merchantMiddleware "extends auth", actually independent Fix description
CODE-018 deployment-architecture.md JWT_SECRET "cwd hash", actual static string Fix fallback desc
CODE-019 flow-login-authentication.md Demo email: amir@example.com, actual demo@example.test Fix email
CODE-020 flow-login-authentication.md Demo calls POST /login, actual POST /demo-login (no creds) Rewrite demo section
CODE-021 container-diagram.md Rate limit missing per-user limit (3 req) Add dual limits
CODE-023 ADR-012 vs deployment-arch Blue/green contradiction Make consistent
CODE-025 database-design.md middleware.ts:11, actual middleware/rate-limit.ts:11 Fix path
CODE-026 data-architecture.md Rate limit cleanup "every check", actual every 100 checks Fix frequency + path
CODE-027 data-architecture.md "6 tables" but lists 7 Fix count
CODE-028 Backend LLD correct, security-arch wrong on SameSite Fix security-arch
CODE-038 flow-qr-payment.md Merchant ID regex doesn't match seed data Relax format spec
CODE-043 flow-login-auth.md Cross-refs list deprecated endpoints Update to current
CONS-004 Frontend vs backend LLD SameSite contradiction Fix frontend LLD
CONS-005 Security vs backend LLD SecureStore vs AsyncStorage Fix security-arch
CONS-007 ADR-012 vs deployment-arch Health check: /api/health vs /v1/health Standardize
CONS-008 ADR-012 vs deployment-arch Blue/green contradiction (dup of CODE-023) Make consistent
CONS-009 ADR-008 vs container-diagram Rate limiting "in-memory", actual DB-backed Fix ADR-008
CONS-011 data-architecture.md "6 tables" header, 7 listed (dup of CODE-027) Fix count
CONS-025 Bank linking vs open banking AISP consent: 180d, should be 90d per PSD2 Use 90d (regulatory)
CONS-019 flow-qr-payment.md Fee "charged to merchant", user pays total Clarify fee model
COMP-006 DATABASE-SCHEMA.md Missing otp_codes table reference Document or remove dead code
COMP-008 All docs Feature flags: 8 flags, no mapping doc Create feature-flags.md
COMP-009 security-architecture.md AML rules: 5 specific rules undocumented Add detection rules
COMP-010 flow-remittance.md Missing /summary and /:id/receipt endpoints Add to flow
COMP-012 All docs Middleware chain undocumented Add request lifecycle
COMP-014 flow-qr-payment.md HMAC verification is optional, not mandatory Clarify + security note
COMP-016 All docs Data retention cron undocumented Add to data-lifecycle
COMP-020 flow-remittance.md PSD2 disclosure endpoint undocumented Add /disclosure to sequence
CODE-033 deployment-architecture.md SERVICE_MODE "mock", actual "demo" Fix value
CODE-034 flow-login-auth-backend.md Demo mode env: NEXT_PUBLIC..., actual DROP_MODE Fix env var
CODE-045 flow-login-auth.md vs backend Frontend describes email/password as active Update frontend LLD

LOW Severity Findings (12)

ID File Issue
CODE-030 ADR-011 Next.js 16 to 15
CODE-031 component-overview.md Registration 4-step, actual BankID-only
CODE-032 migration-strategy.md Cross-ref path: ../lld/ should be ../hld/
CODE-035 audit-architecture.md idx_audit_log_timestamp index doesn't exist
CODE-036 security-architecture.md bcrypt file ref (actually correct)
CODE-040 container-diagram.md "Offline-capable" contradicts component-overview "No offline"
CODE-041 database-design.md idx_tx_idempotency breaks naming convention
CONS-010 component-overview vs system-context Duplicate doc ID "HLD-001"

Batch 1: Global Find-Replace (resolves ~20 findings)

  1. "Next.js 16" to "Next.js 15" (all docs)
  2. "24h (web)" / "7d (mobile)" to "7d (all clients)" (all docs)
  3. "sameSite=strict" to "sameSite=Lax" (all docs)
  4. "middleware.ts" to correct paths (all docs)

Batch 2: Section Rewrites (resolves ~15 findings)

  1. Deployment architecture: mark AWS as planned, document Docker Compose current state
  2. Auth flow: remove email/password, document BankID-only + demo-login
  3. QR payment: fix amount units, fee model, HMAC optionality
  4. Registration: BankID auto-creation, not 4-step

Batch 3: Missing Documentation (resolves ~15 findings)

  1. Demo mode architecture (auth.ts, mode.ts, payments.ts branching)
  2. GDPR endpoints (user.ts: objection, rectification, restriction)
  3. Withdrawal flow (withdrawal.ts)
  4. Feature flag mapping (feature-flags.ts: 8 flags)
  5. Error handling chain (error-handler.ts, Sentry, alerts)
  6. AML monitoring rules and thresholds
  7. Data retention cron job
  8. PSD2 disclosure endpoint
  9. Middleware request lifecycle

Batch 4: Cross-Doc Consistency (resolves ~10 findings)

  1. Unify data classification taxonomy
  2. Fix ADR contradictions (blue/green, rate limiting, health check paths)
  3. Fix AISP consent period (180d to 90d per PSD2)
  4. Assign unique document IDs

Non-Doc Issues Found (for engineering backlog)

These are code issues discovered during documentation review:

# Issue File Severity
1 Build tools (python3, make, g++) in production Docker image Dockerfile:82 HIGH
2 merchant role = admin role (any merchant can access audit/screening/STR) admin.ts:14 HIGH
3 SEED_DEMO=true can enable demo data in production db.ts:241 HIGH
4 HMAC verification on QR payments is optional transactions.ts:400 MEDIUM
5 withdrawal_requests table created at runtime, not in schema withdrawal.ts:34 MEDIUM
6 No async reconciliation for transactions transactions.ts MEDIUM
7 Dead otp_codes cleanup code cron.ts:84 LOW
8 idx_tx_idempotency breaks naming convention db.ts:190 LOW

Generated by drop-critics team: code-critic, consistency-critic, completeness-critic, validator

App Store

App store metadata, screenshots, and assets

App Store

App Store: Metadata

Drop — App Store Metadata

Last updated: 2026-02-21 Status: Ready for submission


iOS App Store Metadata

App Name

Drop — Send penger

Subtitle (max 30 characters)

Enklere betalinger for alle

Promotional Text (170 characters)

Send penger til utlandet med 0,5% gebyr. Betal i butikk med QR. Trygt med BankID. Regulert i Norge.

Description (max 4000 characters)

Drop — Enklere betalinger. Lavere gebyrer.

Drop er den nye standarden for internasjonale overføringer og daglige betalinger i Norge. Enten du skal sende penger til familie i utlandet eller betale for kebaben på hjørnet, gir Drop deg enklere og billigere løsninger enn bankene.

HVA ER DROP?

Drop er en betalingsapp som gjør to ting ekstremt godt:

  1. Send penger internasjonalt med lavere gebyrer enn Wise, Vipps eller Western Union
  2. Betal i butikk med QR-kode — raskere og billigere enn bankkort

HVORFOR VELGE DROP?

0,5% gebyr på internasjonale overføringer — Send 10 000 kr til familie i utlandet for 50 kr i gebyr, ikke 500 kr • 1% gebyr for QR-betalinger — Billigere for butikken, enklere for deg • Trygt med BankID — Norsk autentisering du stoler på • Regulert i Norge — Vi følger alle norske regler for finansielle tjenester • Pengene dine forblir i banken din — Drop bruker Open Banking (PSD2), så vi holder aldri pengene dine • Rask overføring — Internasjonale betalinger tar minutter, ikke dager • 30+ land — Send til Balkan, Pakistan, Tyrkia, Polen, Tyskland og mange flere

HVORDAN FUNGERER DET?

  1. Last ned Drop — Gratis på App Store
  2. Verifiser med BankID — Tar 2 minutter
  3. Send eller betal — Velg mottaker, skriv inn beløp, ferdig

FOR DEG SOM SENDER PENGER INTERNASJONALT

Drop gjør internasjonale overføringer enkelt og billig: • Støtter NOK til RSD (Serbia), BAM (Bosnia), PKR (Pakistan), TRY (Tyrkia), PLN (Polen), EUR og mange flere • Mottageren trenger ikke ha Drop — de får pengene direkte på konto eller kontant • Du betaler bare 0,5% i gebyr, ingen skjulte kostnader • Oversikt over alle transaksjonene dine i én app

FOR DEG SOM VIL BETALE ENKLERE I BUTIKK

Skann QR-kode, betal, ferdig. Ingen terminal, ingen kø, ingen krøll: • Raskere enn kort • Færre feil • Fungerer i alle butikker som har Drop QR-kode • Du får kvittering direkte i appen

SIKKERHET OG TILLIT

BankID-pålogging — Norsk standard for sikker autentisering • Ingen mellomlagring — Pengene dine går direkte fra din bank til mottaker • Regulert av Finanstilsynet — Vi følger alle norske regler • Open Banking (PSD2) — Vi bruker samme sikkerhet som bankene • Transparent prising — Du ser alltid nøyaktig hva du betaler

HVEM ER DROP FOR?

Drop er for alle som bor i Norge og ønsker enklere og billigere betalinger: • Send penger til familie i utlandet uten å betale bankens høye gebyrer • Betal i lokale butikker, kebab-sjapper, kiosker og restauranter med QR • Få oversikt over alle dine betalinger på ett sted • Spar penger på hver transaksjon

PRISER

• 0,5% gebyr på internasjonale overføringer (ingen skjulte kostnader) • 1% gebyr på QR-betalinger i butikk • Gratis å laste ned og opprette konto • Ingen månedlig avgift • Ingen bindingstid

KOMMER SNART

• Wallet og dagligbetalinger • Budsjettverktøy • Flere land og valutaer

KONTAKT OSS

Har du spørsmål? Vi hjelper deg gjerne: • E-post: hei@getdrop.no • Nettsiden vår: https://getdrop.no • Support: https://getdrop.no/support

Drop er et produkt av ALAI Holding AS (org.nr 932 516 136), et norskregistrert selskap.

Last ned Drop i dag — og opplev enklere betalinger.

Keywords (max 100 characters, comma-separated)

betaling,penger,overføring,QR,remittance,utlandet,gebyr,BankID,vipps,wisе,internasjonalt,send

Primary Category

Finance

Secondary Category

Utilities

Age Rating

17+ (Unrestricted Web Access + Infrequent/Mild Gambling and Contests — financial transactions)

Privacy Policy URL

https://getdrop.no/personvern

Marketing URL

https://getdrop.no

Support URL

https://getdrop.no/support

What's New in This Version (v1.0)

Velkommen til Drop!

Dette er den første versjonen av Drop — din nye app for enklere og billigere betalinger.

Hva kan du gjøre: • Send penger til utlandet med 0,5% gebyr • Betal i butikk med QR-kode • Verifiser deg trygt med BankID • Se alle transaksjoner på ett sted

Kommer snart: • Støtte for flere land • Wallet og dagligbetalinger • Budsjettverktøy

Har du tilbakemeldinger? Send oss en e-post på hei@getdrop.no — vi leser alt!

© 2026 ALAI Holding AS

Trade Representative Contact Information

Name: Alem Bašić Phone: +47 40 47 42 51 Email: alem@alai.no


Google Play Store Metadata

App Title (max 50 characters)

Drop — Send penger

Short Description (max 80 characters)

Send penger internasjonalt. Betal i butikk med QR. 0,5% gebyr. Trygt med BankID.

Full Description (max 4000 characters)

Drop — Enklere betalinger. Lavere gebyrer.

Drop er den nye standarden for internasjonale overføringer og daglige betalinger i Norge. Enten du skal sende penger til familie i utlandet eller betale for kebaben på hjørnet, gir Drop deg enklere og billigere løsninger enn bankene.

HVA ER DROP?

Drop er en betalingsapp som gjør to ting ekstremt godt:

  1. Send penger internasjonalt med lavere gebyrer enn Wise, Vipps eller Western Union
  2. Betal i butikk med QR-kode — raskere og billigere enn bankkort

HVORFOR VELGE DROP?

✓ 0,5% gebyr på internasjonale overføringer — Send 10 000 kr til familie i utlandet for 50 kr i gebyr, ikke 500 kr ✓ 1% gebyr for QR-betalinger — Billigere for butikken, enklere for deg ✓ Trygt med BankID — Norsk autentisering du stoler på ✓ Regulert i Norge — Vi følger alle norske regler for finansielle tjenester ✓ Pengene dine forblir i banken din — Drop bruker Open Banking (PSD2), så vi holder aldri pengene dine ✓ Rask overføring — Internasjonale betalinger tar minutter, ikke dager ✓ 30+ land — Send til Balkan, Pakistan, Tyrkia, Polen, Tyskland og mange flere

HVORDAN FUNGERER DET?

  1. Last ned Drop — Gratis på Google Play
  2. Verifiser med BankID — Tar 2 minutter
  3. Send eller betal — Velg mottaker, skriv inn beløp, ferdig

FOR DEG SOM SENDER PENGER INTERNASJONALT

Drop gjør internasjonale overføringer enkelt og billig: • Støtter NOK til RSD (Serbia), BAM (Bosnia), PKR (Pakistan), TRY (Tyrkia), PLN (Polen), EUR og mange flere • Mottageren trenger ikke ha Drop — de får pengene direkte på konto eller kontant • Du betaler bare 0,5% i gebyr, ingen skjulte kostnader • Oversikt over alle transaksjonene dine i én app

FOR DEG SOM VIL BETALE ENKLERE I BUTIKK

Skann QR-kode, betal, ferdig. Ingen terminal, ingen kø, ingen krøll: • Raskere enn kort • Færre feil • Fungerer i alle butikker som har Drop QR-kode • Du får kvittering direkte i appen

SIKKERHET OG TILLIT

• BankID-pålogging — Norsk standard for sikker autentisering • Ingen mellomlagring — Pengene dine går direkte fra din bank til mottaker • Regulert av Finanstilsynet — Vi følger alle norske regler • Open Banking (PSD2) — Vi bruker samme sikkerhet som bankene • Transparent prising — Du ser alltid nøyaktig hva du betaler

HVEM ER DROP FOR?

Drop er for alle som bor i Norge og ønsker enklere og billigere betalinger: • Send penger til familie i utlandet uten å betale bankens høye gebyrer • Betal i lokale butikker, kebab-sjapper, kiosker og restauranter med QR • Få oversikt over alle dine betalinger på ett sted • Spar penger på hver transaksjon

PRISER

• 0,5% gebyr på internasjonale overføringer (ingen skjulte kostnader) • 1% gebyr på QR-betalinger i butikk • Gratis å laste ned og opprette konto • Ingen månedlig avgift • Ingen bindingstid

KOMMER SNART

• Wallet og dagligbetalinger • Budsjettverktøy • Flere land og valutaer

KONTAKT OSS

Har du spørsmål? Vi hjelper deg gjerne: • E-post: hei@getdrop.no • Nettsiden vår: https://getdrop.no • Support: https://getdrop.no/support

Drop er et produkt av ALAI Holding AS (org.nr 932 516 136), et norskregistrert selskap.

Last ned Drop i dag — og opplev enklere betalinger.

Category

Finance

Content Rating

Rated for 17+ (financial transactions)

Privacy Policy URL

https://getdrop.no/personvern

Developer Website

https://getdrop.no

Developer Email

hei@getdrop.no

Developer Address

ALAI Holding AS Org.nr 932 516 136 Norway

Release Notes (v1.0)

Velkommen til Drop!

Dette er den første versjonen av Drop — din nye app for enklere og billigere betalinger.

Hva kan du gjøre: • Send penger til utlandet med 0,5% gebyr • Betal i butikk med QR-kode • Verifiser deg trygt med BankID • Se alle transaksjoner på ett sted

Kommer snart: • Støtte for flere land • Wallet og dagligbetalinger • Budsjettverktøy

Har du tilbakemeldinger? Send oss en e-post på hei@getdrop.no — vi leser alt!


Store Listing Assets Checklist

Required Screenshots

App Icon

Feature Graphic (Google Play only)

Promo Video (optional)


Notes for Submission

  1. BankID requirement — Make sure to mention in description that BankID is required for verification (Norwegian users only)
  2. Age restriction — 17+ due to financial transactions and BankID requirement (18+ in practice)
  3. Permissions explanation — Camera (for QR scanning), Internet (for transactions), Notifications (for transaction alerts)
  4. Regulatory compliance — Mention ALAI Holding AS as the legal entity and Norwegian registration
  5. Pricing transparency — Always show exact fees upfront (0,5% remittance, 1% QR payments)
  6. Pass-through model — Emphasize that Drop never holds user money (Open Banking PSD2)
  7. Norwegian language — All store content is in Norwegian Bokmål (target market)
App Store

App Store: Icon Spec

Drop — App Store Icon Specification

Last updated: 2026-02-21 Status: Design specification ready, icon creation pending


Platform Requirements

iOS App Store

Requirement Specification
Size 1024 × 1024 px
Format PNG (24-bit)
Transparency NOT allowed
Rounded corners NOT allowed (iOS system adds them automatically)
Color space sRGB or Display P3
Layers Flattened (no alpha channel)

Important: iOS automatically applies rounded corners and other effects. Design for a square canvas, but consider that ~10% of corner area will be masked.

Android / Google Play

Requirement Specification
Size 512 × 512 px
Format PNG (32-bit)
Transparency Allowed (but not required)
Rounded corners Manual (can include in design)
Color space sRGB
Adaptive icon Provide foreground + background layers for Android 8.0+

Adaptive Icon (Android 8.0+):


Drop Brand Identity

Color Palette

Color Hex Usage
Drop Green (Dark) #064E25 Primary gradient start, background
Drop Green (Light) #0B6E35 Primary gradient end, foreground
Drop Gold #D4A017 Accent, highlights, glow effect
White #FFFFFF Text, contrast elements

Logo Reference

Current Drop logo (web + mobile):

Location:


Icon Concept Options

Option 1: Drop "D" Lettermark (RECOMMENDED)

Design:

Rationale:

Implementation:

  1. Create "D" lettermark in Figma (vector)
  2. Apply gradient background
  3. Add gold accent for visual interest
  4. Export at required sizes
  5. Test visibility at small sizes (48px, 72px, 96px)

Option 2: Currency Exchange Symbol

Design:

Rationale:

Implementation:

  1. Design circular arrows in Figma (vector paths)
  2. Place "kr" in center (DM Sans or Fraunces font)
  3. Apply gradient and glow effects
  4. Export and test

Option 3: Drop + Currency Hybrid

Design:

Rationale:

Challenges:

Recommendation: Use this only if lettermark or symbol don't test well.


Design Principles

1. Simplicity

2. Memorability

3. Scalability

4. Platform Fit

5. Trust & Professionalism


Production Workflow

Step 1: Design in Figma

  1. Create new Figma file: "Drop App Icon"
  2. Set canvas to 1024 × 1024 px (iOS size)
  3. Design icon using Drop brand colors and fonts
  4. Consider safe area for Android adaptive icon (center 264 × 264 px)
  5. Export variations for review

Step 2: Review & Iterate

  1. Export 3-5 icon concepts
  2. Test at small sizes (48px, 72px, 96px)
  3. Review with Alem for approval
  4. Iterate based on feedback

Step 3: Finalize Assets

iOS:

Android:

Step 4: Implement


Testing Checklist


Technical Export Settings (Figma)

iOS Export (1024 × 1024 px)

Format: PNG
Scale: 1x (already at 1024px)
Suffix: @ios
Background: Opaque (white or brand color fill)
Color space: sRGB
Compression: None (lossless)

Android Export (512 × 512 px)

Format: PNG
Scale: 1x (already at 512px)
Suffix: @android
Background: Transparent (if using adaptive) or opaque (if standard)
Color space: sRGB
Compression: None (lossless)

Android Adaptive Layers

Foreground layer:
- 512 × 512 px PNG
- Transparent background
- Icon artwork centered in safe area (264px circle)

Background layer:
- 512 × 512 px PNG
- Solid color (#0B6E35 or gradient)
- No transparency

Brand Consistency Notes

Drop logo evolution:

  1. Original: Wordmark "Drop" with currency "o" (circular arrows + "kr")
  2. App icon: Should be simplified version — lettermark or symbol

Do:

Don't:


Approval Process

  1. Design concepts: Create 3 variations in Figma
  2. Internal review: John reviews for brand consistency + technical requirements
  3. Alem approval: Final design decision (visual + brand fit)
  4. Asset generation: Export iOS + Android at required sizes
  5. Device testing: Preview on real iOS + Android devices
  6. Store submission: Upload to App Store Connect + Google Play Console

References


Next Steps

  1. Create icon concepts in Figma: 3-5 variations (lettermark, symbol, hybrid)
  2. Export preview at multiple sizes: Test legibility
  3. Present to Alem for approval: Visual review + brand fit
  4. Finalize assets: iOS 1024px + Android 512px + adaptive layers
  5. Integrate into mobile app: Add to iOS/Android projects
  6. Submit to stores: Upload with metadata for App Store + Google Play review

Designer Notes:

For best results, use the /frontend-design skill when creating the actual icon. The skill has access to Figma and can generate professional-quality assets that match Drop's brand identity. DO NOT attempt to create SVG icons manually or use generic design tools — this has historically produced poor results.

App Store

App Store: Screenshot Texts

Drop — Screenshot Text Overlays

Last updated: 2026-02-21 Purpose: Norwegian text overlays for App Store and Google Play screenshots


Screenshot Strategy

Each screenshot showcases one key feature with:

All text in Norwegian Bokmål, natural tone (not AI-translated).


Screenshot 1: Login / BankID

Headline

Trygg pålogging

Subtext

Verifiser deg med BankID på under 2 minutter

Screen Reference

mockups/figma-make-export/src/components/Login.tsx

Visual Notes


Screenshot 2: Dashboard / Home

Headline

Alt på ett sted

Subtext

Send penger, betal med QR, se saldo — enkelt

Screen Reference

mockups/figma-make-export/src/components/Dashboard.tsx

Visual Notes


Screenshot 3: Send Money / Remittance

Headline

0,5% gebyr

Subtext

Send til 30+ land — billigere enn banken

Screen Reference

mockups/figma-make-export/src/components/SendMoney.tsx

Visual Notes


Screenshot 4: QR Payment / Scan

Headline

Betal på sekunder

Subtext

Skann QR-kode i butikk — raskere enn kort

Screen Reference

mockups/figma-make-export/src/components/ScanQR.tsx

Visual Notes


Screenshot 5: Transaction History

Headline

Full kontroll

Subtext

Se alle betalinger og overføringer

Screen Reference

mockups/figma-make-export/src/components/TransactionHistory.tsx

Visual Notes


Screenshot 6 (Optional): Bank Accounts

Headline

Koble banken din

Subtext

Se saldo fra din bank med Open Banking

Screen Reference

mockups/figma-make-export/src/components/BankAccounts.tsx

Visual Notes


Screenshot 7 (Optional): Notifications

Headline

Hold deg oppdatert

Subtext

Varsler for hver betaling og overføring

Screen Reference

mockups/figma-make-export/src/components/Notifications.tsx

Visual Notes


Screenshot 8 (Optional): Profile / Settings

Headline

Dine innstillinger

Subtext

Administrer konto, sikkerhet og varsler

Screen Reference

mockups/figma-make-export/src/components/Profile.tsx

Visual Notes


Screenshot 9 (Optional): Onboarding Step

Headline

Kom i gang

Subtext

3 enkle steg — klar til å sende penger

Screen Reference

mockups/figma-make-export/src/components/Onboarding.tsx

Visual Notes


Screenshot 10 (Optional): Merchant Dashboard

Headline

For butikker

Subtext

QR-betalinger med 1% gebyr

Screen Reference

mockups/figma-make-export/src/components/MerchantDashboard.tsx

Visual Notes


Design Guidelines

Typography

Layout

Branding

Accessibility


Platform-Specific Considerations

iOS App Store

Google Play Store


Production Workflow

  1. Export screens from Figma Make: High-res PNG/JPG (2x or 3x)
  2. Add text overlays: Use Figma or Photoshop with Drop brand fonts
  3. Apply brand elements: Logo watermark, gradient accents if needed
  4. Review for consistency: All screenshots should feel cohesive
  5. Export final assets: Correct dimensions per platform requirements
  6. Test on devices: Preview how they look in actual store listings

Norwegian Language Notes

All text is written in natural Norwegian Bokmål:

Avoid direct translations from English — use idiomatic Norwegian expressions.


Review Checklist

Marketing

Marketing materials and strategy

Marketing

Marketing Overview

Marketing Resources

Marketing resources for Drop project: campaigns, content, analytics.

High-Level Design (HLD)

Bilko — High-Level Design (HLD)

Version: 1.0 Date: 2026-02-23 Project ID: bbd77cc0 Status: Current — reflects actual codebase as of 2026-02-23


Table of Contents

  1. System Overview
  2. Monorepo Structure
  3. Component Architecture
  4. Data Flow
  5. Tech Stack Rationale
  6. Multi-Tenancy Model
  7. Authentication Architecture
  8. Multi-Currency Architecture
  9. Country Plugin System
  10. Infrastructure Overview
  11. Security Model

1. System Overview

Bilko is a cloud-based accounting SaaS for Balkan SMBs operating in Serbia, Bosnia & Herzegovina, and Croatia. It is modeled after Fiken (Norway) — simple, compliant, and affordable.

Key design goals:

Target users: 50K–500K SMBs across the Balkan region Domains: bilko.io (primary), bilko.rs (Serbia redirect)


2. Monorepo Structure

The project uses Turborepo for monorepo management.

Bilko/
├── apps/
│   ├── web/                   # Next.js 15 frontend (App Router)
│   └── api/                   # Express + TypeScript backend
├── packages/
│   ├── database/              # Prisma schema + Prisma Client (@bilko/database)
│   ├── core/                  # Accounting engine (@bilko/core)
│   ├── country-rs/            # Serbia plugin (@bilko/country-rs)
│   ├── country-ba/            # Bosnia & Herzegovina plugin (@bilko/country-ba)
│   ├── country-hr/            # Croatia plugin (@bilko/country-hr)
│   └── ui/                    # Shared UI scaffold (empty, placeholder)
├── infrastructure/
│   ├── terraform/             # AWS IaC — future scale migration (not active at MVP)
│   ├── docker/                # Dockerfiles and docker-compose (local dev)
│   ├── nginx/                 # Nginx reverse proxy config (self-hosted fallback)
│   ├── pm2/                   # PM2 process manager config (self-hosted fallback)
│   └── scripts/               # Deployment shell scripts
├── docs/                      # All documentation
│   ├── backend/               # API, auth, services, DB schema docs
│   ├── frontend/              # Pages, components, design system docs
│   ├── infrastructure/        # Deployment, CI/CD, environment docs
│   ├── regulatory/            # Country-specific accounting law summaries
│   ├── security/              # Security architecture, compliance
│   └── testing/               # Testing guides and inventory
├── CLAUDE.md                  # Project AI assistant instructions
└── PIPELINE.md                # 8-gate checklist

3. Component Architecture

graph TB
    subgraph Client["Client Layer"]
        Browser["Browser / Mobile"]
    end

    subgraph Frontend["apps/web — Next.js 15"]
        AppRouter["App Router"]
        Pages["Pages (Dashboard, Invoices, Expenses, Reports, Banking, Settings)"]
        Components["shadcn/ui Components"]
        MockData["lib/mock-data.ts (TEMP — replace with API calls)"]
        Zustand["Zustand Store (future)"]
    end

    subgraph Backend["apps/api — Express + TypeScript"]
        Middleware["Middleware Stack (helmet → cors → json → rate-limit → auth → validate → handler → error)"]
        Routes["Route Modules (auth, invoices, expenses, contacts, accounts, transactions, reports, banking, settings)"]
        Services["Service Layer (Invoice, Expense, Contact, Account, Banking, Report, Settings)"]
        CoreEngine["@bilko/core (accounting, tax, multi-currency, bank-import)"]
    end

    subgraph Plugins["Country Plugins"]
        RS["@bilko/country-rs (Serbia: PDV 20%, SEF, CIT 15%)"]
        BA["@bilko/country-ba (BiH: PDV 17%, IFRS, UIO)"]
        HR["@bilko/country-hr (Croatia: PDV 25%, eRačun, FINA)"]
    end

    subgraph Data["Data Layer"]
        Prisma["@bilko/database — Prisma Client"]
        PG["PostgreSQL 15 (RDS)"]
    end

    subgraph Storage["Storage"]
        R2["Cloudflare R2 (PDF storage, receipts)"]
    end

    Browser --> AppRouter
    AppRouter --> Pages
    Pages --> Components
    Pages --> MockData
    Pages --> Zustand

    Pages -->|"REST API calls (future)"| Routes
    Middleware --> Routes
    Routes --> Services
    Services --> CoreEngine
    Services --> Plugins
    Services --> Prisma
    Prisma --> PG
    Services --> R2

4. Data Flow

4.1 Standard Request Flow

sequenceDiagram
    participant U as User (Browser)
    participant FE as Next.js Frontend
    participant MW as Middleware Stack
    participant RT as Route Handler
    participant SV as Service Layer
    participant CE as @bilko/core
    participant PR as Prisma Client
    participant DB as PostgreSQL

    U->>FE: User Action (e.g., Create Invoice)
    FE->>MW: POST /api/v1/invoices + Bearer token
    MW->>MW: helmet (security headers)
    MW->>MW: cors (origin check)
    MW->>MW: rate-limit (100 req/min per IP)
    MW->>MW: authGuard (verify JWT access token)
    MW->>MW: organizationScope (attach orgId to request)
    MW->>MW: validate (Zod schema check)
    MW->>RT: req.user + req.body validated
    RT->>SV: invoiceService.createInvoice(orgId, userId, data)
    SV->>CE: calculateVAT(), lockExchangeRate()
    SV->>PR: prisma.$transaction([create invoice, create items])
    PR->>DB: INSERT invoices, invoice_items
    DB-->>PR: Created records
    PR-->>SV: Invoice with items
    SV-->>RT: Formatted response
    RT-->>FE: 201 JSON response
    FE-->>U: Updated UI

4.2 Invoice Lifecycle with Double-Entry

stateDiagram-v2
    [*] --> draft: POST /api/v1/invoices
    draft --> sent: PATCH /status {action: "send"}\n→ Creates TX: DR Receivable / CR Revenue
    sent --> viewed: (future: email tracking webhook)
    viewed --> paid: PATCH /status {action: "mark-paid"}\n→ Creates TX: DR Bank / CR Receivable
    sent --> paid: PATCH /status {action: "mark-paid"}
    draft --> cancelled: PATCH /status {action: "cancel"}
    sent --> cancelled: PATCH /status {action: "cancel"}
    viewed --> overdue: (cron job: past due date)
    overdue --> paid: PATCH /status {action: "mark-paid"}

4.3 Expense Lifecycle with Double-Entry

stateDiagram-v2
    [*] --> pending: POST /api/v1/expenses
    pending --> approved: PATCH /expenses/:id/approve\n→ Creates TX: DR Expense / CR Payable
    approved --> paid: PATCH /expenses/:id/pay\n→ Creates TX: DR Payable / CR Bank
    pending --> rejected: (future endpoint)

5. Tech Stack Rationale

Layer Technology Rationale
Frontend Framework Next.js 15 (App Router) SSR for fast initial load, SEO, file-system routing, React Server Components
Frontend Language TypeScript 5.3 Type safety, IDE support, catch errors at compile time
Styling Tailwind CSS 4 + shadcn/ui Utility-first styling with accessible, unstyled Radix UI primitives
State Management Zustand 4.5 (planned) Lightweight global state; React hooks used currently during mock phase
Charts Recharts 2.15 React-native chart library, composable, good TypeScript support
Icons Lucide React Consistent icon set, tree-shakeable, maintained fork of Feather
Backend Framework Express + TypeScript Minimal, battle-tested, massive ecosystem; team familiarity
ORM Prisma Type-safe database access, migration management, schema-as-code
Database PostgreSQL 15 NUMERIC(19,4) for money, mature ACID compliance, full-text search
Auth JWT (access + refresh) Stateless, scalable; no session store needed
Validation Zod Runtime schema validation with full TypeScript inference
Monorepo Turborepo Fast incremental builds, shared packages, workspace management
Decimal Arithmetic Decimal.js Arbitrary-precision arithmetic — required for financial calculations
Frontend Hosting Vercel Edge network, zero-config Next.js deployment, automatic preview deployments
Backend Hosting Railway (EU Frankfurt) Managed containers, automatic TLS, built-in PostgreSQL, €21/mo MVP cost
File Storage Cloudflare R2 S3-compatible, zero egress fees, stores PDFs and receipts
IaC (future) Terraform Prepared for AWS migration at scale; configs in infrastructure/terraform/

6. Multi-Tenancy Model

Bilko uses organization-scoped multi-tenancy — all business data is isolated by organizationId.

erDiagram
    Organization {
        uuid id PK
        string name
        string baseCurrency "EUR by default"
        string country "RS, BA, HR"
        string language "sr, bs, hr"
    }
    User {
        uuid id PK
        uuid organizationId FK
        enum role "owner, admin, accountant, viewer"
    }
    Invoice {
        uuid id PK
        uuid organizationId FK
    }
    Expense {
        uuid id PK
        uuid organizationId FK
    }
    Transaction {
        uuid id PK
        uuid organizationId FK
    }
    Organization ||--o{ User : has
    Organization ||--o{ Invoice : owns
    Organization ||--o{ Expense : owns
    Organization ||--o{ Transaction : owns

Enforcement mechanism: The organizationScope middleware (apps/api/src/middleware/org-scope.ts) attaches req.user.organizationId to every authenticated request. All service methods receive organizationId as first parameter and filter all Prisma queries with where: { organizationId }. Cross-organization data access is structurally impossible via the API layer.

RBAC roles:

Role Permissions
owner Full access, manage users, change roles, delete org
admin Full access except role management
accountant Read invoices; CRUD on expenses, transactions; view reports
viewer Read-only access to all data

7. Authentication Architecture

sequenceDiagram
    participant C as Client
    participant A as API /auth
    participant DB as PostgreSQL

    C->>A: POST /api/v1/auth/login {email, password}
    A->>DB: findUser(email) → user + passwordHash
    A->>A: bcrypt.verify(password, passwordHash)
    A->>A: signAccessToken({sub, email, role, orgId}) [15min, JWT_SECRET]
    A->>A: signRefreshToken({sub, jti}) [7d, JWT_REFRESH_SECRET]
    A-->>C: 200 {accessToken, user, org} + Set-Cookie: refreshToken (httpOnly)

    Note over C,A: Subsequent requests
    C->>A: GET /api/v1/invoices + Authorization: Bearer <accessToken>
    A->>A: authGuard: verifyAccessToken() → payload
    A->>A: organizationScope: attach orgId to req
    A-->>C: 200 {data}

    Note over C,A: Token refresh
    C->>A: POST /api/v1/auth/refresh (cookie: refreshToken)
    A->>A: verifyRefreshToken() → {sub, jti}
    A->>DB: findUser(sub) → user
    A->>A: signAccessToken(newPayload)
    A-->>C: 200 {accessToken}

Token storage:

Security:


8. Multi-Currency Architecture

All monetary amounts stored as DECIMAL(19,4) in PostgreSQL. The system maintains both the transaction currency amount and the base-currency equivalent.

Key fields on monetary entities:

Field Type Purpose
currencyCode CHAR(3) ISO 4217 currency of the transaction
exchangeRate DECIMAL(12,6) Rate locked at transaction date
amount DECIMAL(19,4) Amount in transaction currency
baseAmount DECIMAL(19,4) Amount converted to org's baseCurrency

Rate locking: When an invoice or expense is created, the exchange rate is fetched from the ExchangeRate table for the most recent date on or before the transaction date and locked permanently. Historical rates are never recalculated (packages/core/src/multi-currency/index.ts: lockExchangeRate()).

Supported currencies: EUR, RSD, BAM, HRK, USD, GBP, CHF

Fallback: If no exchange rate is found for a currency pair on a given date, the system logs a warning and uses 1.0. This is a known gap — exchange rate population is a prerequisite for multi-currency accuracy.


9. Country Plugin System

Each country is a separate npm package with the same module structure:

packages/country-{code}/src/
├── tax/index.ts       # VAT/PDV calculation, CIT, WHT
├── chart/index.ts     # Country-specific chart of accounts
├── fiscal/index.ts    # Fiscal year rules
├── filing/index.ts    # Tax filing periods and deadlines
├── locale/index.ts    # Language/formatting (date, currency)
└── index.ts           # Re-exports all modules

Country-specific data:

Country Plugin VAT Standard VAT Reduced CIT E-Invoice
Serbia (RS) @bilko/country-rs 20% 10% 15% flat SEF (UBL 2.1) mandatory since 2023
Bosnia & Herzegovina (BA) @bilko/country-ba 17% none 10% (FBiH/RS both) CPF (pending, ~2026)
Croatia (HR) @bilko/country-hr 25% 13%, 5% 10%/18% progressive eRačun (UBL 2.1) mandatory since 2026

The core engine (@bilko/core) provides country-agnostic accounting primitives. Country plugins extend these with jurisdiction-specific rules without modifying core logic.


10. Infrastructure Overview

10.1 MVP Architecture (Current)

Bilko's MVP runs on Vercel (frontend) + Railway EU Frankfurt (API + PostgreSQL), chosen for developer velocity and cost efficiency at early stage. See ADR-010 for the full rationale and trade-off analysis.

graph LR
    subgraph DNS["Cloudflare DNS"]
        D1["bilko.io"]
        D2["api.bilko.io"]
    end

    subgraph CDN["Vercel Edge Network"]
        VCL["Vercel\n(Next.js frontend)\nglobal edge CDN"]
    end

    subgraph Railway["Railway — EU Frankfurt"]
        API["Express API\n(Node.js container)"]
        PG["PostgreSQL 15\n(Railway managed)"]
    end

    subgraph Storage["Cloudflare R2"]
        R2["R2 Bucket\n(PDFs, receipts)\nZero egress fees"]
    end

    subgraph External["External APIs"]
        SEND["SendGrid\n(transactional email)"]
        ECB["ECB / Fixer.io\n(exchange rates)"]
        SEF["SEF Serbia\n(e-invoices)"]
        eRacun["eRačun Croatia\n(e-invoices)"]
    end

    D1 --> VCL
    D2 --> API
    VCL -->|"REST API calls"| API
    API --> PG
    API --> R2
    API --> SEND
    API --> ECB
    API --> SEF
    API --> eRacun

Key MVP infrastructure decisions:

Decision Choice Reason
Frontend hosting Vercel Zero-config Next.js deploy, preview deployments per PR, global CDN
API + DB hosting Railway EU Frankfurt Managed containers + PostgreSQL, €21/mo, GDPR-compliant EU region
File storage Cloudflare R2 S3-compatible API, zero egress fees, invoices/receipts stored here
DNS + DDoS Cloudflare Free DDoS protection, CDN proxying for API origin hiding
Email SendGrid Reliable transactional delivery, 40K free emails/month at start
Exchange rates ECB (free) + Fixer.io (paid fallback) Daily EUR base rates free from ECB; Fixer for non-EUR pairs

Estimated MVP cost: €21/mo (Railway Starter: €5 API container + €5 PostgreSQL + €11 networking; Vercel: free tier; R2: free up to 10GB)


10.2 CDN & Static Assets

Vercel's edge network serves the Next.js frontend with automatic:


10.3 Redis Cache (Planned — Growth Phase)

Not deployed at MVP. Planned for growth phase when session load requires it:

Use Case Cache Key Pattern TTL
Exchange rate lookups fx:{base}:{target}:{date} 24h
Report aggregations report:{orgId}:{type}:{period} 1h
User permissions rbac:{userId}:{orgId} 15min

Railway provides a managed Redis add-on when needed. No code changes required in apps/api — add REDIS_URL env var and enable the cache middleware.


10.4 Scaling Path (Future — AWS)

When Bilko scales beyond Railway's limits (est. >10K active orgs), the migration path is:

MVP (Railway)                    → Growth (Railway Pro)              → Scale (AWS eu-central-1)
Express container (€5/mo)          Express + autoscaling                ECS Fargate
Railway PostgreSQL (€5/mo)         Railway PostgreSQL Pro               RDS PostgreSQL Multi-AZ
Vercel Edge CDN                    Vercel Pro                          Vercel Enterprise / CloudFront
—                                  Redis cache (Railway add-on)         ElastiCache Redis
—                                  —                                    CloudWatch + X-Ray

Terraform configs in infrastructure/terraform/ are pre-written for the AWS migration to avoid a cold-start when the time comes.


11. Security Model

Layer Control
Transport HTTPS enforced (HSTS, maxAge: 31536000, includeSubDomains)
Security headers helmet (CSP, X-Frame-Options: deny, X-Content-Type-Options: noSniff)
CORS Whitelist: bilko.io, www.bilko.io, localhost:3000
Rate limiting 100 req/min per IP (general); 5 req/min on /auth/login and /auth/register
Authentication JWT access token (15min) + refresh token (7d, httpOnly cookie)
Authorization RBAC checked per endpoint; organizationScope middleware enforces tenancy
Password storage bcrypt, 12 salt rounds
Audit trail LoggedAction table — append-only, captures all INSERT/UPDATE/DELETE with user, timestamp, old/new values
Money precision NUMERIC(19,4) everywhere; Decimal.js in business logic
Transaction immutability Transaction.locked = true makes records unmodifiable
SQL injection Prisma parameterized queries — no raw SQL in business logic
Secret management Environment variables; never committed to repository

Low-Level Design (LLD)

Bilko — Low-Level Design (LLD)

Version: 1.0 Date: 2026-02-23 Project ID: bbd77cc0 Status: Current — reflects actual codebase as of 2026-02-23


Table of Contents

  1. API Endpoint Specifications
  2. Database Schema Documentation
  3. Service Layer Design
  4. Middleware Stack
  5. Double-Entry Bookkeeping Implementation
  6. Tax Calculation Logic Per Country
  7. Invoice Lifecycle
  8. Bank Import Flow
  9. Core Engine Modules
  10. Cross-Reference Notes (Schema Verification)

1. API Endpoint Specifications

Base URL: /api/v1 Auth: All endpoints except /auth/* and /health require Authorization: Bearer <accessToken> Content-Type: application/json Error format:

{
  "error": "Human-readable message",
  "code": "ERROR_CODE",
  "details": {}
}

1.1 Health

GET /api/v1/health

No auth required.

Response 200:

{ "status": "ok", "timestamp": "2026-02-23T10:00:00.000Z" }

1.2 Authentication (/auth)

Source: apps/api/src/routes/auth.ts

POST /api/v1/auth/register

Rate-limited (stricter). Creates organization + owner user in a single Prisma transaction.

Request body:

{
  "organizationName": "Acme DOO",
  "country": "RS",
  "baseCurrency": "RSD",
  "language": "sr",
  "registrationNumber": "12345678",
  "vatNumber": "123456789",
  "email": "user@acme.rs",
  "password": "securepassword",
  "fullName": "Marko Marković"
}

Response 201:

{
  "user": { "id": "uuid", "email": "...", "fullName": "...", "role": "owner" },
  "organization": { "id": "uuid", "name": "...", "country": "RS", "baseCurrency": "RSD" },
  "tokens": { "accessToken": "jwt...", "refreshToken": "jwt..." }
}

Errors: 409 DUPLICATE (email exists), 400 VALIDATION_ERROR


POST /api/v1/auth/login

Rate-limited (stricter). rememberMe: true extends refresh token to 30 days.

Request body:

{ "email": "user@acme.rs", "password": "securepassword", "rememberMe": false }

Response 200: Same shape as register response. Sets refreshToken httpOnly cookie (path: /api/v1/auth).

Errors: 401 UNAUTHORIZED (invalid credentials)


POST /api/v1/auth/refresh

Uses refreshToken cookie. Issues new access token.

Response 200:

{ "accessToken": "jwt..." }

Errors: 401 NO_TOKEN, 401 TOKEN_EXPIRED, 401 INVALID_TOKEN


POST /api/v1/auth/logout

Clears refreshToken cookie.

Response 204: No content.


GET /api/v1/auth/me

Requires Authorization: Bearer <accessToken>.

Response 200:

{
  "id": "uuid",
  "email": "...",
  "fullName": "...",
  "role": "owner",
  "twoFactorEnabled": false,
  "lastLoginAt": "2026-02-23T10:00:00.000Z",
  "organization": {
    "id": "uuid",
    "name": "...",
    "country": "RS",
    "baseCurrency": "RSD",
    "language": "sr"
  }
}

1.3 Invoices (/invoices)

Source: apps/api/src/routes/invoices.ts, apps/api/src/services/invoice.service.ts

GET /api/v1/invoices

List invoices with pagination and filtering.

Query params:

Param Type Description
status enum draft, sent, viewed, paid, overdue, cancelled
customerId uuid Filter by customer
fromDate YYYY-MM-DD Invoice date from
toDate YYYY-MM-DD Invoice date to
page int Default 1
perPage int Default 20, max 100
sort string invoiceDate, totalAmount, createdAt
order string asc, desc

Response 200:

{
  "data": [
    {
      "id": "uuid",
      "invoiceNumber": "INV-2026-001",
      "customerId": "uuid",
      "customerName": "Acme Client",
      "invoiceDate": "2026-02-01",
      "dueDate": "2026-03-01",
      "currencyCode": "RSD",
      "totalAmount": "120000.0000",
      "status": "draft",
      "createdAt": "2026-02-01T10:00:00.000Z"
    }
  ],
  "meta": { "total": 42, "page": 1, "perPage": 20, "totalPages": 3 }
}

GET /api/v1/invoices/:id

Get single invoice with all line items.

Response 200:

{
  "id": "uuid",
  "invoiceNumber": "INV-2026-001",
  "customerId": "uuid",
  "customerName": "...",
  "invoiceDate": "2026-02-01",
  "dueDate": "2026-03-01",
  "currencyCode": "RSD",
  "exchangeRate": "1.000000",
  "subtotal": "100000.0000",
  "taxAmount": "20000.0000",
  "discountAmount": "0.0000",
  "totalAmount": "120000.0000",
  "baseAmount": "120000.0000",
  "status": "draft",
  "sentAt": null,
  "paidAt": null,
  "items": [
    {
      "id": "uuid",
      "lineNumber": 1,
      "description": "Consulting services",
      "quantity": "10.00",
      "unitPrice": "10000.0000",
      "taxRate": "20.00",
      "lineTotal": "100000.0000",
      "accountId": "uuid"
    }
  ],
  "notes": null,
  "terms": null,
  "pdfUrl": null,
  "createdBy": "uuid",
  "createdAt": "...",
  "updatedAt": "..."
}

Errors: 404 NOT_FOUND


POST /api/v1/invoices

Create invoice in draft status. Auto-generates invoice number (INV-YYYY-NNN). Locks exchange rate at invoiceDate.

Request body:

{
  "customerId": "uuid",
  "invoiceDate": "2026-02-01",
  "dueDate": "2026-03-01",
  "currencyCode": "RSD",
  "items": [
    {
      "description": "Consulting",
      "quantity": 10,
      "unitPrice": 10000,
      "taxRate": 20,
      "accountId": "uuid"
    }
  ],
  "notes": "Optional notes",
  "terms": "Net 30"
}

Response 201: Full invoice object (same as GET /:id)

Errors: 404 NOT_FOUND (customer), 400 VALIDATION_ERROR


PUT /api/v1/invoices/:id

Update invoice. Only allowed when status = draft.

Request body (partial):

{
  "invoiceDate": "2026-02-15",
  "dueDate": "2026-03-15",
  "items": [ ... ],
  "notes": "Updated notes"
}

Errors: 404 NOT_FOUND, 400 BAD_REQUEST (not draft)


PATCH /api/v1/invoices/:id/status

Change invoice status. Each action triggers double-entry transaction creation.

Request body:

{ "action": "send" }
{ "action": "mark-paid", "paidAt": "2026-02-20" }
{ "action": "cancel" }

Actions and effects:

Action From Status To Status Journal Entry
send draft sent DR Accounts Receivable / CR Revenue
mark-paid sent, viewed paid DR Bank / CR Accounts Receivable
cancel draft, sent, viewed cancelled None

Errors: 404 NOT_FOUND, 400 BAD_REQUEST (invalid transition), 400 BAD_REQUEST (accounts not found)


GET /api/v1/invoices/:id/pdf

Redirects to PDF URL in Cloudflare R2. Returns 404 if PDF not generated yet.


POST /api/v1/invoices/:id/send

Send invoice email to customer.

Request body:

{ "to": "customer@example.com", "subject": "Invoice ...", "message": "..." }

Response 200:

{ "sentAt": "...", "sentTo": "customer@example.com", "emailId": "..." }

Note: Email sending is a placeholder — not yet implemented.


DELETE /api/v1/invoices/:id

Delete invoice. Only allowed when status = draft.

Response 204: No content.

Errors: 404 NOT_FOUND, 400 BAD_REQUEST (not draft)


1.4 Expenses (/expenses)

Source: apps/api/src/routes/expenses.ts, apps/api/src/services/expense.service.ts

GET /api/v1/expenses

List with pagination.

Query params: status, category, vendorId, fromDate, toDate, page, perPage, sort, order


GET /api/v1/expenses/:id

Response 200:

{
  "id": "uuid",
  "expenseNumber": "EXP-2026-001",
  "vendorId": "uuid",
  "vendorName": "Office Supplies Ltd",
  "expenseDate": "2026-02-01",
  "category": "office",
  "currencyCode": "RSD",
  "exchangeRate": "1.000000",
  "amount": "5000.0000",
  "baseAmount": "5000.0000",
  "taxAmount": "850.0000",
  "paymentMethod": "bank_transfer",
  "accountId": "uuid",
  "description": "Office supplies purchase",
  "receiptUrl": null,
  "status": "pending",
  "approvedAt": null,
  "paidAt": null,
  "createdBy": "uuid",
  "createdAt": "...",
  "updatedAt": "..."
}

POST /api/v1/expenses

Create expense in pending status. Auto-generates number (EXP-YYYY-NNN).

Request body:

{
  "vendorId": "uuid",
  "expenseDate": "2026-02-01",
  "category": "office",
  "amount": 5000,
  "currencyCode": "RSD",
  "taxAmount": 850,
  "paymentMethod": "bank_transfer",
  "accountId": "uuid",
  "description": "Office supplies"
}

PUT /api/v1/expenses/:id

Update expense. Only pending status.


PATCH /api/v1/expenses/:id/approve

Approve expense. Creates double-entry: DR Expense Account / CR Accounts Payable

Response 200: Updated expense with status: approved


PATCH /api/v1/expenses/:id/pay

Mark expense paid. Creates double-entry: DR Accounts Payable / CR Bank

Response 200: Updated expense with status: paid


DELETE /api/v1/expenses/:id

Delete expense. Only pending status.


1.5 Contacts (/contacts)

Source: apps/api/src/routes/contacts.ts, apps/api/src/services/contact.service.ts

GET /api/v1/contacts

Query params: type (customer, vendor, both), search, page, perPage

GET /api/v1/contacts/:id

POST /api/v1/contacts

Request body:

{
  "type": "customer",
  "name": "Acme Client DOO",
  "email": "billing@acme.rs",
  "phone": "+381 11 123 4567",
  "registrationNumber": "12345678",
  "vatNumber": "123456789",
  "addressLine1": "Bulevar Kralja Aleksandra 1",
  "city": "Beograd",
  "postalCode": "11000",
  "country": "RS",
  "currencyCode": "RSD",
  "paymentTerms": 30,
  "notes": "VIP client"
}

PUT /api/v1/contacts/:id

DELETE /api/v1/contacts/:id

Soft-delete: sets isActive = false. Contact remains in database for historical records.


1.6 Accounts (Chart of Accounts) (/accounts)

Source: apps/api/src/routes/accounts.ts, apps/api/src/services/account.service.ts

GET /api/v1/accounts

Query params: typeId, isActive, includeBalances (boolean)

Response 200:

{
  "data": [
    {
      "id": "uuid",
      "code": "120",
      "name": "Potraživanja od kupaca",
      "accountTypeId": 1,
      "accountType": "Asset",
      "currencyCode": "RSD",
      "parentAccountId": null,
      "isActive": true
    }
  ]
}

POST /api/v1/accounts

Request body: { "code": "1201", "name": "...", "accountTypeId": 1, "currencyCode": "RSD", "parentAccountId": "uuid" }

PUT /api/v1/accounts/:id


1.7 Transactions (General Ledger) (/transactions)

Source: apps/api/src/routes/transactions.ts

GET /api/v1/transactions

Query params: fromDate, toDate, accountId, referenceType (invoice, expense, payment, manual), referenceId, page, perPage, sort, order

Response 200:

{
  "data": [
    {
      "id": "uuid",
      "transactionDate": "2026-02-01",
      "description": "Invoice INV-2026-001",
      "debitAccountId": "uuid",
      "debitAccountCode": "120",
      "debitAccountName": "Receivables",
      "creditAccountId": "uuid",
      "creditAccountCode": "600",
      "creditAccountName": "Revenue",
      "amount": "120000.0000",
      "currencyCode": "RSD",
      "exchangeRate": "1.000000",
      "baseAmount": "120000.0000",
      "referenceType": "invoice",
      "referenceId": "uuid",
      "locked": false,
      "reconciled": false,
      "createdBy": "uuid",
      "createdAt": "..."
    }
  ],
  "meta": { "total": 100, "page": 1, "perPage": 20, "totalPages": 5 }
}

GET /api/v1/transactions/:id

Full transaction detail including account type information.

POST /api/v1/transactions

Manual journal entry. Requires owner, admin, or accountant role. Debit and credit accounts must be different.

Request body:

{
  "transactionDate": "2026-02-01",
  "description": "Manual adjustment",
  "debitAccountId": "uuid",
  "creditAccountId": "uuid",
  "amount": 5000,
  "currencyCode": "RSD",
  "notes": "Correction entry"
}

Errors: 403 FORBIDDEN (viewer role), 422 VALIDATION_ERROR (same debit/credit account), 404 NOT_FOUND (accounts)


1.8 Reports (/reports)

Source: apps/api/src/routes/reports.ts, apps/api/src/services/report.service.ts

GET /api/v1/reports/dashboard

MTD metrics: cash balance, revenue, unpaid invoices, expenses, profit, monthly P&L (6 months), receivables aging, expenses by category.

GET /api/v1/reports/profit-loss

Query params: from (YYYY-MM-DD), to (YYYY-MM-DD)

Response 200:

{
  "period": { "from": "2026-01-01", "to": "2026-01-31" },
  "baseCurrency": "RSD",
  "revenue": { "total": "500000.0000", "accounts": [{ "accountCode": "600", "accountName": "Revenue", "amount": "500000.0000" }] },
  "expenses": { "total": "200000.0000", "accounts": [...] },
  "netProfit": "300000.0000"
}

GET /api/v1/reports/balance-sheet

Query params: date (YYYY-MM-DD, default: today)

Returns assets (current + fixed), liabilities (current + long-term), equity with account detail.

GET /api/v1/reports/cash-flow

Query params: from, to

Categorizes bank account transactions into operating, investing, and financing cash flows with opening/closing balance.

GET /api/v1/reports/vat

Query params: from, to

Returns output VAT (from invoices), input VAT (from expenses), net VAT, and reconciliation status.

GET /api/v1/reports/trial-balance

Query params: date (YYYY-MM-DD, default: today)

Returns all accounts with debit total, credit total, balance, and whether total debits equal total credits.

GET /api/v1/reports/general-ledger

Query params: accountId (optional), from, to

Returns accounts with individual transaction entries sorted by date, showing running debit/credit/counter-account.


1.9 Banking (/bank-accounts)

Source: apps/api/src/routes/banking.ts, apps/api/src/services/banking.service.ts

GET /api/v1/bank-accounts

List all bank accounts with balances.

GET /api/v1/bank-accounts/:id

Single bank account with recent transactions.

POST /api/v1/bank-accounts

Request body:

{
  "bankName": "UniCredit Banka",
  "accountNumber": "170-123456789-01",
  "iban": "RS35170006310000014243",
  "currencyCode": "RSD",
  "accountId": "uuid"
}

GET /api/v1/bank-accounts/:id/transactions

Query params: fromDate, toDate, reconciled (boolean), page, perPage

POST /api/v1/bank-accounts/:id/import

Import CSV bank statement. Request body: { "csvContent": "Date,Amount,..." }

Response:

{ "imported": 45, "duplicates": 3, "errors": 0 }

POST /api/v1/bank-accounts/:id/reconcile

Request body:

{
  "bankTransactionId": "uuid",
  "transactionId": "uuid"
}

1.10 Settings

Source: apps/api/src/routes/settings.ts, apps/api/src/services/settings.service.ts

GET /api/v1/organization

PUT /api/v1/organization

Requires owner or admin role.

Request body:

{
  "name": "Updated Name DOO",
  "registrationNumber": "12345678",
  "vatNumber": "123456789",
  "language": "sr"
}

GET /api/v1/users

Requires owner or admin role. Returns all users in the organization.

POST /api/v1/users/invite

Request body:

{ "email": "newuser@acme.rs", "fullName": "Jana Jović", "role": "accountant" }

PUT /api/v1/users/:id/role

Requires owner role only.

Request body:

{ "role": "admin" }

DELETE /api/v1/users/:id

Requires owner role. Cannot delete self.

GET /api/v1/currencies

List all active currencies with code, name, symbol, decimal places.

GET /api/v1/exchange-rates

Query params: baseCurrency, targetCurrency, date

GET /api/v1/settings/tax-rates

Get org-level tax rate overrides.

PUT /api/v1/settings/tax-rates

Requires owner or admin.


2. Database Schema Documentation

Source: packages/database/prisma/schema.prisma Database: PostgreSQL 15 ORM: Prisma

2.1 Entity Relationship Diagram

erDiagram
    Organization {
        UUID id PK
        VARCHAR(255) name
        VARCHAR(50) registrationNumber
        VARCHAR(50) vatNumber
        CHAR(3) baseCurrency "default: EUR"
        CHAR(2) country
        CHAR(2) language "default: sr"
        DATE fiscalYearStart
        TIMESTAMP createdAt
        TIMESTAMP updatedAt
    }

    User {
        UUID id PK
        UUID organizationId FK
        VARCHAR(255) email UK
        VARCHAR(255) passwordHash
        VARCHAR(255) fullName
        ENUM role "owner|admin|accountant|viewer"
        BOOLEAN twoFactorEnabled
        VARCHAR(255) twoFactorSecret
        TIMESTAMP lastLoginAt
        TIMESTAMP createdAt
        TIMESTAMP updatedAt
    }

    AccountType {
        INT id PK "autoincrement"
        VARCHAR(50) name UK
        ENUM normalBalance "debit|credit"
        TIMESTAMP createdAt
    }

    Account {
        UUID id PK
        UUID organizationId FK
        VARCHAR(10) code
        VARCHAR(255) name
        INT accountTypeId FK
        CHAR(3) currencyCode
        UUID parentAccountId FK "nullable, self-reference"
        BOOLEAN isActive
        TIMESTAMP createdAt
        TIMESTAMP updatedAt
    }

    Contact {
        UUID id PK
        UUID organizationId FK
        ENUM type "customer|vendor|both"
        VARCHAR(255) name
        VARCHAR(255) email
        VARCHAR(50) phone
        VARCHAR(50) registrationNumber
        VARCHAR(50) vatNumber
        VARCHAR(255) addressLine1
        VARCHAR(255) addressLine2
        VARCHAR(100) city
        VARCHAR(20) postalCode
        CHAR(2) country
        CHAR(3) currencyCode
        INT paymentTerms "default: 30 days"
        TEXT notes
        BOOLEAN isActive
        TIMESTAMP createdAt
        TIMESTAMP updatedAt
    }

    Invoice {
        UUID id PK
        UUID organizationId FK
        UUID customerId FK
        VARCHAR(50) invoiceNumber UK
        DATE invoiceDate
        DATE dueDate
        CHAR(3) currencyCode
        DECIMAL(12_6) exchangeRate
        DECIMAL(19_4) subtotal
        DECIMAL(19_4) taxAmount
        DECIMAL(19_4) discountAmount
        DECIMAL(19_4) totalAmount
        DECIMAL(19_4) baseAmount
        ENUM status "draft|sent|viewed|paid|overdue|cancelled"
        TIMESTAMP sentAt
        TIMESTAMP viewedAt
        TIMESTAMP paidAt
        TEXT notes
        TEXT terms
        VARCHAR(500) pdfUrl
        UUID createdBy FK
        TIMESTAMP createdAt
        TIMESTAMP updatedAt
    }

    InvoiceItem {
        UUID id PK
        UUID invoiceId FK
        INT lineNumber
        VARCHAR(500) description
        DECIMAL(10_2) quantity
        DECIMAL(19_4) unitPrice
        DECIMAL(5_2) taxRate
        DECIMAL(19_4) lineTotal
        UUID accountId FK "nullable"
        TIMESTAMP createdAt
    }

    Expense {
        UUID id PK
        UUID organizationId FK
        UUID vendorId FK "nullable"
        VARCHAR(50) expenseNumber UK
        DATE expenseDate
        CHAR(3) currencyCode
        DECIMAL(12_6) exchangeRate
        DECIMAL(19_4) amount
        DECIMAL(19_4) baseAmount
        DECIMAL(19_4) taxAmount
        VARCHAR(100) category
        VARCHAR(50) paymentMethod
        UUID accountId FK "nullable"
        TEXT description
        VARCHAR(500) receiptUrl
        ENUM status "pending|approved|paid|rejected"
        UUID approvedBy FK "nullable"
        TIMESTAMP approvedAt
        TIMESTAMP paidAt
        UUID createdBy FK
        TIMESTAMP createdAt
        TIMESTAMP updatedAt
    }

    Transaction {
        UUID id PK
        UUID organizationId FK
        DATE transactionDate
        VARCHAR(255) description
        UUID debitAccountId FK
        UUID creditAccountId FK
        DECIMAL(19_4) amount
        CHAR(3) currencyCode
        DECIMAL(12_6) exchangeRate
        DECIMAL(19_4) baseAmount
        VARCHAR(50) referenceType "invoice|expense|payment|manual"
        UUID referenceId "nullable"
        BOOLEAN locked "default: false"
        TIMESTAMP lockedAt
        BOOLEAN reconciled "default: false"
        TIMESTAMP reconciledAt
        TEXT notes
        UUID createdBy FK "nullable"
        TIMESTAMP createdAt
    }

    BankAccount {
        UUID id PK
        UUID organizationId FK
        UUID accountId FK
        VARCHAR(255) bankName
        VARCHAR(50) accountNumber
        VARCHAR(50) iban
        CHAR(3) currencyCode
        DECIMAL(19_4) currentBalance
        BOOLEAN isActive
        TIMESTAMP createdAt
        TIMESTAMP updatedAt
    }

    BankTransaction {
        UUID id PK
        UUID bankAccountId FK
        DATE transactionDate
        DECIMAL(19_4) amount
        VARCHAR(500) description
        VARCHAR(255) reference
        BOOLEAN reconciled
        UUID matchedTransactionId "nullable"
        TIMESTAMP createdAt
    }

    Currency {
        CHAR(3) code PK
        VARCHAR(100) name
        VARCHAR(10) symbol
        SMALLINT decimalPlaces "default: 2"
        BOOLEAN isActive
        TIMESTAMP createdAt
    }

    ExchangeRate {
        UUID id PK
        CHAR(3) baseCurrency FK
        CHAR(3) targetCurrency FK
        DECIMAL(12_6) rate
        DATE effectiveDate
        VARCHAR(50) source
        TIMESTAMP lastUpdated
    }

    LoggedAction {
        BIGINT eventId PK "autoincrement"
        TEXT schemaName
        TEXT tableName
        UUID userId FK "nullable"
        TIMESTAMP actionTimestamp
        ENUM action "INSERT|UPDATE|DELETE"
        JSONB rowData "full row snapshot"
        JSONB changedFields "diff for UPDATE"
        TEXT queryText
        INET clientIp
        TEXT applicationName "default: bilko-api"
    }

    SchemaVersion {
        VARCHAR(20) version PK
        TIMESTAMP appliedAt
        TEXT description
    }

    Organization ||--o{ User : has
    Organization ||--o{ Account : owns
    Organization ||--o{ Contact : owns
    Organization ||--o{ Invoice : owns
    Organization ||--o{ Expense : owns
    Organization ||--o{ Transaction : owns
    Organization ||--o{ BankAccount : owns
    AccountType ||--o{ Account : classifies
    Account ||--o{ Account : "parent-child"
    Contact ||--o{ Invoice : "billed to"
    Contact ||--o{ Expense : "billed from"
    Invoice ||--o{ InvoiceItem : contains
    Account ||--o{ InvoiceItem : "revenue account"
    Account ||--o{ Expense : "expense account"
    Account ||--o{ BankAccount : links
    Account ||--o{ Transaction : "debit side"
    Account ||--o{ Transaction : "credit side"
    BankAccount ||--o{ BankTransaction : holds
    Currency ||--o{ ExchangeRate : "base"
    Currency ||--o{ ExchangeRate : "target"
    User ||--o{ LoggedAction : audits

2.2 Key Indexes

Table Index Columns Purpose
users idx_users_organization organizationId User lookup by org
users idx_users_email email Login lookup
accounts idx_accounts_organization organizationId List accounts by org
accounts Unique organizationId, code Prevent duplicate account codes
invoices idx_invoices_organization organizationId List invoices by org
invoices idx_invoices_status status Filter by status
invoices idx_invoices_due_date dueDate Overdue detection
invoices idx_invoices_org_status_date organizationId, status, invoiceDate Complex report queries
transactions idx_transactions_org_date organizationId, transactionDate Date range queries
transactions idx_transactions_reference referenceType, referenceId Find transactions for an invoice/expense
exchange_rates idx_exchange_rates_pair baseCurrency, targetCurrency Currency pair lookup
exchange_rates Unique baseCurrency, targetCurrency, effectiveDate One rate per pair per day
logged_actions idx_logged_actions_timestamp actionTimestamp Audit log queries by time

3. Service Layer Design

All services follow the same pattern:

3.1 InvoiceService

File: apps/api/src/services/invoice.service.ts

Method Description
listInvoices(orgId, params) Paginated list with filters
getInvoice(orgId, id) Single invoice with items
createInvoice(orgId, userId, data) Create draft, calculate amounts, lock exchange rate
updateInvoice(orgId, id, data) Update draft only, recalculate if items changed
changeInvoiceStatus(orgId, id, data) Dispatches to sendInvoice(), markInvoicePaid(), cancelInvoice()
deleteInvoice(orgId, id) Delete draft only
generateInvoiceNumber(orgId) INV-YYYY-NNN sequential
getExchangeRate(from, to, date) DB lookup, falls back to 1.0 with warning
sendInvoice(invoice) Prisma tx: create DR Receivable/CR Revenue + update status
markInvoicePaid(invoice, paidAt) Prisma tx: create DR Bank/CR Receivable + update status

3.2 ExpenseService

File: apps/api/src/services/expense.service.ts

Method Description
listExpenses(orgId, params) Paginated list with filters
getExpense(orgId, id) Single expense
createExpense(orgId, userId, data) Create pending, lock exchange rate
updateExpense(orgId, id, data) Update pending only
approveExpense(orgId, id, userId) Prisma tx: create DR Expense/CR Payable + update status
payExpense(orgId, id) Prisma tx: create DR Payable/CR Bank + update status
deleteExpense(orgId, id) Delete pending only

3.3 ContactService

File: apps/api/src/services/contact.service.ts

Method Description
listContacts(orgId, params) Paginated list with type filter
getContact(orgId, id) Single contact
createContact(orgId, data) Create contact
updateContact(orgId, id, data) Update contact
deleteContact(orgId, id) Soft delete (isActive = false)

3.4 AccountService

File: apps/api/src/services/account.service.ts

Method Description
listAccounts(orgId, params) List with optional balance calculation
createAccount(orgId, data) Create account (checks code uniqueness)
updateAccount(orgId, id, data) Update account metadata

3.5 ReportService

File: apps/api/src/services/report.service.ts

Method Description
getDashboard(orgId) Aggregate MTD metrics
getProfitLoss(orgId, query) Revenue vs expense by account, net profit
getBalanceSheet(orgId, query) Assets, liabilities, equity as of date
getCashFlow(orgId, query) Operating/investing/financing cash flows
getVATReport(orgId, query) Output VAT (invoices) vs input VAT (expenses), net
getTrialBalance(orgId, query) All accounts with debit/credit totals, balanced check
getGeneralLedger(orgId, query) Per-account transaction history

3.6 BankingService

File: apps/api/src/services/banking.service.ts

Method Description
listBankAccounts(orgId) List all active bank accounts
getBankAccount(orgId, id) Single account with recent transactions
createBankAccount(orgId, data) Create bank account linked to GL account
listBankTransactions(orgId, bankAccountId, params) Paginated bank transactions
importBankStatement(orgId, bankAccountId, csvContent) Parse CSV, detect duplicates, insert
reconcileTransaction(orgId, bankAccountId, body) Match bank transaction to GL transaction

3.7 SettingsService

File: apps/api/src/services/settings.service.ts

Method Description
getOrganization(orgId) Organization details
updateOrganization(orgId, data) Update organization metadata
listUsers(orgId, params) List users in org
inviteUser(orgId, data) Create user with temporary password
changeUserRole(orgId, userId, requesterId, data) Change role (cannot demote self)
deleteUser(orgId, userId, requesterId) Remove user (cannot delete self)
listCurrencies() All active currencies
getExchangeRate(params) Get rate for currency pair on date
getTaxRates(orgId) Get org tax rate config
updateTaxRates(orgId, data) Update tax rate config

4. Middleware Stack

File: apps/api/src/app.ts

Order is critical. Each middleware passes control to next() or sends error response.

Request
   │
   ▼
1. helmet()              — Sets security headers (CSP, HSTS, X-Frame-Options: deny, noSniff)
   │
   ▼
2. cors()                — Validates Origin header against whitelist [bilko.io, localhost:3000]
   │                       credentials: true (allows cookies)
   ▼
3. express.json()        — Parses request body as JSON (limit: 10mb)
   │
   ▼
4. express.urlencoded()  — Parses URL-encoded bodies (limit: 10mb)
   │
   ▼
5. cookieParser()        — Parses cookie header, makes cookies accessible via req.cookies
   │
   ▼
6. apiLimiter            — Rate limit: 100 req per 15 min per IP (applied to /api/v1/*)
   │   authLimiter       — Stricter rate limit on /auth/login and /auth/register
   ▼
7. routes                — Mounts all route modules at /api/v1
   │
   ├── authGuard()       — Verifies JWT Bearer token, attaches req.user
   │                       Source: apps/api/src/middleware/auth.ts
   │
   ├── organizationScope() — Validates req.user.organizationId (currently no-op, used as anchor)
   │                         Source: apps/api/src/middleware/org-scope.ts
   │
   ├── validate(schema)  — Validates req.body or req.query against Zod schema
   │                       Source: apps/api/src/middleware/validate.ts
   │
   └── routeHandler      — Business logic (calls service layer)
   │
   ▼
8. errorHandler()        — Centralized error handler (MUST be last)
                           Source: apps/api/src/middleware/error-handler.ts
                           Translates AppError → HTTP status + JSON

Error response format:

{
  "error": "Invoice not found",
  "code": "NOT_FOUND",
  "details": {}
}

HTTP status codes used:


5. Double-Entry Bookkeeping Implementation

Core library: packages/core/src/accounting/index.ts Prisma model: Transaction in packages/database/prisma/schema.prisma

5.1 Fundamental Rule

Every financial event creates exactly one Transaction record with:

5.2 validateDoubleEntry() (core engine)

// packages/core/src/accounting/index.ts
export function validateDoubleEntry(lines: JournalEntryLine[]): boolean {
  // Returns false if: < 2 lines, negative amounts, unbalanced
  let totalDebits = new Decimal(0)
  let totalCredits = new Decimal(0)
  for (const line of lines) {
    const amount = new Decimal(line.amount)
    if (amount.lte(0)) return false
    if (line.side === 'debit') totalDebits = totalDebits.plus(amount)
    else totalCredits = totalCredits.plus(amount)
  }
  return totalDebits.eq(totalCredits)
}

5.3 Transaction Creation Patterns

Invoice sent (DR Receivable / CR Revenue):

DR  Accounts Receivable (code: 12x)   +120,000 RSD
    CR  Revenue (code: 6xx)                       +120,000 RSD

Payment received (DR Bank / CR Receivable):

DR  Bank Account (code: 10x)          +120,000 RSD
    CR  Accounts Receivable (code: 12x)            +120,000 RSD

Expense approved (DR Expense / CR Payable):

DR  Expense Account (code: 5xx)       +5,000 RSD
    CR  Accounts Payable (code: 22x)               +5,000 RSD

Expense paid (DR Payable / CR Bank):

DR  Accounts Payable (code: 22x)      +5,000 RSD
    CR  Bank Account (code: 10x)                    +5,000 RSD

5.4 Account Lookup Strategy

Services find accounts by account type ID + code prefix:

Code prefixes (Balkan chart of accounts):

5.5 Trial Balance

// packages/core/src/accounting/index.ts
export function calculateTrialBalance(transactions: JournalEntry[]): TrialBalance {
  // Groups by accountNumber, sums debits and credits
  // Returns: { rows[], totalDebits, totalCredits, isBalanced }
  // isBalanced = totalDebits.eq(totalCredits)
}

6. Tax Calculation Logic Per Country

Core module: packages/core/src/tax/index.ts Country modules: packages/country-{rs|ba|hr}/src/tax/index.ts

6.1 Serbia (RS)

File: packages/country-rs/src/tax/index.ts

Rate Value Applies To
Standard 20% Most taxable supplies
Reduced 10% Basic food, medicine, newspapers, public transport, utilities
Zero/Exempt 0% Exports, international transport, financial services
export const serbianVATRates = {
  standard: '20',
  reduced: '10',
  zero: '0',
  exempt: '0',
}

// VAT registration: mandatory above 8M RSD annual revenue
export const SERBIAN_VAT_THRESHOLD = '8000000'
// Pausal (simplified) regime: below 6M RSD
export const SERBIAN_PAUSAL_THRESHOLD = '6000000'
// Corporate income tax
export const SERBIAN_CIT_RATE = '15' // flat 15%

Key function:

export function calculateSerbianPDV(amount: MonetaryAmount, rate = 'standard'): string {
  // Returns: net.times(rateDecimal).dividedBy(100).toFixed(2)
}

6.2 Bosnia & Herzegovina (BA)

File: packages/country-ba/src/tax/index.ts

Rate Value Applies To
Standard 17% All taxable supplies (single rate, no reduced)
Zero 0% Exports
export const bosnianVATRates = { standard: '17', zero: '0' }
// Registration threshold: 100,000 BAM
export const BIH_VAT_THRESHOLD = '100000';
// CIT: 10% for both FBiH and RS entities
export const BIH_CIT_RATES = { fbih: '10', rs: '10' }
// WHT: FBiH dividends 5%, RS dividends 10%
export const BIH_WHT_RATES = { fbih: { dividends: '5', interest: '10' }, rs: { dividends: '10', ... } }

6.3 Croatia (HR)

File: packages/country-hr/src/tax/index.ts

Rate Value Applies To
Standard 25% Most taxable supplies
Reduced 13% Food products, accommodation, utilities
Super-reduced 5% Books, medicines, newspapers
Zero 0% Intra-EU transport, international transport
export const croatianVATRates = { standard: '25', reduced: '13', superReduced: '5', zero: '0' }
// Registration threshold: 60,000 EUR (aligned with EU 2025)
export const CROATIAN_VAT_THRESHOLD = '60000'
// CIT: progressive — 10% if revenue < 1M EUR, 18% if >= 1M EUR
export const CROATIAN_CIT_RATES = { small: '10', standard: '18', threshold: '1000000' }

6.4 Generic VAT Calculation (Core Engine)

// packages/core/src/tax/index.ts
export function calculateVAT(amount: MonetaryAmount, rate: MonetaryAmount): VATResult {
  const base = new Decimal(amount);       // net amount
  const vatRate = new Decimal(rate);
  const tax = base.times(vatRate).dividedBy(100);
  const total = base.plus(tax);
  return {
    base: new Decimal(base.toFixed(4)),
    tax: new Decimal(tax.toFixed(4)),
    total: new Decimal(total.toFixed(4)),
  };
}

export function calculateNetFromGross(grossAmount: MonetaryAmount, vatRate: MonetaryAmount): VATResult {
  // Reverse VAT: gross / (1 + rate/100)
  const divisor = new Decimal(100).plus(new Decimal(vatRate)).dividedBy(100);
  const base = new Decimal(grossAmount).dividedBy(divisor);
  ...
}

7. Invoice Lifecycle

7.1 Status Machine

draft ──[send]──► sent ──[mark-paid]──► paid
  │                 │
  │            [cancel]
  │                 │
  └────[cancel]──► cancelled
                sent ──[overdue cron]──► overdue ──[mark-paid]──► paid
                viewed ──[mark-paid]──► paid

7.2 Numbering

Invoice numbers are generated sequentially per organization per year: INV-YYYY-NNN (e.g., INV-2026-001). The service queries the last invoice number with the current year prefix and increments.

private async generateInvoiceNumber(organizationId: string): Promise<string> {
  const year = new Date().getFullYear();
  const prefix = `INV-${year}-`;
  const lastInvoice = await prisma.invoice.findFirst({
    where: { organizationId, invoiceNumber: { startsWith: prefix } },
    orderBy: { invoiceNumber: 'desc' },
  });
  const nextNumber = lastInvoice ? parseInt(lastInvoice.invoiceNumber.split('-')[2]) + 1 : 1;
  return `${prefix}${String(nextNumber).padStart(3, '0')}`;
}

7.3 Amount Calculation

On create/update:

  1. For each line item: lineTotal = quantity × unitPrice
  2. taxAmount per line: lineTotal × taxRate / 100
  3. subtotal = Σ lineTotals
  4. taxAmount = Σ lineTax amounts
  5. totalAmount = subtotal + taxAmount
  6. baseAmount = totalAmount × exchangeRate

All using Decimal.js — never JavaScript number.


8. Bank Import Flow

Source: packages/core/src/bank-import/index.ts

8.1 CSV Format

Date,Amount,Currency,Direction,Counterparty,Reference,Description
2026-02-01,5000.00,RSD,inbound,Acme Client,INV-2026-001,Invoice payment

Supported date formats: YYYY-MM-DD, DD.MM.YYYY, DD/MM/YYYY

8.2 Import Process

POST /api/v1/bank-accounts/:id/import
  │
  ├── parseCSV(csvContent) → BankTransaction[]
  │     - Split by newline, skip header
  │     - Parse each field: date, amount, currency, direction, reference
  │     - Generate deterministic ID for dedup: hash(date|amount|currency|reference|lineIndex)
  │
  ├── detectDuplicates(existingTxs, importedTxs)
  │     - Fingerprint: YYYY-MM-DD|amount|currency|reference
  │     - Returns list of duplicate transactions
  │
  ├── Filter out duplicates
  │
  └── Insert new BankTransactions into database
        Returns: { imported: N, duplicates: M, errors: K }

8.3 Reconciliation

Manual reconciliation links a BankTransaction to a Transaction (GL entry):

POST /api/v1/bank-accounts/:id/reconcile
  body: { bankTransactionId, transactionId }
  │
  ├── Verify both belong to organization
  ├── Set BankTransaction.reconciled = true
  ├── Set BankTransaction.matchedTransactionId = transactionId
  └── Set Transaction.reconciled = true

9. Core Engine Modules

Package: @bilko/core (packages/core/src/)

Module File Purpose
accounting src/accounting/index.ts validateDoubleEntry, createJournalEntry, calculateTrialBalance
tax src/tax/index.ts calculateVAT, calculateNetFromGross, getVATRates, calculateCIT
multi-currency src/multi-currency/index.ts convertCurrency, lockExchangeRate, calculateForexGainLoss
bank-import src/bank-import/index.ts parseCSV, detectDuplicates
invoicing src/invoicing/index.ts Invoice computation helpers
chart-of-accounts src/chart-of-accounts/index.ts Chart structure definitions
reporting src/reporting/index.ts Report calculation utilities

Key constraint: MonetaryAmount = string | Decimal — JavaScript number is never used for monetary values anywhere in the core engine.


10. Cross-Reference Notes (Schema Verification 2026-02-23)

This document was cross-checked against the actual codebase on 2026-02-23. Notes:

10.1 Implementation Status

Layer Status Notes
packages/database/prisma/schema.prisma Implemented — verified 15 models, all types and indexes match this LLD
apps/api/ Not yet implemented Sections 1–3 are target design specs, not live code
apps/web/ Implemented with mock data All pages exist; data sourced from lib/mock-data.ts until API is ready
packages/core/ Partially implemented Module structure matches Section 9; some stubs may be incomplete

10.2 Schema Discrepancies Found

Location LLD Says Actual Schema Action Required
LoggedAction.applicationName Default: "bilko-api" Default: "fiken-clone-api" (legacy project name) Fix before first deploy — update default in schema migration
Invoice.invoiceNumber Described as globally UNIQUE @@unique([organizationId, invoiceNumber]) composite No fix needed — composite is correct; LLD description imprecise
Expense.expenseNumber Described as globally UNIQUE @@unique([organizationId, expenseNumber]) composite No fix needed — composite is correct
BankTransaction No currencyCode field noted No currencyCode in schema Verify if currency tracking needed for bank transactions; may need migration

10.3 Cross-References

Topic Document
Hosting decision (Vercel + Railway, not AWS) docs/architecture/ADR.md — ADR-010
Database schema detailed spec docs/architecture/DATABASE-SCHEMA-DOCUMENT.md
API endpoint specification (OpenAPI) docs/architecture/API-SPECIFICATION.md
@bilko/core module design docs/architecture/MODULE-DESIGN.md
Data flow & retention policy docs/architecture/DATA-FLOW.md
External integrations (SEF, eRačun, SendGrid) docs/architecture/INTEGRATION-DESIGN.md

Architecture Decision Records (ADR)

Architecture Decision Records — Bilko

Project: Bilko Version: 1.1 Date: 2026-02-24 Author: Petter Graff (ADR-001 to ADR-006), Security-Test-Writer Agent (ADR-007 to ADR-013) Status: Active

Document History

Version Date Author Changes
0.1 2026-02-23 Petter Graff Initial draft — ADR-001 to ADR-006
1.1 2026-02-24 Security-Test-Writer Added ADR-007 to ADR-013 — auth, state, framework, hosting, ORM, validation, router

ADR-001 — Turborepo Monorepo with Separate Packages

ADR Number: ADR-001 Title: Use Turborepo monorepo with separate npm packages for core and country plugins Date: 2026-02-23 Author: Petter Graff Status: Accepted

1. Context

1.1 Situation

Bilko is a multi-country accounting SaaS with: (1) a Next.js frontend, (2) an Express backend, (3) a country-agnostic accounting engine, and (4) three country-specific regulatory plugins. These components share TypeScript types and business logic but must be independently versioned and testable.

1.2 Forces & Constraints

Technical forces:

Business forces:

1.3 Problem Statement

We need to decide: How to organize the Bilko codebase so that frontend, backend, accounting engine, and country plugins share code but can be independently versioned and tested.

2. Decision

We will: Use Turborepo to manage a monorepo with the structure apps/web, apps/api, packages/core, packages/database, packages/country-{rs|ba|hr}, packages/ui.

Rationale: Turborepo provides incremental builds, shared TypeScript configuration, and workspace management without the overhead of publishing packages to npm. The plugin architecture is enforced at the package boundary — country plugins import @bilko/core but not each other.

3. Alternatives Considered

Option A: Turborepo Monorepo ← Selected

Pros:

Cons:

Cost/Effort: Low — Turborepo setup is ~1 day

Option B: Separate Git Repositories (polyrepo)

Pros:

Cons:

Why not chosen: Type sharing overhead is too high for MVP pace.

Option C: Single Package (everything in one apps/api and apps/web)

Pros:

Cons:

Why not chosen: Violates separation of concerns; makes future product reuse impossible.

4. Consequences

4.1 Positive Consequences

4.2 Negative Consequences

4.3 Technical Debt Created


ADR-002 — NUMERIC(19,4) and Decimal.js for All Monetary Values

ADR Number: ADR-002 Title: Use PostgreSQL NUMERIC(19,4) and Decimal.js for all monetary amounts — never IEEE 754 float Date: 2026-02-23 Author: Petter Graff Status: Accepted

1. Context

1.1 Situation

Bilko is an accounting system. Every stored value represents money. JavaScript's native number type uses IEEE 754 double-precision floating point, which cannot represent 0.1 exactly — 0.1 + 0.2 === 0.30000000000000004. This is catastrophic for financial software where rounding errors cause balance mismatches that fail audits.

1.2 Forces & Constraints

Technical forces:

Regulatory:

1.3 Problem Statement

We need to decide: What data type to use for all monetary values in the database and application layer.

2. Decision

We will: Store all monetary values as NUMERIC(19,4) in PostgreSQL and use Decimal.js (via Prisma's Decimal type) in all application code. JavaScript number is prohibited for any value representing money.

Rationale: NUMERIC(19,4) can represent amounts up to 999,999,999,999,999.9999 (sufficient for SMB range). Decimal.js provides arbitrary-precision arithmetic. The combination eliminates all floating-point rounding errors.

3. Alternatives Considered

Option A: NUMERIC(19,4) + Decimal.js ← Selected

Pros:

Cons:

Option B: Store as integers (minor units — e.g., paras/cents)

Pros:

Cons:

Why not chosen: 4-decimal-place requirement makes minor-unit storage awkward.

Option C: JavaScript number / PostgreSQL DOUBLE PRECISION

Pros:

Cons:

Why not chosen: Fundamentally incompatible with financial software requirements.

4. Consequences

4.1 Positive Consequences

4.2 Negative Consequences


ADR-003 — Double-Entry Bookkeeping as the Core Transaction Model

ADR Number: ADR-003 Title: Use double-entry bookkeeping as the sole transaction model — every financial event creates one Transaction with debit and credit Date: 2026-02-23 Author: Petter Graff Status: Accepted

1. Context

1.1 Situation

Bilko must generate legally valid accounting reports: profit & loss, balance sheet, trial balance, VAT return. These reports require a general ledger where every financial event is recorded as a balanced journal entry. Many SaaS products use simpler "append events to a list" approaches that work for dashboards but fail for formal accounting.

1.2 Forces & Constraints

Regulatory:

Technical forces:

1.3 Problem Statement

We need to decide: How to store financial events — simple event list vs. double-entry journal.

2. Decision

We will: Use double-entry bookkeeping as the sole transaction model. Every financial event creates exactly one Transaction record with debitAccountId, creditAccountId, and amount. The validateDoubleEntry() function in @bilko/core is called before every Prisma transaction to enforce balance.

3. Alternatives Considered

Option A: Double-Entry Bookkeeping ← Selected

Pros:

Cons:

Option B: Simple Ledger (credit/debit as +/- amounts, single account per row)

Pros:

Cons:

Why not chosen: Regulatory non-compliance disqualifies this option.

Option C: No ledger — compute reports directly from invoices/expenses tables

Pros:

Cons:

Why not chosen: Insufficient for formal accounting; blocks future chartered accountant use.

4. Consequences

4.1 Positive Consequences

4.2 Technical Debt Created


ADR-004 — Lock Exchange Rates at Transaction Date

ADR Number: ADR-004 Title: Lock exchange rates at transaction date — never recalculate historical amounts Date: 2026-02-23 Author: Petter Graff Status: Accepted

1. Context

1.1 Situation

Bilko supports multi-currency invoicing (EUR, RSD, BAM, HRK, USD). When an invoice is created in a foreign currency, it must be converted to the organization's base currency for the general ledger. Exchange rates change daily. The question is: should historical amounts be recalculated when rates change, or locked at transaction date?

1.2 Forces & Constraints

Regulatory:

Technical forces:

1.3 Problem Statement

We need to decide: Whether to lock exchange rates at transaction date or recalculate dynamically.

2. Decision

We will: Lock the exchange rate at invoiceDate (or expenseDate) and store it permanently in the exchangeRate field. baseAmount = totalAmount × exchangeRate is computed once and stored. Neither field is ever updated after creation.

3. Alternatives Considered

Option A: Lock at Transaction Date ← Selected

Pros:

Cons:

Option B: Recalculate on every report

Pros:

Cons:

Why not chosen: Regulatory non-compliance.

4. Consequences

4.1 Positive Consequences

4.2 Negative Consequences


ADR-005 — Organization-Scoped Multi-Tenancy via Middleware

ADR Number: ADR-005 Title: Enforce multi-tenancy at the API middleware layer (organizationScope) — not via PostgreSQL RLS Date: 2026-02-23 Author: Petter Graff Status: Accepted

1. Context

1.1 Situation

Bilko is a multi-tenant SaaS. Every database record (invoices, expenses, transactions, etc.) belongs to one organization. Cross-organization data access would be a critical security breach. There are two primary approaches: enforce isolation in the database (RLS) or in the application (middleware).

1.2 Forces & Constraints

Technical forces:

Business forces:

1.3 Problem Statement

We need to decide: Where to enforce that users can only access data from their own organization.

2. Decision

We will: Enforce organization scoping via the organizationScope Express middleware (apps/api/src/middleware/org-scope.ts), which attaches req.user.organizationId from the JWT. All service methods receive organizationId as first parameter and all Prisma queries include where: { organizationId }.

3. Alternatives Considered

Option A: Application-layer middleware ← Selected

Pros:

Cons:

Option B: PostgreSQL Row-Level Security (RLS)

Pros:

Cons:

Why not chosen: Prisma integration complexity and team expertise gap. Can be added post-MVP.

4. Consequences

4.1 Positive Consequences

4.2 Negative Consequences

4.3 Technical Debt Created


ADR-006 — Country Plugin Architecture as Separate npm Packages

ADR Number: ADR-006 Title: Implement country-specific accounting rules as separate Turborepo packages with a shared interface Date: 2026-02-23 Author: Petter Graff Status: Accepted

1. Context

1.1 Situation

Bilko targets three countries with different VAT rates, tax thresholds, e-invoice platforms, chart of accounts templates, and filing deadlines. These rules change independently (e.g., Serbia changed e-invoice rules in 2023; Croatia mandated eRačun from Jan 2026). Rules must be extensible to future markets (Slovenia, North Macedonia) without modifying core accounting logic.

1.2 Problem Statement

We need to decide: How to organize country-specific accounting rules to allow independent versioning and easy extensibility.

2. Decision

We will: Implement each country as a separate Turborepo package (@bilko/country-rs, @bilko/country-ba, @bilko/country-hr) with a standard module structure: tax/, chart/, fiscal/, filing/, locale/, index.ts. The API selects the plugin at runtime based on org.country.

3. Alternatives Considered

Option A: Separate packages per country ← Selected

Pros:

Cons:

Option B: Single @bilko/countries package with all countries in subdirectories

Pros:

Cons:

Why not chosen: Independent versioning is critical given different regulatory cadences per country.

Option C: Country rules stored in database (configurable per org)

Pros:

Cons:

Why not chosen: Regulatory logic (e-invoice XML, fiscal year rules) cannot be stored as configuration.

4. Consequences

4.1 Positive Consequences

4.2 Negative Consequences


ADR-007 — JWT for Authentication

ADR Number: ADR-007 Title: Use JWT (RS256) with short-lived access tokens and httpOnly refresh tokens — no server-side sessions Date: 2026-02-24 Author: Security-Test-Writer Agent Status: Accepted

1. Context

1.1 Situation

Bilko's API must authenticate users on every request. The system must be stateless for horizontal scaling. Tokens must be secure against XSS and CSRF attacks. The mobile PWA requires a token-based approach (cookies work; sessions require sticky sessions).

1.2 Forces & Constraints

Technical forces:

Security forces:

Business forces:

1.3 Problem Statement

We need to decide: How to authenticate API requests — JWT, session-based, or delegated OAuth.

2. Decision

We will: Use RS256-signed JWT access tokens (15 min TTL, stored in memory by the client) and RS256-signed refresh tokens (7 days, stored as httpOnly; SameSite=Strict cookie). Access tokens contain sub, email, organizationId, role, iat, exp. No server-side session store is required.

Implementation: apps/api/src/utils/jwt.ts (sign/verify), apps/api/src/middleware/auth.ts (authGuard middleware).

3. Alternatives Considered

Option A: JWT (RS256, access + refresh) ← Selected

Pros:

Cons:

Option B: Session-based authentication (server-side sessions)

Pros:

Cons:

Why not chosen: Requires Redis which is not in the MVP stack (ADR-010).

Option C: Delegated OAuth (Auth0, Clerk, Supabase Auth)

Pros:

Cons:

Why not chosen: Cost and external dependency at MVP stage.

4. Consequences

4.1 Positive Consequences

4.2 Negative Consequences

4.3 Technical Debt Created


ADR-008 — Zustand for Frontend State Management

ADR Number: ADR-008 Title: Use Zustand for minimal global frontend state — React hooks for local component state Date: 2026-02-24 Author: Security-Test-Writer Agent Status: Accepted

1. Context

1.1 Situation

The Next.js frontend (apps/web) needs to share state across components: authenticated user info, current organization, UI preferences (sidebar open/closed, theme). Most application data is server-fetched per page — only a small slice of global state is needed in the client.

1.2 Forces & Constraints

Technical forces:

Business forces:

1.3 Problem Statement

We need to decide: What library to use for global client-side state management in the Next.js frontend.

2. Decision

We will: Use Zustand 4.5 for a minimal global store containing { user, organization, accessToken, setUser, clearAuth }. All per-page data is fetched server-side in RSCs or client-side with React hooks. Zustand is NOT used as a substitute for server-fetched data.

3. Alternatives Considered

Option A: Zustand ← Selected

Pros:

Cons:

Option B: Redux Toolkit (RTK)

Pros:

Cons:

Why not chosen: Bundle overhead and boilerplate disproportionate to MVP state needs.

Option C: React Context API

Pros:

Cons:

Why not chosen: Performance characteristics unsuitable for auth state shared across many components.

Option D: Jotai / Recoil

Pros:

Cons:

Why not chosen: Zustand is simpler and better supported for our use case.

4. Consequences

4.1 Positive Consequences

4.2 Negative Consequences


ADR-009 — Express for Backend API Framework

ADR Number: ADR-009 Title: Use Express.js with TypeScript as the backend API framework Date: 2026-02-24 Author: Security-Test-Writer Agent Status: Accepted

1. Context

1.1 Situation

Bilko's backend API (apps/api) handles all accounting operations. The framework must support TypeScript, middleware composition (auth, validation, rate limiting), and have a mature ecosystem for the integrations needed (Prisma, Zod, Helmet, express-rate-limit).

1.2 Forces & Constraints

Technical forces:

Business forces:

1.3 Problem Statement

We need to decide: Which Node.js HTTP framework to use for the Express API.

2. Decision

We will: Use Express 4.x with TypeScript. Middleware stack: helmet → cors → json → rate-limit → auth → validate → handler → error-handler. Route modules in apps/api/src/routes/, service layer in apps/api/src/services/.

3. Alternatives Considered

Option A: Express.js ← Selected

Pros:

Cons:

Option B: Fastify

Pros:

Cons:

Why not chosen: Performance advantage irrelevant at MVP scale; ecosystem gaps outweigh speed benefit.

Option C: NestJS

Pros:

Cons:

Why not chosen: Overkill for MVP. Complexity outweighs conventions benefit for a small team.

Option D: Hono

Pros:

Cons:

Why not chosen: Unproven at scale for financial SaaS; ecosystem too immature.

4. Consequences

4.1 Positive Consequences

4.2 Technical Debt Created


ADR-010 — Vercel + Railway for Hosting

ADR Number: ADR-010 Title: Use Vercel for frontend hosting and Railway for backend API + PostgreSQL — no self-managed infrastructure at MVP Date: 2026-02-24 Author: Security-Test-Writer Agent Status: Accepted

1. Context

1.1 Situation

Bilko needs hosting for: (1) Next.js 15 frontend, (2) Express API, (3) PostgreSQL database, (4) file storage (Cloudflare R2). The team is small; infrastructure management overhead must be minimal. GDPR compliance requires EU data residency.

1.2 Forces & Constraints

Business forces:

Technical forces:

1.3 Problem Statement

We need to decide: Where to host the Bilko MVP — managed platforms vs. cloud VMs vs. self-hosted.

2. Decision

We will: Deploy the Next.js frontend to Vercel (Hobby → Pro tier) and the Express API + PostgreSQL to Railway (EU Frankfurt region). Cloudflare R2 for file storage (PDF invoices, receipts).

Cost breakdown:

Component Service Cost
Frontend Vercel €0 (Hobby) → €20 (Pro)
API + PostgreSQL Railway Starter €5–20/mo
File storage Cloudflare R2 ~€1/mo
Total MVP ~€21/mo

3. Alternatives Considered

Option A: Vercel + Railway ← Selected

Pros:

Cons:

Option B: AWS (EC2 + RDS + CloudFront + Route 53)

Pros:

Cons:

Why not chosen: 4-7x more expensive at MVP scale; ops overhead unacceptable for small team.

Option C: GCP (Cloud Run + Cloud SQL + Firebase Hosting)

Pros:

Cons:

Why not chosen: Team unfamiliarity; less Next.js optimization than Vercel.

Option D: Self-hosted (Hetzner VPS / dedicated)

Pros:

Cons:

Why not chosen: Ops burden unacceptable for a 2-person team at MVP stage.

4. Consequences

4.1 Positive Consequences

4.2 Negative Consequences

4.3 Scaling Path


ADR-011 — Prisma as the ORM

ADR Number: ADR-011 Title: Use Prisma 5.x as the ORM with schema-as-code and generated TypeScript client Date: 2026-02-24 Author: Security-Test-Writer Agent Status: Accepted

1. Context

1.1 Situation

Bilko needs type-safe database access to PostgreSQL with: NUMERIC(19,4) for monetary values, UUID primary keys, Prisma-managed migrations, and Prisma middleware for the LoggedAction audit trail.

1.2 Forces & Constraints

Technical forces:

1.3 Problem Statement

We need to decide: Which ORM or database access library to use for Bilko's PostgreSQL interactions.

2. Decision

We will: Use Prisma 5.x (packages/database/prisma/schema.prisma). Prisma generates the TypeScript client (@bilko/database), manages migrations, and maps Decimal fields to NUMERIC(19,4).

3. Alternatives Considered

Option A: Prisma ← Selected

Pros:

Cons:

Option B: Drizzle ORM

Pros:

Cons:

Why not chosen: Less mature migration tooling at time of decision; Prisma's @db.Decimal is a better fit for NUMERIC(19,4).

Option C: TypeORM

Pros:

Cons:

Why not chosen: Inferior TypeScript DX compared to Prisma; decorator-heavy pattern conflicts with functional service layer.

Option D: Knex.js (query builder)

Pros:

Cons:

Why not chosen: No type generation means maintaining types manually — unacceptable for a 15-model schema.

4. Consequences

4.1 Positive Consequences

4.2 Negative Consequences


ADR-012 — Zod for Request Validation

ADR Number: ADR-012 Title: Use Zod 3.x for all API request validation with full TypeScript type inference Date: 2026-02-24 Author: Security-Test-Writer Agent Status: Accepted

1. Context

1.1 Situation

All user input entering the Bilko API must be validated before processing. Validation must: (1) reject invalid data with field-level error messages, (2) provide TypeScript types from the schema without duplication, (3) be composable across endpoints (reuse sub-schemas).

1.2 Forces & Constraints

Technical forces:

Security forces:

1.3 Problem Statement

We need to decide: Which validation library to use for API request body/query validation.

2. Decision

We will: Use Zod 3.x for all request validation. Zod schemas are defined per endpoint and composed from shared sub-schemas (e.g., monetaryAmountSchema, isoDateSchema). The validate(schema) middleware in apps/api/src/middleware/validate.ts calls schema.parse(req.body) and passes validated, typed data to route handlers.

// Example: createInvoiceSchema
const createInvoiceSchema = z.object({
  customerId: z.string().uuid(),
  invoiceDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
  dueDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
  currencyCode: z.string().length(3),
  items: z.array(z.object({
    description: z.string().min(1).max(500),
    quantity: z.number().positive(),
    unitPrice: z.number().min(0),
    taxRate: z.number().min(0).max(100),
    accountId: z.string().uuid().optional(),
  })).min(1),
});
type CreateInvoiceRequest = z.infer<typeof createInvoiceSchema>;

3. Alternatives Considered

Option A: Zod ← Selected

Pros:

Cons:

Option B: Yup

Pros:

Cons:

Why not chosen: Inferior TypeScript inference — requires manual type maintenance.

Option C: Joi

Pros:

Cons:

Why not chosen: Not TypeScript-native; dual maintenance of types and schemas.

Option D: class-validator + class-transformer

Pros:

Cons:

Why not chosen: Requires class instance pattern — incompatible with functional service layer.

4. Consequences

4.1 Positive Consequences

4.2 Negative Consequences


ADR-013 — Next.js App Router

ADR Number: ADR-013 Title: Use Next.js 15 App Router with React Server Components — not Pages Router, Remix, or SvelteKit Date: 2026-02-24 Author: Security-Test-Writer Agent Status: Accepted

1. Context

1.1 Situation

The Bilko frontend is built on Next.js 15. Next.js offers two routing systems: the legacy Pages Router and the modern App Router (stable since Next.js 13, production-ready since Next.js 14). The choice affects how data is fetched, how layouts are composed, and whether React Server Components (RSC) can be used.

1.2 Forces & Constraints

Technical forces:

Business forces:

1.3 Problem Statement

We need to decide: Whether to use Next.js App Router or Pages Router (and whether Next.js is the right choice vs. alternative frameworks).

2. Decision

We will: Use Next.js 15 App Router with the following patterns:

3. Alternatives Considered

Option A: Next.js App Router ← Selected

Pros:

Cons:

Option B: Next.js Pages Router

Pros:

Cons:

Why not chosen: Legacy approach; loses RSC performance benefits; will require migration later anyway.

Option C: Remix

Pros:

Cons:

Why not chosen: Team familiarity with Next.js; Vercel deployment optimization; RSC is a better long-term bet.

Option D: SvelteKit

Pros:

Cons:

Why not chosen: Full framework switch — team TypeScript/React expertise would not transfer; no shared type system with backend.

4. Consequences

4.1 Positive Consequences

4.2 Negative Consequences

4.3 Technical Debt Created


Approval

Role Name Date Signature
Author (ADR-001–006) Petter Graff 2026-02-23
Author (ADR-007–013) Security-Test-Writer Agent 2026-02-24
Tech Lead
CTO / Architect Alem Bašić

Competitive Research

Bilko — Competitive Research

Version: 2.0 Date: 2026-02-24 Researcher: Bilko Docs Team (AI-assisted research, February 2026) Status: Current


Table of Contents

  1. Executive Summary
  2. Competitor Profiles
  3. Feature Comparison Matrix
  4. Pricing Comparison Matrix
  5. API Pattern Analysis
  6. Security Comparison
  7. Balkan Market Gap Analysis
  8. Key Takeaways for Bilko Positioning

1. Executive Summary

Bilko is a cloud accounting SaaS for Balkan SMBs (Serbia, BiH, Croatia). The competitive landscape reveals a clear market gap: no global accounting software natively supports SEF (Serbia), UIO VAT (BiH), or HR-FISK (Croatia) compliance out of the box. Regional solutions exist (Minimax, e-racuni, Eurofaktura) but lack modern SaaS UX or multi-country coverage.

Primary inspiration: Fiken (Norway) — simple, beautiful, built for local compliance. Bilko = Fiken for the Balkans.

Key February 2026 regulatory updates:

Key opportunities from this research:

  1. Global players (Zoho Books, Wave, FreshBooks) have zero Balkan compliance features — high switching cost for current users
  2. Fiken's success with a simple flat pricing model at ~€18/month proves SMBs pay for simplicity + compliance
  3. FreeAgent's bank integration model (free with partner bank) is a growth hack applicable to Balkan banking partners
  4. No competitor offers native SEF + HR-FISK + UIO (BiH) in a single product
  5. Wave's freemium to paid conversion playbook is directly applicable (free invoicing → paid for bank feeds + payroll)
  6. New threat: Minimax has Croatia (Fiskalizacija 2.0) and Serbia subsidiaries — first regional competitor with modern cloud UX + Balkan compliance. Bilko must position against them.

2. Competitor Profiles

2.1 Fiken (Norway)

Overview: The direct inspiration for Bilko. Fiken is Norway's leading cloud accounting SaaS for small businesses and freelancers. Built from the ground up for Norwegian compliance (Altinn VAT, EHF e-invoicing, payroll). Acquired by Visma in 2020 but operates independently.

Company: Fiken AS, Oslo, Norway. Founded 2010. ~50K+ Norwegian SMB customers.

Core Features

Feature Details
Invoicing Professional invoices via EHF (Elektronisk HandelsFormat), email, or paper. Quotes. Payment reminders.
Expenses Electronic voucher storage, OCR receipt scanning
VAT Automated VAT calculations + direct electronic submission to Altinn
Banking Bank statement import; bank reconciliation (bank integration add-on)
Payroll Full payroll with payslips via email, A-melding reporting (add-on)
Reports Income statement (P&L), balance sheet, cash flow
Chart of Accounts Norwegian standard (NS 4102) pre-loaded
E-invoicing EHF format — Norway's national e-invoice standard
Time tracking Add-on per user
API access Available as paid add-on

Pricing (Updated Feb 2026)

Major change: Fiken moved from a two-tier model (Solo / Standard) to a single flat plan at NOK 209/month with modular add-ons. The old Solo plan at NOK 139 has been discontinued.

Plan Price Notes
Fiken NOK 209/month (~€18.50) All-inclusive base — unlimited invoices, multiple users, EHF
No binding contract Cancel anytime
Free trial 30 days No credit card required

Add-ons (monthly):

Add-on Price
Bank integration NOK 59/month (~€5)
Project accounting NOK 59/month
KID number NOK 59/month
API access NOK 99/month (~€9)
Time tracking NOK 59/user/month
Travel expenses/receipts NOK 59/user/month
Payroll (1st employee) NOK 79/month
Additional employees NOK 39/employee/month

Annual services:

Service Price
Tax return filing (sole proprietor) NOK 1,290 one-time
Tax return filing (AS/corporation) NOK 1,490 one-time

API

Security

Aspect Details
2FA Available (not enforced by default)
Encryption TLS in transit, encrypted at rest
Hosting Norwegian data centers (Visma infrastructure)
Compliance GDPR compliant; Norwegian Datatilsynet oversight
Certifications ISO 27001 (Visma group)

Compliance Coverage

Strengths vs. Bilko

Weaknesses (Bilko Opportunities)


2.2 FreeAgent (UK)

Overview: UK-focused cloud accounting SaaS, strong accountant partnership model. Notable for free access via NatWest/RBS/Mettle banking partnership. Subsidiary of NatWest Group since 2018.

Company: FreeAgent Central Ltd, Edinburgh, UK. Founded 2007. Acquired by NatWest 2018.

Core Features

Feature Details
Invoicing Professional invoices, recurring billing, estimates
Expenses Receipt capture, mileage tracking, expense categories
VAT Making Tax Digital (MTD) compliant — direct HMRC filing
Banking Open Banking feeds (automatic bank import)
Payroll RTI-compliant payroll, pension auto-enrollment
Self Assessment Personal tax return filing (UK-only)
Time tracking Project time tracking linked to invoices
Reports P&L, balance sheet, tax timeline
Corporation Tax CT600 submission to HMRC

Pricing (Updated Feb 2026)

Plan Price Notes
Bank partnership (free) £0 Free while you have NatWest, RBS, Ulster Bank, or Mettle account with ≥1 transaction/month
Standard 50% off first 6 months (promotional), then full rate All features included
30-day free trial Available No card required
Smart Capture Unlimited add-on Extra charge Auto data extraction for receipts/bills (10+ files/month)

Note (Feb 2026): FreeAgent is currently running a 50% off for first 6 months promotional offer for non-bank customers. Full standard pricing not publicly listed; varies by plan tier. The Mettle (NatWest digital bank) partnership also provides free access.

API

Security

Aspect Details
2FA Available (optional) — TOTP-based
Encryption TLS in transit, AES-256 at rest
Hosting AWS UK region
Compliance GDPR, FCA regulated (as NatWest subsidiary)
Certifications SOC 2 (via NatWest infrastructure)

Compliance Coverage

Strengths vs. Bilko

Weaknesses (Bilko Opportunities)


2.3 Holded (Spain)

Overview: All-in-one ERP/accounting SaaS for Spanish and Latin American SMBs. Broader than pure accounting — includes CRM, inventory, HR, and project management. Rapidly expanding via API integrations.

Company: Holded, Barcelona, Spain. Founded 2016. Backed by Nauta Capital, Lakestar.

Core Features

Feature Details
Accounting Full double-entry accounting, chart of accounts, reconciliation
Invoicing Verifactu certified (Spain Tax Agency AEAT), e-invoicing
Expenses OCR receipt scanning, expense management
Inventory Real-time stock tracking, batch/serial numbers, POS
CRM Customer pipeline, deal tracking
HR Employee management, payroll (Spain)
Projects Project management, time tracking
Reports P&L, balance sheet, VAT reports, custom reports

Pricing (Updated Feb 2026)

Plan Price Notes
Basic ~€12/user/month Core accounting + invoicing (up to 3 users)
Standard ~€19/user/month + Order management, inventory
Advanced ~€39/user/month + HR, advanced CRM
Enterprise €99+ / custom Dedicated support, custom integrations
Free trial 14 days No card required

Note (Feb 2026): Holded pricing spans €0–€99 across 4 editions. Per-user model gets expensive for growing teams.

API

Security

Aspect Details
2FA Available
Encryption TLS 1.2+, AES-256 at rest
Hosting AWS EU (Ireland)
Compliance GDPR, LOPD (Spanish data protection)
Certifications ISO 27001 (pending confirmation)
Verifactu Certified by Spanish Tax Agency (AEAT) for e-invoicing compliance

Compliance Coverage

Strengths vs. Bilko

Weaknesses (Bilko Opportunities)


2.4 Wave (Canada/US)

Overview: Freemium accounting SaaS for micro-businesses and freelancers. Free core product funded by financial services add-ons (payments, payroll). GraphQL API. H&R Block acquired Wave in 2019.

Company: Wave Financial Inc., Toronto, Canada. Founded 2010. Acquired by H&R Block 2019.

Core Features

Feature Details
Invoicing Unlimited invoices (free), recurring billing, estimates
Expenses Receipt OCR (Pro only), expense tracking
Accounting Double-entry accounting, chart of accounts, journal entries
Banking Bank connection and auto-import (manual entry in Starter)
Reports P&L, balance sheet, cash flow, aged receivables
Payments Online payment processing (fee-based: 2.9% + $0.60)
Payroll Add-on service ($20–40/month, US/Canada only)
Bookkeeping Managed bookkeeping service (Wave Advisors)

Pricing (Updated Feb 2026)

Plan Price Notes
Starter Free Invoicing, expense tracking, reports (unlimited)
Pro $16–19/month or $170/year Automated bank import, receipt scanning, recurring invoices
Payments 2.9% + $0.60 per transaction Optional online payment processing
Payroll $20–40/month + $6/employee US/Canada only
Wave Advisors From $149/month Managed bookkeeping (human accountants)

Notable change (2024–2026): Wave removed transaction-level CSV export from free (Starter) accounts without public notice. Third-party OAuth now requires active Pro subscription. These changes are eroding Wave's free-software reputation.

API

Security

Aspect Details
2FA Available (SMS or authenticator app)
Encryption TLS in transit, encrypted at rest
Hosting Google Cloud (US)
Compliance GDPR (limited — US-centric); PCI-DSS for payment processing
Certifications PCI-DSS Level 1 (payment processing only)
Data residency US-based — compliance risk for EU/GDPR businesses

Compliance Coverage

Strengths vs. Bilko

Weaknesses (Bilko Opportunities)


2.5 Zoho Books (Global)

Overview: Part of the Zoho suite, Zoho Books is the most globally comprehensive accounting platform with 16+ country-specific editions. Deep API, multi-currency, global tax handling. Part of Zoho Corp (Indian conglomerate).

Company: Zoho Corporation, Chennai, India / Austin, US. Founded 1996. Private, profitable.

Core Features

Feature Details
Invoicing Smart invoicing, recurring billing, client portal, estimates
Expenses OCR scanning, expense reports, mileage tracking
Banking Bank feeds, auto-reconciliation, AI categorization
Inventory Real-time tracking, batch/serial numbers (Standard+ plans)
Projects Time tracking, project billing, billable expenses
Payroll Zoho Payroll add-on (select countries)
Reports Comprehensive: P&L, balance sheet, trial balance, aging, custom reports
Multi-currency 170+ currencies, real-time exchange rates
Tax GST (India), VAT (UK, UAE, Saudi, etc.), sales tax (US), 16+ countries

Pricing (Updated Feb 2026)

Plan Price (annual) Users Notes
Free $0 1 + 1 accountant Up to 1,000 invoices/year
Standard $15/org/month 3 Unlimited invoices; 5,000 expenses/year
Professional $40/org/month 5 + Inventory; 10,000 bills
Premium $60/org/month 10 + Advanced features; 25,000 bills
Elite $120/org/month 10 + Advanced inventory; 100,000 bills
Ultimate $240/org/month 15 All features
User add-on $2.50/user/month (annual) Additional users beyond plan limit

Key change (2026): Standard dropped from $20 → $15/month. Professional dropped from $50 → $40. User add-ons now available at $2.50/user/month (annual) or $3/month.

API

Security

Aspect Details
2FA Available (TOTP, SMS)
Encryption TLS 1.2/1.3, AES-256 at rest
Hosting US (AWS), EU (AWS Frankfurt for EU users), India, Australia, Japan
Compliance GDPR, ISO 27001, SOC 2 Type II (Zoho Corp infrastructure)
Data residency EU data hosted in Frankfurt for GDPR compliance
Certifications ISO 27001, SOC 2 Type II

Compliance Coverage

Strengths vs. Bilko

Weaknesses (Bilko Opportunities)


2.6 FreshBooks (Canada)

Overview: Cloud accounting focused on service businesses, freelancers, and agencies. Best-in-class invoicing and time tracking. Strong on client experience. SOC 2 Type 1 certified.

Company: FreshBooks, Toronto, Canada. Founded 2003. Private equity backed (Oak Investment Partners).

Core Features

Feature Details
Invoicing Professional invoices, recurring billing, automatic payment reminders
Expenses Receipt scanning, expense categories, mileage tracking
Time tracking Built-in time tracking linked to projects and invoices
Client portal Clients view invoices, approve estimates, pay online
Projects Project management, team collaboration, profitability tracking
Reports P&L, expenses, tax summary, accounts aging
Payroll Gusto integration (US/Canada)
Proposals Business proposals with e-signature

Pricing (Updated Feb 2026)

Plan Price (monthly billing) Price (annual billing) Clients
Lite $19/month $17.10/month Up to 5 clients
Plus $43/month ~$38.70/month Up to 50 clients
Premium $60/month $54/month Unlimited clients
Select Custom Custom Dedicated support, custom domain
User add-on $11/user/month Additional team members
Advanced Payments $20/month Add-on
Payroll From $40 + $6/person/month US/Canada
30-day free trial Available Full features

Promotional note (Feb 2026): FreshBooks frequently offers 90% off for the first 4 months before reverting to full price.

API

Security

Aspect Details
2FA Available (email-based OTP, rolling out to all accounts)
Encryption TLS 1.2+, AES-256 at rest
Hosting AWS (Canada/US)
Compliance GDPR (limited — data may be in US), PCI-DSS for payments
Certifications SOC 2 Type 1 (achieved February 2023, assessed by A-LIGN)
Penetration testing Regular third-party pen tests

Compliance Coverage

Strengths vs. Bilko

Weaknesses (Bilko Opportunities)


2.7 Pancake (Self-hosted)

Overview: Self-hosted project management and invoicing platform for freelancers. One-time license model, no subscription. Not a compliance accounting tool.

Note: Pancake is not a direct accounting compliance competitor to Bilko. It serves a different use case (project/invoice management, self-hosted). Included for completeness of the freelancer market segment.

Company: Pancake App. Self-hosted. No country-specific entity.

Core Features

Feature Details
Invoicing Professional invoices, recurring billing
Time tracking Project-based billable hours
Project management Kanban, milestones, task management
Client portal Clients view projects, invoices, communicate
Expenses Basic expense tracking
Contracts Contract templates, e-signature
Payments PayPal, Stripe integration

Pricing (Updated Feb 2026)

License Price Notes
Pancake License ~$149–179 one-time 1 installation, lifetime updates
No subscription Self-hosted, no recurring fees

Compliance Coverage

Relevance to Bilko

Bilko opportunity: Target Pancake users who are self-hosting for basic invoicing but need real accounting compliance (VAT filing, SEF integration). Data sovereignty messaging and local compliance are Bilko differentiators for this segment.


2.8 Minimax (Slovenia/Croatia/Serbia)

Overview: ⚠️ Highest-priority regional competitor. Minimax is the most popular cloud accounting software in Slovenia, with active subsidiaries in Croatia and Serbia. Parent company SAOP d.o.o. (acquired by Seyfor group). Built as SaaS from the ground up. Has implemented Fiskalizacija 2.0 for Croatia's January 2026 e-invoicing mandate. Has mobile apps (iOS/Android). This is the closest regional analogue to Bilko.

Company: SAOP d.o.o., Slovenia. Part of Seyfor Group (CZ). ~1,500+ employees across region. Active in SL/HR/RS.

Core Features

Feature Details
Invoicing Cloud invoicing, recurring billing, e-invoicing
Accounting Full double-entry accounting
VAT Slovenia/Croatia/Serbia VAT compliance
E-invoicing Fiskalizacija 2.0 (Croatia, Jan 2026), EDI via BizBox
Payroll Supported (Slovenia, Croatia, Serbia)
Banking Bank import, reconciliation
Reports Standard financial reports per country
Mobile app ✅ iOS (App Store) + Android (Google Play)
Multi-entity Supported
API / integrations EDI network via BizBox partner

Pricing

Note: Minimax does not publish pricing publicly on their website. A 30-day free trial is available. Pricing is subscription-based (SaaS). To get pricing, contact regional teams:

Based on market research, estimated monthly pricing is in the €10–30 range per organization depending on modules, but this is unverified — contact Minimax directly for confirmed pricing.

Compliance Coverage (Feb 2026)

Country Compliance
Slovenia ✅ Full VAT (FURS), e-invoicing, payroll
Croatia ✅ Fiskalizacija 2.0 (LIVE Jan 2026), VAT, PDV reports
Serbia ✅ Accounting, VAT (PDV); SEF integration status unverified
BiH ❌ Not documented

Strengths vs. Bilko

Weaknesses (Bilko Opportunities)


2.9 Pantheon — Datalab (Slovenia/ERP)

Overview: Enterprise ERP system from Datalab Tehnologije d.d. (Ljubljana, Slovenia). Primarily license-based on-premise + cloud hybrid. Used in accounting firms across Slovenia, Croatia, Serbia, and Bulgaria. Not a modern SaaS product — requires setup, licensing, and maintenance.

Company: Datalab Tehnologije d.d., Ljubljana, Slovenia. Founded 1990s. Available in SL/HR/RS/BG.

Core Features

Feature Details
Accounting Full ERP accounting, general ledger, AP/AR
Invoicing Receiving/issuing invoices, interwarehouse transfers
Banking Automated bank statement import and posting
Document archive Digital archive, document management
HR/Payroll Employee management and payroll
Manufacturing Production modules (ERP scope)
Reports Financial statements, VAT, custom
Compliance Country-specific editions for SL/HR/RS/BG

Pricing

License-based model: Pantheon uses perpetual license pricing with an annual maintenance fee of 22% of total active license value. Pricing not standardized — varies by modules, users, and country. Small Business edition available. Contact Datalab or regional partners for quotes.

Target Market

Mid-market companies and accounting firms (not micro-SMBs). Requires IT setup, partner deployment. High total cost of ownership compared to SaaS alternatives.

Strengths vs. Bilko

Weaknesses (Bilko Opportunities)


2.10 e-Racunovodstvo / e-racuni.com (Serbia)

Overview: Web-based ERP and invoicing platform for Serbian (and Slovenian) businesses. One of the few cloud accounting solutions with native Serbian compliance. Three subscription tiers including a free BASIC plan. Has Shopify app for Croatian fiscalized invoicing.

Company: e-racuni.com. Available for Serbia (srbija.e-racuni.com) and Slovenia (e-racuni.com).

Core Features

Feature Details
Invoicing Cloud invoicing, recurring billing
Payroll Included in ADVANCED+
VAT reporting Serbian VAT (PDV)
eTax integration Interface to Serbian Tax Administration (eTax portal)
General ledger ADVANCED+
Stock management ADVANCED+
POS/Retail Available as add-on
Production Available as add-on
Annual reporting PREMIUM — financial statements, tax returns
Assets PREMIUM

Pricing (Verified via official pricing page)

Plan Price Notes
BASIC Free Single user; invoicing, expenses, cash book, e-banking
ADVANCED From €15/month + Payroll, timesheet, VAT reporting, general ledger, stock
PREMIUM From €35/month + Financial reporting, assets, multi-company
POS (Retail) +€10/month Add-on to any plan
Production +€12/month Add-on to any plan
Phone support +€25–30/month Optional

Prices in EUR; payment in Serbian dinar at NBS middle exchange rate.

Compliance Coverage

Strengths vs. Bilko

Weaknesses (Bilko Opportunities)


3. Feature Comparison Matrix

Feature Fiken FreeAgent Holded Wave Zoho Books FreshBooks Minimax e-racuni Bilko (Target)
Invoicing ✅ ⭐
Recurring invoices ✅ (Pro)
Expense tracking
Receipt OCR ✅ (add-on) ✅ (Pro) ⚠️
Bank feeds ✅ (add-on) ✅ (Open Banking) ✅ (Pro)
Bank reconciliation
Double-entry accounting
Chart of accounts ✅ (NS 4102) ✅ (UK) ✅ (Spain) ✅ (generic) ✅ (16 countries) ✅ (generic) ✅ (SL/HR/RS) ✅ (RS) ✅ (RS/BA/HR)
VAT / Tax reports ✅ (Altinn) ✅ (HMRC MTD) ✅ (IVA Spain) ⚠️ manual ✅ (16 countries) ⚠️ manual ✅ (SL/HR/RS) ✅ (RS) ✅ (SEF/UIO/HR)
E-invoicing ✅ (EHF Norway) ✅ (Verifactu ES) ✅ (HR Fisk 2.0) ⚠️ unverified ✅ (SEF/HR-FISK)
Payroll ✅ (add-on NO) ✅ (UK RTI) ✅ (Spain) ✅ (US/CA add-on) ⚠️ select countries ⚠️ (Gusto US/CA) ✅ (SL/HR/RS) ✅ (RS) 📋 Phase 2
Multi-currency ⚠️ basic ✅ (170 currencies) ⚠️ basic ⚠️ ✅ (RSD/BAM/EUR)
Time tracking ✅ (add-on) ✅ ⭐ ⚠️ basic
Inventory ✅ (Standard+) ✅ (add-on) 📋 Phase 3
CRM ⚠️ (via Zoho CRM)
Client portal 📋 Phase 2
P&L report
Balance sheet
Cash flow
Mobile app ⚠️ PWA ✅ iOS+Android ✅ (Phase 2)
Accountant access
RBAC (roles) ⚠️ basic ⚠️ basic ⚠️ basic ⚠️ basic ⚠️ basic
Serbia (SEF) ⚠️ unverified ⚠️ unverified ✅ Phase 2
BiH (UIO) ✅ Phase 3
Croatia (HR-FISK 2.0) ✅ LIVE ✅ Phase 2
Balkan languages ✅ (SL/HR/RS) ✅ (RS) ✅ (SR/BS/HR)
Free plan ✅ (via bank) ✅ Starter ✅ BASIC

Legend: ✅ Full support | ⚠️ Partial/limited/unverified | ❌ Not supported | 📋 Planned


4. Pricing Comparison Matrix

Product Entry Price (EUR/month) Model Free Plan/Trial Notes
Fiken ~€18.50 Flat per-org + add-ons 30-day free trial Single plan; modular add-ons
FreeAgent Free* Flat per-org 30-day free trial *Free with NatWest/RBS/Mettle; 50% off promo for paid
Holded ~€12/user Per-user 14-day free trial 4 tiers €0–€99; gets expensive
Wave Free Freemium + Pro $16-19/mo Core product free Eroding free tier (CSV paywalled)
Zoho Books Free Per-org 14-day trial on paid Standard now $15/mo; free 1K invoices
FreshBooks ~€17 (Lite) Per-org 30-day free trial Plus $43/mo monthly; client-count limits
Pancake ~€136 one-time One-time Self-hosted, no compliance
Minimax Unverified (~€10–30 est.) Per-org SaaS 30-day free trial No public pricing; contact sales
Pantheon (Datalab) License + 22% annual maint. Perpetual license ERP, not SMB-SaaS; high setup cost
e-racuni.com Free (BASIC) Freemium Free tier ADVANCED €15/mo; PREMIUM €35/mo
Bilko (target) ~€15–18/month Per-org 30-day free trial Flat pricing; compliance included

Pricing Insight for Bilko


5. API Pattern Analysis

Product API Style Auth Webhooks Rate Limits SDKs Maturity
Fiken REST v2 OAuth 2.0 + API key Not public Community Medium
FreeAgent REST v2 OAuth 2.0 Not public Community PHP/Node High
Holded REST API key Not public Community Medium
Wave GraphQL OAuth 2.0 (Pro required) 429 documented None official High
Zoho Books REST v3 OAuth 2.0 Per-plan limits Official (5 languages) Very High
FreshBooks REST OAuth 2.0 Not public None official High
Minimax EDI via BizBox None Low
e-racuni Limited API key None Low
Pancake Limited REST API key None Low

Key API Patterns for Bilko Design

  1. OAuth 2.0 is standard — all enterprise-grade competitors use it. Bilko's API must support OAuth 2.0 authorization code flow for third-party integrations.

  2. REST > GraphQL for accounting — while Wave uses GraphQL, REST is the dominant pattern in accounting APIs. Bilko should use REST with JSON. Consider GraphQL for a future v2 API.

  3. Webhooks are expected — invoice sent, payment received, expense approved are minimum webhook events. Important for accountant tools and ERP integrations.

  4. API key for simple integrations — in addition to OAuth, provide API key support for simple server-to-server integrations (e.g., WooCommerce → Bilko invoice creation).

  5. Wave's Pro-gate on API — requiring a paid plan for OAuth access is a monetization lever. Consider for Bilko: API access on all plans, but webhook events only on paid plans.

  6. Zoho's versioning model — v3 API with stable versioning and developer console is the gold standard. Bilko should version from day one (/api/v1/).

  7. Regional competitors have no real APIs — Minimax uses EDI/BizBox, e-racuni has minimal API. Bilko's modern REST API is a clear differentiator in the Balkan market.

Bilko API Target Design

Authentication: OAuth 2.0 (code flow) + API key
Style: REST + JSON
Versioning: /api/v1/
Webhooks: invoice.created, invoice.sent, invoice.paid, expense.approved, payment.received
Rate limits: 100 req/15min (general), 5 req/15min (auth)
Documentation: OpenAPI 3.0 spec + generated SDK
SDKs: JavaScript/TypeScript (Phase 2), Python (Phase 3)

6. Security Comparison

Product 2FA Encryption Certifications Data Residency GDPR
Fiken ✅ Optional TLS + AES-256 ISO 27001 (Visma) Norway
FreeAgent ✅ Optional TLS + AES-256 SOC 2 (NatWest) UK (AWS)
Holded ✅ Optional TLS + AES-256 ISO 27001 (pending) EU (AWS Ireland)
Wave ✅ Optional TLS + AES-256 PCI-DSS Level 1 US (Google Cloud) ⚠️
Zoho Books ✅ Optional TLS + AES-256 ISO 27001 + SOC 2 Type II US/EU/India/AU
FreshBooks ✅ Optional TLS + AES-256 SOC 2 Type 1 US/CA (AWS) ⚠️
Minimax ⚠️ unverified TLS (cloud) Unverified Slovenia/EU ⚠️
e-racuni TLS + backups None documented Serbia ⚠️
Pancake TLS (self) None Self-hosted N/A
Bilko (target) ✅ TOTP (enforce on owner) TLS 1.3 + AES-256 + AES-256-GCM (L4 fields) SOC 2 Type II (Phase 2) EU West (Railway)

Security Observations

  1. 2FA is optional everywhere — no competitor enforces 2FA. Bilko can differentiate by requiring 2FA for org owners (compliance-first positioning).

  2. Field-level encryption is not mentioned by any competitor — Bilko's AES-256-GCM for tax IDs (PIB/JMBG/OIB/JIB) and IBAN is a genuine differentiator in the Balkan regulatory context.

  3. SOC 2 is the enterprise trust signal — FreshBooks got Type 1 in 2023; Zoho has Type II. Bilko should target SOC 2 Type 1 within 18 months of launch as an enterprise sales enabler.

  4. Wave's US data residency is a GDPR risk — EU users of Wave are technically at risk. Bilko's EU West (Railway Frankfurt/Amsterdam) hosting is a compliance advantage.

  5. Regional competitors (Minimax, e-racuni) have weak security posture — no 2FA enforcement, limited certifications. Bilko's security-first approach is a differentiator vs. regional alternatives.


7. Balkan Market Gap Analysis

Regulatory Status — February 2026 Update

Country Regulation Status (Feb 2026)
Croatia HR-FISK 2.0 (Fiskalizacija 2.0) 🟢 LIVE — mandatory for all VAT-registered B2B since Jan 1, 2026. HR-FISK format: UBL 2.1 + EN 16931.
Serbia SEF B2B e-invoicing 🟡 DELAYED — B2B mandate pushed to Jan 2028. Voluntary SEF from March 30, 2026. G2G/B2G already mandatory.
Serbia VAT Law Amendments 🔵 IN EFFECT — new requirements for retail + corporate card transactions from April 2026. Pre-filled VAT returns postponed to Jan 2027.
BiH Fiscalization / e-invoicing 🟡 ADVANCING — FBiH House of Peoples approved Fiscalization Bill (Dec 2025). Mandatory proposed from Jan 2026; full transition by Jan 2027. Law not yet enacted.

What Exists Today (Balkan SMB Accounting)

Solution Serbia BiH Croatia Quality Modern?
Minimax ✅ (Fisk 2.0 live) High Yes — SaaS
e-racuni.com Low No (legacy web)
Eurofaktura Medium No (legacy)
Pantheon (Datalab) Medium No (desktop/ERP)
Excel + manual SEF N/A
Zoho Books (global) ⚠️ manual ⚠️ manual ⚠️ manual High Yes
Bilko (target) Target: highest Yes

Compliance Gaps in Global Players

Compliance Requirement Fiken FreeAgent Holded Wave Zoho Books FreshBooks Minimax Bilko
Croatia HR-FISK 2.0 (B2B mandatory Jan 2026) ✅ LIVE ✅ Phase 2
Croatia PDV 25%/13%/5% ⚠️ manual
FINA certificate flow ✅ Phase 2
Serbia SEF e-invoicing (voluntary Mar 2026) ⚠️ ✅ Phase 2
Serbia PDV 20%/10% calculation ⚠️ manual
Serbia APR financial reporting ⚠️ ✅ Phase 2
BiH Fiscalization (law advancing) ✅ Phase 3
BiH UIO VAT (17%) ⚠️ manual
BiH FBiH/RS entity CoA
Serbian/Bosnian/Croatian UI ✅ SL/HR/RS

Conclusion: Minimax is now the primary regional competitor (Croatia live, Serbia active). Bilko must differentiate on: BiH coverage, modern developer API, transparent pricing, and open banking integration.


8. Key Takeaways for Bilko Positioning

8.1 Positioning Statement

"Bilko is the Fiken for the Balkans — simple, beautiful accounting built for Serbia, BiH, and Croatia compliance. The only cloud accounting platform covering all three markets with native e-invoicing, local languages, and transparent pricing."

vs. Minimax: "Unlike Minimax, Bilko covers Bosnia & Herzegovina, has transparent self-serve pricing, and is built API-first for the modern accountant ecosystem."

8.2 Differentiation Pillars

Pillar How Bilko Wins
Three-country coverage Only product covering RS + BA + HR with native compliance. Minimax covers RS + HR but not BiH.
Compliance-native No global player has SEF, HR-FISK, or BiH UIO. Minimax has HR/RS but not BiH. Bilko = complete Balkan stack.
Simple UX Fiken-inspired simplicity — built for SMB owners, not accountants. No accounting jargon.
Flat per-org pricing Transparent, self-serve. Minimax requires sales call. No per-user pricing (unlike Holded).
Modern tech stack React, TypeScript, REST API — vs. legacy desktop or ERP tools
Security-first Field-level encryption for tax IDs/IBAN; EU data residency; 2FA required for owners
Local language SR/BS/HR UI and support — global players are English-only; Minimax is Slovenian-first
Open Banking ready Modern bank feed via open banking — none of the regional competitors have this
Developer API REST API + webhooks — Minimax has EDI/BizBox only; no modern API in regional market

8.3 Pricing Recommendation

Based on updated competitor analysis:

8.4 Feature Priority (Based on Competitive Gaps)

Build first (MVP — what all global competitors lack):

  1. SEF integration (Serbia B2B e-invoicing — voluntary from Mar 2026, mandatory 2028)
  2. HR-FISK 2.0 integration (Croatia — ALREADY MANDATORY since Jan 2026 — urgent!)
  3. VAT calculations per country (RS 20%/10%, BA 17%, HR 25%/13%/5%)
  4. Country-specific Chart of Accounts (RS, BA FBiH/RS, HR)
  5. Serbian/Croatian/Bosnian UI

Build second (match competitors + differentiate from Minimax): 6. Mobile app (Minimax has iOS+Android; Bilko must match) 7. Receipt OCR scanning 8. Bank feed integration (Open Banking for Balkans — Minimax lacks this) 9. Modern REST API (no regional competitor has one) 10. Client portal

Build third (long-term differentiation): 11. Payroll (Serbia: PIO contributions; Croatia: OPZ-STAT) 12. Accountant partner portal (Fiken's biggest growth channel) 13. BiH Fiscalization (once law is enacted)

8.5 Go-to-Market Insights

Insight Source Implication for Bilko
Accountant partnerships drive SMB adoption Fiken, FreeAgent, Minimax Build accountant portal early; offer free access for accountants who onboard clients
Bank partnerships = free distribution FreeAgent + NatWest Target Serbian and Croatian neobanks (Revolut Business, neobanks entering RS/HR) for partnership
Croatia HR-FISK 2.0 is LIVE NOW Croatian Parliament, Jan 2026 Immediate urgency marketing — businesses in Croatia need compliance TODAY
Serbia SEF delay = early mover window Official SEF postponement Serbian voluntary SEF from March 2026 — target early adopters before 2028 mandate
BiH law advancing FBiH Parliament Dec 2025 First-mover in BiH — no one is building for BiH compliance yet
Free trial converts better than freemium for compliance products FreshBooks, Fiken, FreeAgent 30-day trial with onboarding call; no freemium complexity
Minimax opaque pricing = Bilko opportunity Minimax.hr, Minimax.rs Self-serve signup with transparent pricing vs. "contact us" = lower friction
API for integrators Wave, Zoho, FreshBooks Accountant practice management tools, e-commerce plugins (WooCommerce RS/HR)

8.6 Pricing / Feature Bundling Recommendation

Bilko Starter — €18/month
├── Unlimited invoices + expenses
├── VAT reports (manual export)
├── 2 users
├── 30-day trial

Bilko Growth — €29/month  (most popular)
├── Everything in Starter
├── SEF e-invoicing (Serbia) and/or HR-FISK 2.0 (Croatia)
├── Bank feeds + reconciliation
├── Unlimited users
├── API access

Bilko Pro — €49/month
├── Everything in Growth
├── All 3 countries (RS + BA + HR)
├── Priority support
├── Accountant portal
├── Custom reports
├── Audit trail access

Sources

International Competitors

Regional Competitors

Regulatory Sources


Last Updated: 2026-02-24 Version: 2.0 (major update — added Minimax, Pantheon, e-racuni; updated all pricing; added Feb 2026 regulatory updates) Next Review: Q3 2026 or when BiH Fiscalization Law is enacted Owner: Product Team

Bilko Documentation Index

Bilko Documentation Index

Last updated: 2026-02-25 Total documents: 90 Status: 32 Final / 58 Draft



Business Requirements

6 documents

Document Description Status
Acceptance Criteria Feature acceptance criteria and DoD per module Draft
BRD Business Requirements Document — project goals, stakeholders, scope Draft
Functional Requirements Detailed functional requirements for all features Draft
Non-Functional Requirements Performance, scalability, reliability, and quality requirements Draft
Requirements Traceability Matrix Maps requirements to implementation and tests Draft
User Stories Agile user stories with acceptance criteria by epic Draft

Architecture

6 documents

Document Description Status
ADR Architecture Decision Records — key technical decisions and rationale Final
API Specification OpenAPI v3 specification for the Bilko REST API Final
Data Flow Data flow diagrams — user actions, API calls, DB interactions Draft
Database Schema Document Full database schema documentation for bilko_production Draft
Integration Design External integrations — SEF, eRačun, SendGrid, ECB/Fixer.io, Cloudflare R2 Draft
Module Design Design of @bilko/core accounting core module (Turborepo package) Draft

Backend

14 documents

Document Description Status
API Coverage Report Maps every frontend page to required API endpoints Draft
API Reference Full REST API reference with endpoints, schemas, and examples Draft
Authentication Auth architecture — JWT, sessions, OAuth, and RBAC design Draft
Backend Architecture Node.js/Express service architecture overview Draft
Business Logic Core accounting rules — invoicing, VAT calculation, journal entries Draft
Database Schema Prisma schema reference — implemented PostgreSQL data model Final
Error Codes Catalog Complete error code catalog with HTTP status, cause, and resolution Final
Event Schema Domain event definitions for the event-driven architecture Draft
External Services Third-party integrations — fiscal APIs, payment providers Draft
Middleware Design Express middleware stack — auth, logging, rate limiting design Draft
Middleware Middleware configuration and execution order specification Draft
Roles and Permissions RBAC model — roles, permissions, and resource access matrix Final
Service Design Service layer design for all backend services Draft
Services Service catalogue — external APIs and integrations consumed Draft

Frontend

7 documents

Document Description Status
Accessibility Audit WCAG 2.1 AA accessibility audit and remediation plan Draft
Component Inventory Full inventory of React components in apps/web/components/ Final
Design System Design tokens, color palette, typography, and spacing system Final
Forms Form inventory, validation patterns, and react-hook-form migration plan Final
Frontend Architecture Next.js 15 App Router architecture, routing, and rendering strategy Draft
Pages All implemented pages with routes, components, and data requirements Final
State Management Current React hooks state + Zustand migration plan Final

Security & Compliance

8 documents

Document Description Status
Breach Response Plan IRP-SEC-001 — Incident response procedures for data breaches Final
Compliance Framework GDPR, PCI-DSS, and regional compliance requirements overview Final
Compliance Status Current compliance posture — gaps and remediation roadmap Draft
Data Encryption Policy POL-SEC-ENC-001 — Encryption standards for data at rest and in transit Final
DPIA Data Protection Impact Assessment for processing personal financial data Final
Key Management Policy POL-SEC-KM-001 — Cryptographic key lifecycle management Final
Security Architecture Security design — auth, network security, secrets, and threat model Draft
Security Testing Policy POL-SEC-TEST-001 — SAST, DAST, and penetration testing requirements Final

Testing & QA

7 documents — see also Test Plan in Standalone

Document Description Status
Definition of Done DoD checklist for features, sprints, and releases Draft
E2E Test Plan End-to-end testing plan with Playwright test scenarios Draft
Performance Test Plan Load and stress testing targets and methodology Draft
Test Case Template Standard template for writing and documenting test cases Draft
Test Inventory Catalogue of all planned tests — unit, integration, E2E Draft
Test Strategy Overall testing strategy — scope, tools, and coverage targets Draft
Testing Guide Developer guide for running and writing tests Draft

Infrastructure & DevOps

6 documents

Document Description Status
CI/CD Pipeline GitHub Actions pipeline design — build, test, deploy stages Draft
Deployment Guide Target deployment architecture — Railway/Fly.io, Postgres, S3 Draft
Disaster Recovery DR plan — RTO/RPO targets, backup strategy, failover procedures Draft
Environment Configuration Environment variables, secrets management, dev/staging/prod config Draft
Infrastructure as Code Terraform/IaC specifications for cloud infrastructure Draft
Monitoring & Observability Metrics, logging, alerting, and observability stack design Draft

Operations

5 documents

Document Description Status
Go-Live Runbook Step-by-step production launch checklist and procedures Draft
Incident Report Incident report template and reporting process Draft
Operational Runbook Day-to-day ops procedures — deployments, rollbacks, monitoring Draft
Post-Mortem Post-incident analysis template and blameless review process Draft
SLA Report SLA definitions and monthly performance reporting template Draft

Governance

5 documents

Document Description Status
Communication Plan Stakeholder communication cadence and channels Draft
Project Brief One-page project summary — scope, goals, and constraints Draft
Project Charter Formal project authorization — objectives, budget, and authority Draft
RACI Matrix Responsibility assignment for all project activities Draft
Risk Register Identified risks with probability, impact, and mitigation Draft

Release

4 documents

Document Description Status
Deployment Checklist Pre/post-deployment verification checklist Draft
Release Notes Release notes template and changelog format Draft
Rollback Plan Rollback procedures and decision criteria Draft
UAT Sign-Off User acceptance testing sign-off template Draft

Developer Experience

4 documents

Document Description Status
Coding Standards TypeScript, ESLint, Prettier, and project-specific code conventions Draft
Developer Offboarding Knowledge transfer and access revocation checklist Draft
Developer Onboarding New developer orientation — codebase, tools, and first contribution Draft
Local Development Setup Step-by-step local environment setup with prerequisites Draft

Cross-Cutting

3 documents

Document Description Status
Change Request Change request process, templates, and approval workflow Draft
Lessons Learned Project retrospective insights and team learnings Draft
Tech Debt Log Known technical debt items with priority and remediation plan Draft

Regulatory

8 documents

Document Description Status
Bosnia & Herzegovina Overview BA regulatory requirements — entity structure and tax obligations Final
BIH PDV (VAT) Bosnia & Herzegovina VAT rules, rates, and e-invoicing status Final
Chart of Accounts Unified CoA reference — BA, RS, HR cross-country comparison Final
Croatia eRačun Croatia e-invoicing (eRačun) and fiscalization (Fiskalizacija) Final
Croatia Overview HR regulatory requirements — EU member, PDV, fiscal obligations Final
Multi-Region Overview Shared core + country plugin architecture for multi-region compliance Final
Serbia Overview RS regulatory requirements — SEF mandate, PDV, e-invoicing Final
Serbia SEF Serbia's Sistem Elektronskih Faktura — mandatory e-invoicing system Final

Open Banking

2 documents

Document Description Status
Balkan Open Banking Strategy CEO-approved unified platform strategy for open banking across the Balkans Final
Open Banking Business Case Financial and strategic justification for open banking investment Final

Standalone Documents

5 documents

Document Description Status
Competitive Research Market analysis — competitors, positioning, and differentiation strategy Final
High-Level Design System overview — architecture, technology stack, and key decisions Final
Low-Level Design Detailed component design — services, APIs, and data models Final
Test Plan Master test plan covering all testing phases and acceptance criteria Final
Validation Report Gate validation report — documentation completeness and quality Final

Templates excluded. See templates/ for reusable document templates.

MC 103057 — Bilko Demo API Hang Validation 2026-06-06

MC #103057 — Bilko demo API hang recurrence validation (3f56ab5)

Deployment

Changes

Validation evidence

Verdict

READY FOR VALIDATOR REVIEW. The previous ~50% recurring /health hang/504 pattern was not observed after deploy. There was one client-side abort in the 76-minute watcher, but Cloud Run did not record a matching 504/non-200 or slow request for the new revision, and final custom + direct probes were clean (60/60).

Follow-up

MC #103060 remains the durable architectural fix: migrate blocking Exposed transaction {} usage to newSuspendedTransaction(Dispatchers.IO) or a bounded DB dispatcher.