Overview & Architecture
Project overview, architecture, roadmap, and architectural decisions
- Drop — Project Handbook
- Project Overview
- Architecture
- Architecture Decision Records
- ADR-014 — Hybrid Encryption for L4 Restricted Fields
- ADR-015: Four-Jurisdiction Plugin Architecture
- ADR-016: EInvoice Adapter Lifecycle and Contract
- ADR-017: RLS Multi-Tenancy Migration
- ADR-019: Integration Adapter Registry
- ADR-020: Backend Canonical — Deprecate api-kotlin
- ADR-021: Bilko Blueprint Section 15 Realignment
- Reviews & Reports
- App Store
- Marketing
- High-Level Design (HLD)
- Low-Level Design (LLD)
- Architecture Decision Records (ADR)
- Competitive Research
- Bilko Documentation Index
- MC 103057 — Bilko Demo API Hang Validation 2026-06-06
Drop — Project Handbook
Drop — Fintech Payment App
Quick Info
- What: Remittance + QR payments for everyone in Scandinavia
- Target: ALL residents in Norway/Scandinavia — NOT limited to diaspora
- IMPORTANT: Drop is a general-purpose payment app. Do NOT frame it as diaspora-only.
- Pipeline: See project/PIPELINE.md
- Business Case: project/docs/zica-business-case-v2.md (pre-rebrand, content valid)
- Architecture: project/architecture/drop-architecture.md
- Backlog: project/backlog/
- Full Documentation: docs/INDEX.md — backend, frontend, mobile, infra, security, testing
- BookStack Wiki: http://localhost:6875 → shelf "Drop — Digital Banking" (11 knjiga: Architecture, Backend, Frontend, Mobile, Infra, Security, Legal, Specs, Design, QA, Research)
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
- Neonomics: ELIMINATED (only EUR-EUR, no NOK support). Meeting with Trine Stefferud confirmed.
- ZTL Payment Solution AS (Oslo, org.nr 920970931): TOP CANDIDATE — has PISP + AISP + remittance licence from Finanstilsynet. Covers all Norwegian banks, NOK-native, EEA passporting.
- Emails sent 2026-02-24: hello@ztlpay.io + fintech@finanstilsynet.no — WAITING RESPONSE.
Licence paths
- If ZTL says YES → Drop operates as agent under their licence. €0 capital, weeks to activate.
- If ZTL says NO → Own PI licence at Finanstilsynet. €50-125K capital, 6-12 months. Details:
legal/konsesjonssoknad-forberedelse.md - Tok (Balkan Open Banking) → AISP registration at Finanstilsynet (€0 capital) + EEA passporting to Croatia + local NBS registration for Serbia. Details:
~/ALAI/products/Tok/docs/regulatory/BALKAN-STRATEGY.md
Key decisions
- ADR-003: PSD2 pass-through model (
comms/decisions/ADR-003-psd2-passthrough-model.md) - Neonomics eliminated: HiveMind #14371
- ZTL identified: HiveMind #14504
- Unified Platform Model approved: HiveMind #14522, session
e9a95745
Branding
- Name: Drop (ex-Zica, renamed for cultural sensitivity)
- Domain: getdrop.no (drop.no owned by TV2)
- Tagline: "Send penger. Enkelt." (mobile) / "Enklere betalinger. Lavere gebyrer." (landing)
- Logo: Figma vector wordmark "Drop" with currency exchange "o" (circular arrows + "kr") and gold dot top-right. Green rounded rectangle with gradient (#0B6E35 to #064E25). Web:
@/components/drop-logo.tsx, Mobile:components/DropLogo.js - Design: MUST invoke
frontend-designskill. Read~/system/tools/PREMIUM_DESIGN_PATTERNS.mdfirst. - NEVER: fake SVG logos, system fonts as logo, generic AI aesthetics
Folder Structure
- brand/ — Logo, colors, guidelines
- apps/drop-app/ — Main application code
- landing/ — Landing page and marketing site
- project/ — Project documentation and planning
- pitch/ — Partnership materials
- rnd/ — Research & development documents
- legal/ — Legal resources, contracts, compliance
- marketing/ — Marketing campaigns, content, analytics
- infrastructure/ — Deployment, monitoring, CI/CD (NOTE: terraform/ subdirectory is DEPRECATED — was AWS IaC)
- design/ — Figma links, UI specs, assets
- support/ — Customer support FAQs, guides, feedback
- comms/ — Communications history
- docs/ — Technical documentation (see docs/INDEX.md for full index)
- docs/audits/ — Audit reports, reviews, build blueprint
- docs/security/audits/ — Security audit reports (moved from root security/)
- intake/ — Client intake materials
- mockups/ — Design mockups
- mockups/figma-make-export/ — UI SOURCE OF TRUTH (Vite+React, 10 screens)
UI Source of Truth
- Make export:
mockups/figma-make-export/src/components/— 10 screens - BEFORE any UI change: Read the corresponding Make component first
- Screens: Login, Onboarding, Dashboard, SendMoney, BankAccounts, TransactionHistory, ScanQR, Profile, Notifications, MerchantDashboard
- No Cards screen in Make — Cards is feature-flagged, not part of core product
Core Features (Pass-through PSD2 model)
- Remittance — send money abroad to 30+ countries (PISP from user's bank account)
- QR Payments — pay in-store by scanning QR (PISP from user's bank account)
- Bank Accounts — view linked bank account balances via AISP (Open Banking)
- Notifications — push notifications and transaction alerts
- Settings — user preferences and account management
- Transaction History — view all transactions with filters
IMPORTANT: Pass-through model
- Drop NEVER holds customer money. No wallet, no balance, no top-up.
- User's money stays in their bank account at all times.
- AISP reads balance from bank via Open Banking / BankID consent.
- PISP initiates payments directly from user's bank account.
- Cards feature is gated behind feature flags (future, requires partner).
User Requirements (ENFORCED — from vilkår)
- Minimum age: 18 — BankID fødselsnummer encodes DOB → validate >= 18
- Residency: Norway — Norwegian phone (+47) + Norwegian BankID
- BankID verification: mandatory — before any transaction
- Pass-through model — Drop never holds money, Open Banking (PSD2) reads balance + initiates transfers
- See
project/architecture/architecture-document.mdsection 1.4 for implementation details
Tech Stack (ADR-014, updated 2026-03-03)
- Database: PostgreSQL 16 (ALL environments — no SQLite)
- ORM: Drizzle ORM (
packages/shared/db/schema.ts= single source of truth) - Shared code:
packages/shared/(@drop/sharednpm workspace) - Local dev:
docker compose up -d→ PostgreSQL on port 5433 - Connection:
DATABASE_URL=postgresql://drop:dev_only_not_a_secret@localhost:5433/drop_dev - Schema push:
make db-pushorcd packages/shared && npx drizzle-kit push - SUPERSEDED: ADR-006 (SQLite), ADR-010 (dual-driver),
better-sqlite3 - Infra: Azure VM + docker-compose (NOT AWS — see Production Infrastructure above)
Rules
- Follow ALAI SDLC: processes/sdlc/
- All decisions logged in comms/decisions/
- NEVER use word "banking" without licence disclaimer
- Security-first: httpOnly JWT, parameterized SQL, rate limiting
- ADR-001 required before ANY cloud migration — no exceptions
- DO NOT reference AWS App Runner as production — it was sunset 2026-04-30
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 |
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:
- Remittance — pošalji novac u inostranstvo jeftinije (primatelj NE treba app)
- 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
- Amir otvori Drop, tap "Pošalji novac"
- Odabere: Srbija, mama Jasmina, njen broj računa
- Unese 2,000 NOK → vidi: primatelj dobije 23,400 RSD, fee 10 NOK (0.5%)
- Potvrdi, plati sa norveške kartice
- Mama dobije SMS: "Primili ste 23,400 RSD od Amira"
- Novac na računu za 1-2 radna dana
Journey B: QR Payment
- Amir uđe u Ahmetov kebab shop u Oslu
- Na kasi je Drop QR naljepnica
- Amir otvori Drop, tap "Skeniraj"
- Skenira QR → prikaže se: "Ahmetov Kebab, unesi iznos"
- Unese 129 NOK, tap "Plati"
- Ahmet dobije notifikaciju: "Primljeno 129 NOK od Amir"
- Instant. Bez terminala. Fee 1.29 NOK umjesto 3.55 NOK (Vipps).
Journey C: Killer Combo
- Amir šalje 5,000 NOK mami — dobije 25 Drop bodova
- Plaća kebab 129 NOK QR-om — dobije 1 bod
- Na 50 bodova: besplatna remittance (no fee)
- Ahmet (merchant) vidi: "Ove sedmice: 47 transakcija, 12,300 NOK, fee 123 NOK"
- Ahmet preporuči Drop svim korisnicima → novi korisnici → više remittance
5. Merchant Onboarding (3 minuta)
- Vlasnik skine Drop app
- Tap "Registruj biznis" → unese: naziv, adresa, bank račun
- KYC: lična karta + org.nummer
- Dobije QR kod — printaj ili koristi na telefonu
- Lijepi QR na kasu
- 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:
- Startup cost: 250K NOK (bootstrapable)
- Break-even: 7-9 mjeseci (realistično)
- LTV:CAC: 24:1 (consumer), 48:1 (merchant)
- Tržište: 5.7 mlrd NOK remittance + 30,000+ immigrant biznisa
- Niko ne radi remittance + QR combo u Norveškoj
- Alem razumije problem iz prvog lica — autentičnost
Rizici:
- Marketing budget je realan trošak (~50K NOK/mj)
- Compliance je ongoing
- Alem je jedini human — decision bottleneck
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
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
- What: Cloud accounting for Balkan SMBs (Serbia, BiH, Croatia)
- Target: 50K-500K SMBs across Balkan region
- Inspiration: Fiken (Norway) — simple, compliant, affordable
- Pipeline: See PIPELINE.md (8-gate checklist)
- Project ID: bbd77cc0
- Domains: bilko.io (primary), bilko.rs (Serbia), bilko.cloud (Croatia / HR), bilko.company (Bosnia / BA)
- Landing pages: apps/landing-hr/ (bilko.cloud) + apps/landing-ba/ (bilko.company) — deployed to CF Pages
Branding
- Name: Bilko (from Serbian "bilans" = balance sheet)
- Primary Color: #8B6BBF (Plum)
- Secondary: #5B3E8A (Deep Plum)
- Accent: #F2C87A (Gold)
- Surface: #F9F7FC (Light Lavender)
- Text Dark: #231C33
- Font Heading: National Park
- Font Body: Work Sans
- Font Mono: DM Mono
- Grid: 8px spacing system
- Icons: Lucide React
Tech Stack (updated 2026-03-17)
- Frontend: Next.js 15 + React 19 + TypeScript + Tailwind CSS 4 + shadcn/ui (ALAI standard ✓)
- Backend: Kotlin/Ktor + Exposed + Flyway (ALAI standard, sole canonical backend, ADR-020+ADR-021). CEO removed Express/api-express 2026-05-02 (MC #10493).
- State: Zustand (installed but mostly React hooks currently)
- Charts: Recharts (BarChart, PieChart, LineChart)
- Monorepo: Turborepo
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:
- Dashboard (revenue, expenses, charts)
- Invoices List + Create (6-step wizard)
- Expenses List
- Purchases (alias to expenses)
- Banking (placeholder)
- Reports Hub + VAT Report
- Settings
- Layout (sidebar + top-bar)
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
- Organization, User, AccountType, Account, Contact
- Invoice, InvoiceItem, Expense, Transaction
- BankAccount, BankTransaction, Currency, ExchangeRate
- LoggedAction (audit), SchemaVersion
KEY DECISIONS:
- Double-entry bookkeeping (debit/credit in Transaction model)
- Multi-currency with exchange rate locking at transaction date
- NUMERIC(19,4) for ALL monetary amounts — NEVER use float
- UUID primary keys throughout
- Immutable audit trail (LoggedAction table is APPEND-ONLY)
- Organization-scoped multi-tenancy
- RBAC: owner, admin, accountant, viewer
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
- Money = NUMERIC(19,4) — NEVER use float or number for currency
- Double-entry always — Every financial event = debit + credit entries
- Multi-currency locking — Exchange rate locked at transaction date
- Immutable audit — LoggedAction is append-only, NEVER delete
- Mock data replacement — Flag all mock data usage, replace with API calls
- Schema migrations — Always create new migration, NEVER edit existing
Specs Location
All specs in ~/system/specs/bilko-*.md:
- bilko-prd.md (product requirements)
- bilko-tech-stack.md (technical decisions)
- bilko-wireframes.md (UI specs)
- bilko-brand-identity.md (branding)
Open Banking (Bank Feed)
Bilko uses Tok (~/ALAI/products/Tok/) for automatic bank feed via Open Banking (PSD2 AISP).
- Tok is the independent Open Banking platform — Bilko is a consumer of Tok API
- Integration spec:
docs/INTEGRATION-WITH-TOK.md - Tok docs:
~/ALAI/products/Tok/docs/ - Open Banking docs have been migrated to Tok —
docs/open-banking/no longer exists
Documentation
- Root index:
docs/INDEX.md— documents (see INDEX.md for current count) - Backend API:
docs/backend/API-REFERENCE.md(contract for api/ implementation) - Regulatory:
docs/regulatory/(Serbia/BiH/Croatia accounting laws) - Legal:
docs/legal/(Privacy Policy, ToS, Data Retention) - Security:
docs/security/(11 docs — GDPR, DPIA, encryption, pentest) - Business:
docs/business/(GTM, pricing, beta testing, onboarding) - Open Banking integration:
docs/INTEGRATION-WITH-TOK.md
Shared Dev Configs
- TypeScript: `@alai/tsconfig` — `~/ALAI/internal/configs/packages/tsconfig/`
- ESLint: `@alai/eslint-config` — `~/ALAI/internal/configs/packages/eslint-config/`
- Prettier: `@alai/prettier-config` — `~/ALAI/internal/configs/packages/prettier-config/`
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
- Market Research — TAM/SAM/SOM analysis, customer pain points
- Competitive Analysis — Competitor landscape, differentiation strategy
- Tech Stack Decision — Frontend, backend, database, hosting choices
- Product Requirements — PRD with features, user stories, acceptance criteria
- Database Schema — Full schema design validated against PRD
- UI/UX Design — Wireframes, mockups, design system
- Regulatory Compliance — Legal research (Serbia, BiH, Croatia accounting laws)
- 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
- ✅ All features mapped to user stories
- ✅ Acceptance criteria defined
- ✅ Technical feasibility confirmed
- ✅ Resource estimate (8-10 weeks MVP, €2K bootstrap)
Gate 5: Database Schema — PASS
- ✅ All PRD features covered by schema (15 models)
- ✅ No phantom features in schema not in PRD
- ✅ Multi-currency support validated (Currency + ExchangeRate models)
- ✅ Double-entry bookkeeping validated (Transaction.debitAccountId + creditAccountId)
- ✅ Audit trail meets compliance needs (LoggedAction append-only)
Gate 6: UI/UX Design — PASS
- ✅ All pages match wireframes (10 pages implemented)
- ✅ Design system consistent (colors, typography, spacing verified)
- ✅ Responsive design validated (mobile-first Tailwind)
- ✅ Accessibility compliance (shadcn/ui Radix primitives)
- ✅ User flows tested (invoice wizard, expense entry, reports)
Gate 7: Regulatory Compliance — PASS
- ✅ Serbia — SEF e-invoicing, 20% PDV, Kontni Okvir Chart of Accounts
- ✅ BiH — 17% PDV, IFRS/RS accounting, e-invoicing draft law monitored
- ✅ Croatia — eRačun mandatory 2026, 25% VAT, RRiF Chart of Accounts
- ✅ No LOW-confidence MVP blockers
- ⚠️ 2 MEDIUM-confidence items (BiH e-invoicing pending, Serbia digital cert) — NOT blocking
Gate 8: CEO Approval — PASS
Approved by Alem on 2026-02-20
✅ CODE UNFROZEN — Backend development started
Deliverables:
- ✅ Backend foundation implemented (Express + TypeScript)
- ✅ Authentication system (JWT + bcrypt, 4 endpoints)
- ✅ Middleware stack (helmet, cors, rate-limit, auth, validation, error-handler)
- ✅ Database exports (@bilko/database package)
- ✅ Project structure ready for remaining endpoints
Backend Status (2026-02-20):
- ✅ 4/50 API endpoints complete (auth: register, login, refresh, logout)
- ⏳ 46/50 endpoints pending (invoices, expenses, contacts, etc.)
- ✅ All middleware and utilities implemented
- ✅ Route aggregator ready for expansion
Next Steps:
- Implement remaining 46 API endpoints (invoices, expenses, contacts, accounts, transactions, reports, banking)
- Create Zod validators for all endpoints
- Add integration tests for auth flow
- Connect frontend to real backend (replace mock data)
- 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
- Backend development started (2026-02-20) — Authentication system complete, 46 endpoints remaining
- Frontend is prototype — Still using mock data. Backend connection pending full API implementation.
- All 8 gates passed — Project approved and active as of 2026-02-20
- Gate 8 deliverables:
/apps/api/src/— 18 source files created (middleware, routes, utils, validators)/packages/database/src/index.ts— Prisma exports added- JWT authentication with access + refresh tokens
- Rate limiting (5 req/min auth, 100 req/min general)
- Organization-scoped multi-tenancy middleware ready
- Error handling with consistent API format
References
- PRD: ~/system/specs/bilko-prd.md
- Tech Stack: ~/system/specs/bilko-tech-stack.md
- Wireframes: ~/system/specs/bilko-wireframes.md
- Brand Identity: ~/system/specs/bilko-brand-identity.md
- Database Schema: packages/database/prisma/schema.prisma
- Frontend Code: apps/web/
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:
- BankID returns fødselsnummer (11-digit) which encodes DOB → extract and validate age >= 18
- In demo/MVP: mock BankID with DOB field, enforce 18+ check in
/api/auth/register - Pass-through model: Drop never holds money, uses Open Banking (PSD2) to read balance and initiate transfers
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):
- Open Banking provider (PSD2 AISP/PISP) for production bank account access
- Card issuing provider (Stripe or similar) for physical/virtual cards — gated behind feature flags
- KYC provider (Sumsub/Onfido or partner's existing system)
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
- Engine: PostgreSQL 16 (all environments — development, CI, staging, production)
- ORM: Drizzle ORM (
src/shared/db/schema.ts— single source of truth) - Local dev: Docker (
docker compose up -d), port 5433 - Rationale: Full test/prod parity, type-safe schema, PostgreSQL-native features (ADR-014)
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.balancefield 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)
- Purpose: Read user's bank account balance and transaction history
- Method: Open Banking API via BankID consent
- Storage: Cached balance in
bank_accounts.balance(read-only, synced periodically) - Note: Drop never controls or holds this balance
PISP (Payment Initiation)
- Purpose: Initiate payments directly from user's bank account
- Use cases: Remittance transfers, QR merchant payments
- Method: Open Banking payment initiation with Strong Customer Authentication (SCA)
- Flow: User approves payment → PISP initiates → Bank debits user's account → Drop records transaction
Compliance Requirements (PSD2)
- User consent: Explicit BankID consent required for AISP + PISP access
- SCA (Strong Customer Authentication): Required for all payments
- Data minimization: Only store what's necessary for compliance
- Audit trail: All PISP/AISP operations logged in
audit_logtable - Right to withdraw consent: Tracked in
consentstable
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
- Method: JWT (jose library)
- Storage: httpOnly cookie (NOT localStorage)
- Expiry: 1h access token
- PIN: bcrypt hashed, never stored plain
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
- In Transit: HTTPS/TLS (when deployed)
- At Rest: AWS RDS AES-256 encryption (TLS 1.3 in transit to DB)
- PII: Phone numbers hashed in logs
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)
- Date: 2026-02-08
- Status: Superseded by ADR-014
- Context: Demo app needs simple DB setup
- Decision (original): SQLite — zero config, file-based
- Consequence: Could not handle concurrent writes well. Superseded before production use.
- Current state: PostgreSQL 16 in all environments (development, CI, staging, production). See ADR-014.
ADR-002: JWT in httpOnly Cookie
- Date: 2026-02-08
- Status: Accepted
- Context: Need secure token storage
- Decision: httpOnly cookie prevents XSS token theft
- Consequence: Slightly more complex CSRF handling needed.
ADR-003: Monolith Architecture
- Date: 2026-02-08
- Status: Accepted
- Context: 7 pages, simple API
- Decision: Single Next.js app handles everything
- Consequence: Easy to deploy and maintain. Refactor if scaling needed.
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 | ✅ |
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-accountsto 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:
- 400 — invalid amount (min 100 NOK, max 50000 NOK)
- 402 — insufficient balance
- 403 — KYC not approved
- 404 — recipient not found
- 422 — unsupported corridor
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:
- 400 — invalid amount (min 1 NOK)
- 402 — insufficient balance
- 404 — merchant not found
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)
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). Usemake db-pushto 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
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:
- The state of the art
- The cost of implementation
- The nature, scope, context, and purposes of processing
- 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:
- JMBG (Serbia/BiH citizen number): Encrypted at application layer before storage. HMAC-SHA256 hash stored in
jmbgHashcolumn for exact-match lookup when required. - OIB (Croatia personal/company ID): Encrypted at application layer before storage. HMAC-SHA256 hash stored in
oibHashcolumn for exact-match lookup.
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:
- These fields are rarely queried in bulk (lookup is by known value, not range/partial match)
- These fields are displayed to users infrequently (only on contact detail views, not list views)
- The DPIA (section 3) already documents AES-256-GCM for these fields -- implementing it fulfills the documented risk mitigation
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:
- PIB (Serbia tax ID): Publicly searchable on APR. Printed on every invoice. Encrypted at disk level (Railway AES-256). Protected by org-scoping middleware, RBAC, audit trail, and TLS in transit.
- JIB (BiH tax ID): Publicly searchable on court registry. Printed on every invoice. Same controls as PIB.
- IBAN: Shared with every business partner for payment. Printed on invoices. Masked in list responses (show last 4 digits only). Same disk-level + application-layer controls.
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
FIELD_ENCRYPTION_KEY: 32-byte hex string stored in Railway secrets. Used for JMBG and OIB encryption only.FIELD_HMAC_KEY: Separate 32-byte hex string stored in Railway secrets. Used for deterministic hash columns.- Key rotation: Annual. Rotation requires a migration script to re-encrypt all JMBG/OIB rows -- scoped to Contact table only (manageable volume at MVP scale).
- Per Key Management Policy (POL-SEC-KM-001), keys are never committed to source code and are provisioned only through Railway environment secrets or Vaultwarden.
3. Alternatives Considered
Option A: No field-level encryption for any L4 field (original Security Architecture position)
Pros:
- Zero query limitations -- PIB, JMBG, OIB, JIB, IBAN all fully searchable/sortable
- Zero performance overhead from application-layer crypto
- Simpler development -- no encryption middleware, no hash columns, no key management
- Consistent with what competitors do (FreeAgent, Fiken, Minimax)
- Disk-level encryption (Railway AES-256) protects against physical theft and disk-level breaches
Cons:
- A database breach (e.g., SQL injection bypassing Prisma, Railway compromise, backup leak) exposes all JMBG/OIB in plaintext
- JMBG exposure is irrevocable -- victims cannot change their JMBG
- Contradicts the DPIA (section 3) which documents field-level encryption as a committed mitigation
- Weaker posture for regulatory audits and GDPR accountability demonstrations
- Negligent if a breach occurs and DPA asks why field-level encryption was not implemented despite being documented in the DPIA
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:
- Maximum defense-in-depth -- all L4 data encrypted at field level
- Simplest compliance narrative ("all sensitive data is encrypted at rest at the field level")
- Matches the DPIA and Encryption Policy as written
Cons:
- PIB and JIB are publicly available data -- encrypting them provides marginal security benefit
- IBAN is shared routinely -- encrypting it adds complexity for minimal breach-impact reduction
- Encrypting PIB/JIB breaks SQL
WHEREclause filtering for invoice lookups by buyer tax ID -- a common query pattern for SEF e-invoice reconciliation and accounting workflows - Encrypting IBAN breaks bank reconciliation queries
- Performance overhead on every invoice read/write for fields that are public or semi-public
- Over-engineering -- GDPR Art. 32 requires measures "appropriate to the risk," not maximum possible measures
- No competitor in the accounting SaaS space implements field-level encryption for business tax IDs
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:
- Proportionate to risk: highest-sensitivity fields get strongest protection
- Preserves full query capability for business tax IDs (PIB, JIB) and IBAN -- critical for accounting workflows
- Manageable scope: only Contact table fields encrypted, not Organization or BankAccount
- HMAC hash columns enable exact-match lookup for the encrypted fields
- Consistent with GDPR Art. 32 risk-based approach
- Defensible in regulatory audit: "we assessed risk per field and applied measures proportionate to each"
- Limited performance impact: JMBG/OIB are read infrequently (contact detail views only)
Cons:
- More nuanced compliance narrative than "everything is encrypted"
- Two tiers of L4 data treatment requires clear documentation
- IBAN remains in plaintext at database level (mitigated by disk encryption + masking)
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:
- Encryption happens at the database layer -- transparent to application code
- Works with raw SQL queries (unlike Prisma middleware)
Cons:
- Requires
pgp_sym_encrypt()/pgp_sym_decrypt()in every query -- significantly increases SQL complexity - Key must be passed in every query or stored in a PostgreSQL session variable -- key management is harder
- Railway managed PostgreSQL may not support pgcrypto extension
- Prisma has no native support for pgcrypto -- requires
$queryRawfor all encrypted field operations, negating the benefits of the Prisma ORM (ADR-011) - Breaks Prisma schema-as-code principle
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
- JMBG and OIB are protected against database breaches -- even full DB dump yields only ciphertext
- HMAC hash columns enable exact-match lookup without exposing plaintext
- PIB, JIB, IBAN remain fully queryable -- no impact on accounting workflows, SEF reconciliation, or bank reconciliation
- Risk-proportionate approach is defensible under GDPR Art. 32 and ZZPL Art. 50
- Aligns with DPIA documented mitigations (AES-256-GCM for personal identifiers)
- Key management scope is limited (one encryption key, one HMAC key, scoped to two field types)
4.2 Negative Consequences
- JMBG and OIB fields cannot be filtered, sorted, or partially matched in SQL -- only exact-match via hash -- Mitigation: These fields are rarely queried in bulk; exact-match via HMAC covers the primary use case (lookup by known value)
prisma-field-encryptionlibrary is a third-party dependency (47ng/prisma-field-encryption, MIT license) -- Mitigation: Library is well-maintained, 600+ GitHub stars, uses Node.js nativecryptomodule internally; can be replaced with custom Prisma extension if abandoned- Key rotation requires re-encryption migration -- Mitigation: Scoped to Contact table rows only; at MVP scale (<10K contacts), migration completes in seconds
- Raw SQL queries (used for financial reports per ADR-011) bypass Prisma encryption middleware -- Mitigation: JMBG/OIB are never included in financial report queries; they are contact metadata, not transaction data
4.3 Technical Debt Created
- DATA-ENCRYPTION-POLICY.md and COMPLIANCE-FRAMEWORK.md must be updated to reflect the tiered L4 approach (JMBG/OIB = field-level, PIB/JIB/IBAN = disk-level + controls) instead of blanket "all L4 = field-level" -- Plan: Update immediately after ADR approval
- SECURITY-ARCHITECTURE.md line "Column-level encryption: Not needed" must be updated to reflect the hybrid decision -- Plan: Update immediately after ADR approval
- DPIA data inventory table should differentiate encryption tier per field -- Plan: Update in next DPIA revision
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:
- JMBG/OIB: Irrevocable personal identifiers with high breach impact. Field-level encryption is justified and proportionate.
- PIB/JIB: Publicly available business identifiers. Field-level encryption adds cost without meaningful risk reduction.
- 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
- GDPR Article 32 -- Security of Processing
- GDPR Article 87 -- Processing of National Identification Numbers
- Serbia ZZPL (Sl. glasnik RS 87/2018)
- BiH ZZLP -- AZLP Security Measures Guidance
- Croatia AZOP -- Data Protection Overview
- prisma-field-encryption Library
- FreeAgent Security
- ICO Encryption Guidance
- JMBG Structure and Sensitivity
- Bilko DPIA (docs/security/DPIA.md)
- Bilko Data Encryption Policy (docs/security/DATA-ENCRYPTION-POLICY.md)
- Bilko Compliance Framework (docs/security/COMPLIANCE-FRAMEWORK.md)
- Bilko Key Management Policy (docs/security/KEY-MANAGEMENT-POLICY.md)
Approval
| Role | Name | Date | Signature |
|---|---|---|---|
| Author | Petter Graff | 2026-02-25 | |
| Tech Lead | |||
| DPO | |||
| CTO / Architect | Alem |
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 |
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 |
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. |
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) |
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
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
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
- 19-table schema includes 7 dedicated compliance tables (
audit_log,aml_alerts,str_reports,screening_results,consents,data_access_requests,complaints) — most fintechs bolt these on later - Pass-through PSD2 model correctly avoids e-money license complexity — Drop positions as PISP/AISP only
- BankID-only authentication is the right call for Norwegian fintech (SCA by design, no password management)
- Dual-database abstraction (
db.ts) with SQL compatibility translation is clean engineering — SQLite for dev speed, PostgreSQL for production reliability
2.2 Security Fundamentals
- Parameterized SQL throughout — zero SQL injection vulnerabilities
- bcrypt with 12 rounds for password hashing
- Idempotency keys on transactions with unique index — double-charge protection exists
- Structured audit logging with IP, user-agent, request-id correlation
2.3 AML/Compliance Framework
The transaction monitoring module (transaction-monitor.ts) has 5 rule types:
- Structuring detection (multiple small txns avoiding thresholds)
- Velocity checks (>5 txns/hour)
- High-amount flagging (>25K NOK)
- High-risk corridor detection (FATF grey-list countries)
- Unusual pattern analysis (3x user average)
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
- JWT key rotation process?
- BankID downtime fallback?
- Session hijacking detection (2 IPs simultaneously)?
- Direct database access logging?
Data Integrity
- DST transition handling with TEXT timestamps?
- Exchange rate locking between quote and execution?
- Transaction status if PISP call times out?
- Client-generated idempotency key security?
Compliance
- How to enforce 90-day AISP consent expiry?
- Who reviews AML alerts and how fast?
- STR filing integration with Økokrim?
- GDPR data export time for user request?
Operations
- Database backup RPO/RTO?
- Zero-downtime schema migration strategy?
- PagerDuty alert rules?
- 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
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
| # | 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" |
Recommended Fix Priority
Batch 1: Global Find-Replace (resolves ~20 findings)
- "Next.js 16" to "Next.js 15" (all docs)
- "24h (web)" / "7d (mobile)" to "7d (all clients)" (all docs)
- "sameSite=strict" to "sameSite=Lax" (all docs)
- "middleware.ts" to correct paths (all docs)
Batch 2: Section Rewrites (resolves ~15 findings)
- Deployment architecture: mark AWS as planned, document Docker Compose current state
- Auth flow: remove email/password, document BankID-only + demo-login
- QR payment: fix amount units, fee model, HMAC optionality
- Registration: BankID auto-creation, not 4-step
Batch 3: Missing Documentation (resolves ~15 findings)
- Demo mode architecture (auth.ts, mode.ts, payments.ts branching)
- GDPR endpoints (user.ts: objection, rectification, restriction)
- Withdrawal flow (withdrawal.ts)
- Feature flag mapping (feature-flags.ts: 8 flags)
- Error handling chain (error-handler.ts, Sentry, alerts)
- AML monitoring rules and thresholds
- Data retention cron job
- PSD2 disclosure endpoint
- Middleware request lifecycle
Batch 4: Cross-Doc Consistency (resolves ~10 findings)
- Unify data classification taxonomy
- Fix ADR contradictions (blue/green, rate limiting, health check paths)
- Fix AISP consent period (180d to 90d per PSD2)
- 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: 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:
- Send penger internasjonalt med lavere gebyrer enn Wise, Vipps eller Western Union
- 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?
- Last ned Drop — Gratis på App Store
- Verifiser med BankID — Tar 2 minutter
- 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!
Copyright
© 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:
- Send penger internasjonalt med lavere gebyrer enn Wise, Vipps eller Western Union
- 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?
- Last ned Drop — Gratis på Google Play
- Verifiser med BankID — Tar 2 minutter
- 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
- 6.7" iPhone Pro Max (1290 x 2796 px) — 10 screenshots
- 5.5" iPhone 8 Plus (1242 x 2208 px) — 10 screenshots
- 12.9" iPad Pro (2048 x 2732 px) — 10 screenshots
- Android Phone (1080 x 1920 px or higher) — 8 screenshots
App Icon
- iOS: 1024×1024 PNG (no transparency, no rounded corners)
- Android: 512×512 PNG (may include transparency)
Feature Graphic (Google Play only)
- 1024 × 500 PNG
Promo Video (optional)
- 30-second app preview video
- YouTube URL for Google Play
Notes for Submission
- BankID requirement — Make sure to mention in description that BankID is required for verification (Norwegian users only)
- Age restriction — 17+ due to financial transactions and BankID requirement (18+ in practice)
- Permissions explanation — Camera (for QR scanning), Internet (for transactions), Notifications (for transaction alerts)
- Regulatory compliance — Mention ALAI Holding AS as the legal entity and Norwegian registration
- Pricing transparency — Always show exact fees upfront (0,5% remittance, 1% QR payments)
- Pass-through model — Emphasize that Drop never holds user money (Open Banking PSD2)
- Norwegian language — All store content is in Norwegian Bokmål (target market)
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+):
- Foreground: 512 × 512 px (icon artwork)
- Background: 512 × 512 px (solid color or simple pattern)
- Safe area: Center 264 × 264 px (guaranteed visible on all shapes)
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):
- Shape: Rounded rectangle with green gradient (#0B6E35 → #064E25)
- Typography: "Drop" wordmark in white, Fraunces font (serif, bold)
- Icon element: Currency exchange "o" with circular arrows + "kr" inside
- Accent: Gold dot top-right of the "p"
Location:
- Web:
src/drop-app/components/drop-logo.tsx - Mobile:
components/DropLogo.js - Brand assets:
brand/directory
Icon Concept Options
Option 1: Drop "D" Lettermark (RECOMMENDED)
Design:
- Large "D" letter in Fraunces serif font (700 weight)
- Green gradient background (#0B6E35 → #064E25, 135° diagonal)
- White "D" with subtle shadow for depth
- Gold accent: small circle or dot positioned top-right or inside the "D" curve
- Optional: Currency symbol "kr" subtly integrated into the "D" negative space
Rationale:
- Simple, scalable, recognizable at all sizes
- Strong brand association with "Drop" name
- Serif "D" gives premium, trustworthy feel (important for finance)
- Green + gold = Drop brand colors
Implementation:
- Create "D" lettermark in Figma (vector)
- Apply gradient background
- Add gold accent for visual interest
- Export at required sizes
- Test visibility at small sizes (48px, 72px, 96px)
Option 2: Currency Exchange Symbol
Design:
- Circular icon with two curved arrows (↻ circular exchange motif)
- "kr" currency symbol in center
- Green gradient background
- Gold glow or accent around arrows
- Minimalist, flat design
Rationale:
- Directly communicates core function (money exchange/transfer)
- Familiar symbol (used in Drop logo "o")
- Works well at small sizes
- Less text-dependent than lettermark
Implementation:
- Design circular arrows in Figma (vector paths)
- Place "kr" in center (DM Sans or Fraunces font)
- Apply gradient and glow effects
- Export and test
Option 3: Drop + Currency Hybrid
Design:
- "Drop" wordmark compressed to fit square canvas
- Currency arrows integrated into the "o"
- Green gradient background
- Gold dot accent on "p"
- Rounded rectangle container (similar to current logo)
Rationale:
- Most direct translation of current logo
- Full brand name visible
- Recognizable for users who know the logo
Challenges:
- Text may be hard to read at small sizes (48px icon on home screen)
- Horizontal logo doesn't fit square canvas well
- Risk of looking cluttered
Recommendation: Use this only if lettermark or symbol don't test well.
Design Principles
1. Simplicity
- App icons must be recognizable at 48px × 48px (smallest size on device)
- Avoid fine details, thin lines, or complex shapes
- Limit to 2-3 colors max
2. Memorability
- Unique shape or color combination
- Avoid generic finance icons (e.g., dollar sign, piggy bank)
- Drop brand = green gradient + gold accent — use this consistently
3. Scalability
- Test at multiple sizes: 48px, 72px, 96px, 144px, 192px, 512px, 1024px
- Icon should be clear and legible at all sizes
- Avoid gradients that lose contrast when scaled down
4. Platform Fit
- iOS: Modern, flat, colorful, rounded by system
- Android: Material Design, adaptive, bold
- Consider context: app icon sits among many others — must stand out
5. Trust & Professionalism
- Finance app = users trust it with money
- Avoid playful or cartoonish styles
- Premium feel: clean typography, quality gradients, subtle shadows
Production Workflow
Step 1: Design in Figma
- Create new Figma file: "Drop App Icon"
- Set canvas to 1024 × 1024 px (iOS size)
- Design icon using Drop brand colors and fonts
- Consider safe area for Android adaptive icon (center 264 × 264 px)
- Export variations for review
Step 2: Review & Iterate
- Export 3-5 icon concepts
- Test at small sizes (48px, 72px, 96px)
- Review with Alem for approval
- Iterate based on feedback
Step 3: Finalize Assets
iOS:
- Export 1024 × 1024 px PNG (no transparency, flattened)
- Verify no rounded corners in design
- Validate with App Store Connect's icon validator
Android:
- Export 512 × 512 px PNG (standard icon)
- Export adaptive icon layers:
- Foreground: 512 × 512 px PNG (transparent background)
- Background: 512 × 512 px PNG (solid color or pattern)
- Test with Android Asset Studio preview tool
Step 4: Implement
- iOS: Add to
ios/Drop/Images.xcassets/AppIcon.appiconset/ - Android: Add to
android/app/src/main/res/mipmap-*/ic_launcher.png - Android adaptive: Add foreground/background to
mipmap-anydpi-v26/
Testing Checklist
- Icon is visible and clear at 48px × 48px
- Icon stands out among other app icons (test on real device home screen)
- Gradient doesn't wash out at small sizes
- Text (if any) is legible at all sizes
- Colors match Drop brand palette
- No transparency issues on iOS (must have solid background)
- Android adaptive icon safe area respected (center 264px circle)
- Icon looks good on both light and dark backgrounds
- No copyright issues (all elements original or licensed)
- Final review by Alem
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:
- Original: Wordmark "Drop" with currency "o" (circular arrows + "kr")
- App icon: Should be simplified version — lettermark or symbol
Do:
- Use official Drop green gradient (#0B6E35 → #064E25)
- Include gold accent (#D4A017) for brand recognition
- Use Fraunces (serif) for lettermarks or DM Sans (sans-serif) for symbols
- Maintain premium, trustworthy feel
Don't:
- Use system fonts (Arial, Helvetica) — breaks brand consistency
- Use bright/neon greens — not Drop brand
- Include too much text — icon must work at 48px
- Copy competitor icons (Vipps, Wise, Revolut) — must be unique
Approval Process
- Design concepts: Create 3 variations in Figma
- Internal review: John reviews for brand consistency + technical requirements
- Alem approval: Final design decision (visual + brand fit)
- Asset generation: Export iOS + Android at required sizes
- Device testing: Preview on real iOS + Android devices
- Store submission: Upload to App Store Connect + Google Play Console
References
- Apple HIG (App Icons): https://developer.apple.com/design/human-interface-guidelines/app-icons
- Material Design (Icons): https://m3.material.io/styles/icons/overview
- Android Asset Studio: https://romannurik.github.io/AndroidAssetStudio/icons-launcher.html
- Drop Brand Assets:
/Users/makinja/ALAI/products/Drop/brand/
Next Steps
- Create icon concepts in Figma: 3-5 variations (lettermark, symbol, hybrid)
- Export preview at multiple sizes: Test legibility
- Present to Alem for approval: Visual review + brand fit
- Finalize assets: iOS 1024px + Android 512px + adaptive layers
- Integrate into mobile app: Add to iOS/Android projects
- 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.
Recommended approach: Figma design → Export PNG at required sizes → Validate → Submit.
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:
- Headline: Bold, 3-5 words, main benefit
- Subtext: 1 line, supporting detail
- Visual: Corresponding app screen from Figma Make export
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
- Show BankID logo prominently
- Green "Logg inn med BankID" button
- Clean, minimal design
- No clutter, focus on trust
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
- Show bank account balance (AISP demo)
- Quick actions: "Send penger", "Skann QR", "Motta"
- Recent transactions preview
- Green gradient balance card
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
- Show currency conversion (NOK → RSD example)
- Fee breakdown: "Send 10 000 kr → mottar 117 050 RSD, gebyr 50 kr"
- Country flags visible
- Clear, transparent pricing
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
- Show camera viewfinder with QR overlay
- Example merchant: "Ahmetov Kebab"
- Payment flow: scan → amount → confirm
- Gold accent for QR feature
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
- List of transactions with icons
- Filters: "Alle", "Sendt", "Mottatt", "QR"
- Date grouping: "I dag", "I går", "Denne uken"
- Clear amounts with +/− indicators
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
- Show linked bank account (DNB example)
- Balance visible via AISP
- "Koble til ny bank" CTA
- Security badge: "Sikret med BankID"
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
- Notification list: "Betaling fullført", "Penger mottatt"
- Timestamps
- Read/unread states
- Push notification icon
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
- User profile with BankID verification badge
- Settings sections: "Sikkerhet", "Varsler", "Personvern"
- Logout option
- Clean, organized layout
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
- Onboarding steps: "Last ned → Verifiser → Send"
- Progress indicator
- Friendly illustration or icon
- "Neste" CTA button
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
- Merchant view: "Dagens inntekt", "Antall transaksjoner"
- QR code display
- Payout schedule
- Business-focused design
Design Guidelines
Typography
- Headline font: Fraunces (serif, 700 weight, 32-40px)
- Subtext font: DM Sans (sans-serif, 400 weight, 16-18px)
- Color: Headlines in dark (#1A1A1A), subtext in muted (#6B7280)
Layout
- Overlay text at top or bottom of screenshot (avoid covering key UI)
- Use subtle gradient or solid background behind text for readability
- Align left or center depending on screen composition
- Keep 24px margin from screen edges
Branding
- Consistent with Drop brand: green (#0B6E35), gold accent (#D4A017)
- Drop logo watermark in corner (small, 48px height)
- Professional but approachable tone
Accessibility
- High contrast between text and background
- Minimum 14px font size for subtext
- No more than 2 lines of text per screenshot
Platform-Specific Considerations
iOS App Store
- 6.7" iPhone Pro Max: 1290 × 2796 px (primary)
- 5.5" iPhone 8 Plus: 1242 × 2208 px (fallback)
- Text overlay can be more minimal (iOS users familiar with gestures)
- Show status bar if relevant to UX
Google Play Store
- Android Phone: 1080 × 1920 px or higher
- Text overlay should be more descriptive (diverse Android audience)
- Consider showing navigation elements for context
Production Workflow
- Export screens from Figma Make: High-res PNG/JPG (2x or 3x)
- Add text overlays: Use Figma or Photoshop with Drop brand fonts
- Apply brand elements: Logo watermark, gradient accents if needed
- Review for consistency: All screenshots should feel cohesive
- Export final assets: Correct dimensions per platform requirements
- Test on devices: Preview how they look in actual store listings
Norwegian Language Notes
All text is written in natural Norwegian Bokmål:
- Trygg = secure, safe
- Enklere = simpler, easier
- Billigere = cheaper
- På sekunder = in seconds
- Full kontroll = full control
- Kom i gang = get started
Avoid direct translations from English — use idiomatic Norwegian expressions.
Review Checklist
- All 10 screenshots have Norwegian headlines and subtext
- Text is readable at thumbnail size
- Headlines are 3-5 words, subtext is 1 line
- Consistent typography (Fraunces + DM Sans)
- Drop branding visible but not overwhelming
- Each screenshot showcases one clear feature
- No spelling or grammar errors in Norwegian
- Platform-specific sizes prepared (iOS + Android)
- Final review by native Norwegian speaker (Alem)
Marketing
Marketing materials and strategy
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
- System Overview
- Monorepo Structure
- Component Architecture
- Data Flow
- Tech Stack Rationale
- Multi-Tenancy Model
- Authentication Architecture
- Multi-Currency Architecture
- Country Plugin System
- Infrastructure Overview
- 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:
- Double-entry bookkeeping engine with immutable audit trail
- Multi-country regulatory compliance (RS, BA, HR) via pluggable country modules
- Multi-currency support with exchange rate locking at transaction date
- Organization-scoped multi-tenancy
- All monetary values stored as
NUMERIC(19,4)— never float
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:
- Access token: returned in response body, client stores in memory
- Refresh token:
httpOnlycookie, path/api/v1/auth,SameSite: strict
Security:
- Passwords: bcrypt with 12 salt rounds (
apps/api/src/utils/password.ts) - JWT: RS256 signing, issuer/audience validation (
apps/api/src/utils/jwt.ts) - Optional 2FA: TOTP via
User.twoFactorSecret(field exists, not yet wired)
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 |
| 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:
- Static asset caching at edge PoPs globally
- Automatic HTTPS + TLS certificate rotation
- ISR (Incremental Static Regeneration) for report pages
- Preview deployments on every pull request branch
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
- API Endpoint Specifications
- Database Schema Documentation
- Service Layer Design
- Middleware Stack
- Double-Entry Bookkeeping Implementation
- Tax Calculation Logic Per Country
- Invoice Lifecycle
- Bank Import Flow
- Core Engine Modules
- 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).
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
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:
- Constructor receives
PrismaClient(or use singletonprismafromlib/prisma.ts) - All methods receive
organizationIdas first parameter - Return plain objects (not Prisma model instances) for clean API layer separation
- Use Prisma transactions (
prisma.$transaction()) for multi-step operations - Throw errors from
utils/errors.tsfor consistent HTTP responses
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:
400— Validation error, bad request401— Missing token, expired token, invalid credentials403— Insufficient permissions (role check)404— Resource not found409— Duplicate (unique constraint)422— Unprocessable entity (e.g., same debit/credit account)429— Rate limit exceeded500— Unhandled server error
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:
debitAccountId— account to debitcreditAccountId— account to creditamount— must be equal for both sides (enforced by model design, not DB constraint)currencyCode+exchangeRate+baseAmount— for multi-currency
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:
accountTypeId: 1= AssetaccountTypeId: 2= LiabilityaccountTypeId: 3= EquityaccountTypeId: 4= RevenueaccountTypeId: 5= Expense
Code prefixes (Balkan chart of accounts):
10x= Bank/Cash accounts12x= Accounts Receivable22x= Accounts Payable5xx= Expense accounts6xx= Revenue 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:
- For each line item:
lineTotal = quantity × unitPrice taxAmountper line:lineTotal × taxRate / 100subtotal = Σ lineTotalstaxAmount = Σ lineTax amountstotalAmount = subtotal + taxAmountbaseAmount = 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:
- TypeScript types (Prisma models, API response shapes) must be shared between frontend and backend without duplication
- Country plugins must be independently releasable (a VAT rate change in Serbia should not require redeploying Croatia logic)
- Build times must remain fast as the codebase grows
Business forces:
- MVP must launch quickly; tooling complexity must be low
- Team is small — cannot manage multiple separate repositories
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:
- Fast incremental builds — only rebuilds changed packages
- Shared TypeScript types across all apps and packages
- Country plugins are independent packages — different release cadence per country regulation
- Single repo — one PR, one CI run, one git history
Cons:
- Turborepo learning curve for new developers
- All packages share the same git repo — sensitive to large PRs
Cost/Effort: Low — Turborepo setup is ~1 day
Option B: Separate Git Repositories (polyrepo)
Pros:
- Maximum isolation between packages
- Per-repo CI/CD pipelines
Cons:
- Type sharing requires publishing to npm registry — adds publish/version management overhead
- Cross-repo refactoring is painful
- Slow for a small team
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:
- Simplest setup — no workspace config
Cons:
- Cannot independently version or test the accounting engine
- Country tax logic is tangled with API routes
- Cannot reuse accounting engine in future products
Why not chosen: Violates separation of concerns; makes future product reuse impossible.
4. Consequences
4.1 Positive Consequences
@bilko/corecan be unit tested without a database- Country plugins can update VAT rates in a hotfix without touching API or frontend code
- Prisma types from
@bilko/databaseare shared — no duplicated interfaces
4.2 Negative Consequences
- Turborepo
turbo.jsonpipeline config must be maintained as packages grow — Mitigation: document pipeline inPIPELINE.md
4.3 Technical Debt Created
packages/uiis an empty scaffold at MVP — not yet used — Plan: populate with shared shadcn components in v1.1
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:
- Accounting software requires exact decimal arithmetic — not approximations
- PostgreSQL's
NUMERICtype supports arbitrary precision;FLOATdoes not - Prisma maps
NUMERICto itsDecimaltype (backed bydecimal.js)
Regulatory:
- Serbian, Croatian, and BiH tax law require monetary precision to 4 decimal places for VAT calculations
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:
- Exact decimal arithmetic — 0.1 + 0.2 = 0.3000 exactly
- Range: up to ~1 quadrillion — sufficient for any SMB
- Prisma's
Decimaltype integrates seamlessly
Cons:
- Developers must remember to never use
numberfor money Decimal.jsoperations are slightly more verbose:new Decimal(a).plus(b)vsa + b
Option B: Store as integers (minor units — e.g., paras/cents)
Pros:
- Integer arithmetic is exact with no library needed
- Common approach in payment systems (Stripe stores cents)
Cons:
- Accounting requires 4 decimal places — minor units would need to be in 1/10000 of a currency unit (unusual)
- Prisma schema, API responses, and display logic all require conversion
- Confusing for accountants reviewing data directly
Why not chosen: 4-decimal-place requirement makes minor-unit storage awkward.
Option C: JavaScript number / PostgreSQL DOUBLE PRECISION
Pros:
- No library needed
- Simple arithmetic operators
Cons:
- Fatal flaw:
0.1 + 0.2 !== 0.3— causes balance sheet mismatches in any real scenario - Cannot pass an audit with floating-point rounding errors in ledger balances
Why not chosen: Fundamentally incompatible with financial software requirements.
4. Consequences
4.1 Positive Consequences
- Trial balance always balances to exactly zero —
totalDebits.equals(totalCredits) - VAT calculations are exact — no rounding surprises for Serbian PDV declarations
- API responses return monetary values as strings (e.g.,
"120000.0000") — clients parse asDecimal
4.2 Negative Consequences
- All API clients must handle monetary values as strings, not numbers — Mitigation: document in API spec; TypeScript types enforce
stringfor monetary fields
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
Transactionwith 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:
- Serbian, BiH, and Croatian accounting law require double-entry bookkeeping for legal entities
- VAT filings require a precise split between output VAT (from revenue) and input VAT (from expenses)
Technical forces:
- Double-entry enables the
Trial Balanceto always balance — a fundamental correctness check - It enables any accounting report to be derived from the same
Transactiontable
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:
- Legally correct for all three target countries
- Enables Trial Balance, Balance Sheet, P&L from one table
isBalancedcheck provides immediate data integrity verification
Cons:
- Higher implementation complexity — developers must understand debit/credit semantics
- Every status change requires finding correct GL accounts
Option B: Simple Ledger (credit/debit as +/- amounts, single account per row)
Pros:
- Simpler to implement and understand
- Easier to query (no join to debit/credit accounts)
Cons:
- Cannot produce a legally valid trial balance
- VAT report requires cross-referencing invoices and expenses separately — cannot derive from ledger
- Not compliant with Pravilnik (Serbia), RRiF (Croatia), or FBiH Pravilnik (BiH)
Why not chosen: Regulatory non-compliance disqualifies this option.
Option C: No ledger — compute reports directly from invoices/expenses tables
Pros:
- Simplest: no separate transaction table
- Fast to build for MVP
Cons:
- Cannot produce a balance sheet (assets, liabilities, equity) without a ledger
- Cannot handle manual journal entries (adjustments, corrections)
- Cannot reconcile bank transactions against GL
Why not chosen: Insufficient for formal accounting; blocks future chartered accountant use.
4. Consequences
4.1 Positive Consequences
- All accounting reports (P&L, balance sheet, cash flow, trial balance) derive from the same
Transactiontable locked = trueon transactions creates an immutable audit trailreconciled = trueflag enables bank reconciliation
4.2 Technical Debt Created
validateDoubleEntry()is called in application code, not enforced by DB constraint —debitAmount = creditAmountrelies on application logic — Mitigation: unit test every code path that creates transactions
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:
- All three accounting systems (Serbian, BiH, Croatian) require that historical financial records show the exchange rate in effect at the transaction date — recalculation is not permitted for recognized transactions
Technical forces:
- Recalculating historical amounts on every rate update would invalidate the trial balance and all prior period reports
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:
- Legally compliant — historical records reflect the rate in effect at the time
baseAmountis stable — prior period reports never change- No complex recalculation logic
Cons:
- If no rate exists for the exact date, fallback to nearest date — risk of slight inaccuracy
- Exchange rate population is a prerequisite for accuracy
Option B: Recalculate on every report
Pros:
- Always uses the current rate
- Simpler — no need to store historical rates
Cons:
- Illegal under Balkan accounting standards — invoices must show the rate at issue date
- Prior period P&L reports would change every time rates update
- Audit trail becomes meaningless if historical amounts drift
Why not chosen: Regulatory non-compliance.
4. Consequences
4.1 Positive Consequences
- Invoice
exchangeRateandbaseAmountare immutable after creation - The
ExchangeRatetable witheffectiveDatesupports complete historical rate lookup lockExchangeRate()in@bilko/corecentralizes this logic
4.2 Negative Consequences
- Exchange rate population (ECB daily cron) must be running before multi-currency invoices are created — Mitigation: warn in UI if no rate found for selected date
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:
- Team is more proficient in TypeScript/Node.js than PostgreSQL RLS configuration
- Prisma ORM does not have first-class RLS support — it would require raw SQL policies
- Application-layer enforcement is testable with unit tests
Business forces:
- MVP timeline is tight — RLS adds PostgreSQL configuration complexity
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:
- Enforceable in unit tests — mock
req.user.organizationId - Developers understand it — familiar TypeScript patterns
- Works with Prisma ORM without raw SQL
Cons:
- Defense depends on application code correctness — a missed
where: { organizationId }is a bug - No DB-level enforcement as a second layer
Option B: PostgreSQL Row-Level Security (RLS)
Pros:
- Enforcement at database level — even direct DB queries are isolated
- Defense-in-depth — second layer below application
Cons:
- Requires setting
app.current_tenant_idon each connection via Prisma$executeRaw - Prisma does not natively support connection-level session variables
- Complex to test and debug — policy errors show as unexpected empty results
Why not chosen: Prisma integration complexity and team expertise gap. Can be added post-MVP.
4. Consequences
4.1 Positive Consequences
- Enforcement is testable — every service method test verifies
organizationIdfilter is applied
4.2 Negative Consequences
- Direct DB access (e.g., migrations, admin scripts) bypasses enforcement — Mitigation: document that all data access must go through API
4.3 Technical Debt Created
- RLS would provide a stronger defense-in-depth guarantee — Plan: evaluate adding PostgreSQL RLS as a secondary enforcement layer post-MVP
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:
- A Serbia VAT rate change does not touch Croatia or BiH code
- Each plugin can be unit tested independently
- New countries (e.g., Slovenia) are added as a new package without touching existing code
- Regulatory changes trigger a focused PR in one package
Cons:
Option B: Single @bilko/countries package with all countries in subdirectories
Pros:
- Simpler dependency management
Cons:
- A Serbian VAT rate PR touches the same package as Croatia/BiH — increases risk of cross-country bugs
- Cannot version countries independently
Why not chosen: Independent versioning is critical given different regulatory cadences per country.
Option C: Country rules stored in database (configurable per org)
Pros:
- Can update VAT rates without a deployment
- Admin UI for configuration
Cons:
- VAT calculation logic (not just rates) varies by country — cannot be reduced to a database record
- E-invoice XML format generation (UBL 2.1) must be code, not config
- Much higher complexity at MVP stage
Why not chosen: Regulatory logic (e-invoice XML, fiscal year rules) cannot be stored as configuration.
4. Consequences
4.1 Positive Consequences
- Serbia launched first —
@bilko/country-rsis the most complete plugin; BiH and Croatia can lag @bilko/coreremains country-agnostic — reusable in future non-Bilko products
4.2 Negative Consequences
- Adding a new country requires creating a new package, wiring it in the API country-selector — Mitigation: document standard plugin creation guide in
CONTRIBUTING.md
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:
- API is consumed by Next.js frontend (same-domain in production) and potentially mobile PWA in future
- Access tokens must be short-lived to limit exposure if intercepted
- Refresh tokens must survive browser tab reloads without re-authentication
Security forces:
- Access tokens in
localStorageare vulnerable to XSS - Session cookies without SameSite are vulnerable to CSRF
Business forces:
- Must avoid per-user costs of managed auth services at MVP scale
- Must work without Redis (no session store in MVP — see ADR-010)
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:
- Stateless — no Redis or DB session table required
- RS256 asymmetric signing — public key can be shared for future microservices validation
- 15-min access token limits breach window
- httpOnly refresh cookie prevents XSS token theft
- No per-user cost
Cons:
- Cannot invalidate individual access tokens before expiry (JWT is stateless)
- Refresh token rotation requires careful implementation to prevent replay
Option B: Session-based authentication (server-side sessions)
Pros:
- Instant revocation — delete session from store
- No token expiry concerns on the client
Cons:
- Requires server-side session store (Redis) — increases infrastructure complexity for MVP
- Not naturally stateless — complicates horizontal scaling without sticky sessions
- Session cookies are CSRF-vulnerable without additional mitigation
Why not chosen: Requires Redis which is not in the MVP stack (ADR-010).
Option C: Delegated OAuth (Auth0, Clerk, Supabase Auth)
Pros:
- Managed security, automatic token refresh, MFA support
- Reduces auth implementation burden
Cons:
- Auth0/Clerk: $23+/month for 1,000+ users — significant cost at MVP scale
- External dependency — outage on Auth0 = Bilko login down
- Couples auth to third-party vendor; migration is painful
Why not chosen: Cost and external dependency at MVP stage.
4. Consequences
4.1 Positive Consequences
- API is fully stateless — scales horizontally without sticky sessions
organizationIdandrolein JWT payload mean 0 DB queries for authorization checks- Future microservices can verify tokens independently using the public key
4.2 Negative Consequences
- Access token cannot be revoked before 15 min expiry — Mitigation: 15 min window is acceptable; force re-login via refresh token revocation if compromise suspected
4.3 Technical Debt Created
- 2FA TOTP field exists on User model but not yet wired into login flow — Plan: wire 2FA check in auth middleware in v1.1
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:
- Next.js App Router encourages React Server Components (RSC) for data fetching — most data doesn't need global client-side state
- Client Components still need access to auth state (user, org) without prop-drilling
- State management solution must not add heavy bundle to RSC-first architecture
Business forces:
- MVP pace — no time for Redux boilerplate
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:
- 1KB bundle — negligible impact on LCP
- Hooks-based API — consistent with React patterns
- Zero boilerplate —
create((set) => ({ ... }))is all that's needed - No Provider wrapping required (unlike Context API)
- Excellent TypeScript inference
Cons:
- Less opinionated — developers must define store structure themselves
- No built-in devtools (Redux has excellent browser devtools)
Option B: Redux Toolkit (RTK)
Pros:
- Battle-tested at large scale
- Excellent browser devtools (time-travel debugging)
- Built-in RTK Query for server state caching
Cons:
- ~50KB bundle (vs 1KB for Zustand)
- Slice/action/reducer/dispatch boilerplate even with RTK
- RTK Query would duplicate Next.js RSC data fetching capabilities
Why not chosen: Bundle overhead and boilerplate disproportionate to MVP state needs.
Option C: React Context API
Pros:
- Built-in — no dependency
- Familiar to all React developers
Cons:
- Every
useContextconsumer re-renders on any state change — causes performance issues with auth state updates - No atomic selector pattern — must split contexts manually to avoid cascading re-renders
Why not chosen: Performance characteristics unsuitable for auth state shared across many components.
Option D: Jotai / Recoil
Pros:
- Atomic state model — precise subscriptions, no unnecessary re-renders
- Jotai is React-focused, small bundle
Cons:
- Less community adoption than Zustand
- Atomic model adds cognitive overhead for simple use case (one auth store)
Why not chosen: Zustand is simpler and better supported for our use case.
4. Consequences
4.1 Positive Consequences
- Zero boilerplate for auth state management
- No Provider nesting required — clean component tree
- Tiny bundle contribution
4.2 Negative Consequences
- Custom devtools setup required for debugging — Mitigation: Zustand supports Redux DevTools extension via
devtoolsmiddleware
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:
- Framework must compose cleanly with Prisma, Zod validators, Helmet, and custom middleware
- Middleware order matters for security (Helmet must run before routes)
- TypeScript support must be first-class
Business forces:
- Team familiarity reduces ramp-up time
- Must not over-engineer at MVP stage
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:
- Battle-tested (10+ years, billions of installs)
- Largest ecosystem — every library has an Express adapter/example
- Minimal and unopinionated — no forced patterns
- Team familiarity — zero ramp-up time
- Excellent TypeScript support via
@types/express
Cons:
- 2-3x slower throughput than Fastify in benchmarks (not meaningful at MVP scale <500 concurrent users)
- No built-in input validation — requires Zod middleware
- Verbose error handling without an error middleware
Option B: Fastify
Pros:
- 2-3x faster than Express in benchmarks
- Built-in JSON schema validation
- Strong TypeScript support
Cons:
- Plugin system is more complex than Express middleware
- Smaller ecosystem — some libraries require shimming for Fastify
- Less team familiarity — ramp-up cost
Why not chosen: Performance advantage irrelevant at MVP scale; ecosystem gaps outweigh speed benefit.
Option C: NestJS
Pros:
- Full framework with DI, modules, decorators
- Built-in Swagger generation
- Strong conventions reduce decision fatigue
Cons:
- Angular-style architecture with heavy boilerplate (modules, providers, controllers, decorators)
- 10-15x more files than equivalent Express app
- Forces specific patterns that conflict with our functional service layer approach
- Adds significant complexity for a small team
Why not chosen: Overkill for MVP. Complexity outweighs conventions benefit for a small team.
Option D: Hono
Pros:
- Extremely fast, edge-native, very small bundle
- Excellent TypeScript support (router types)
Cons:
- Very new (2023) — limited production track record for financial applications
- Smaller ecosystem — fewer middleware examples
Why not chosen: Unproven at scale for financial SaaS; ecosystem too immature.
4. Consequences
4.1 Positive Consequences
- Predictable middleware execution order — critical for security (Helmet before routes)
- Massive example library for every integration (Prisma, Zod, JWT, multer, etc.)
- Service layer design is framework-agnostic — can migrate to Fastify/Hono in v2 without rewriting services
4.2 Technical Debt Created
- If Bilko scales to 10K+ concurrent connections, benchmarks should be run to evaluate Fastify migration — Plan: evaluate at post-10K scale
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:
- MVP budget: <€50/month for hosting
- No dedicated DevOps resource — infra must be managed with minimal effort
- EU data residency for GDPR compliance (Serbia, BiH, Croatia users)
Technical forces:
- Next.js App Router is optimized for Vercel deployment (edge functions, ISR)
- PostgreSQL must be accessible from the API without VPC configuration complexity
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:
- €21/month — minimal cost for MVP
- Vercel: zero-config Next.js deployment, preview URLs per PR, global CDN, edge network
- Railway: PostgreSQL included, EU Frankfurt region (GDPR), git-push deploys, no Dockerfile needed
- No SSL configuration, no Nginx management, no PM2 setup
Cons:
- Railway has vendor lock-in risk (smaller company than AWS/GCP)
- Performance headroom limited to Railway's plan tiers
Option B: AWS (EC2 + RDS + CloudFront + Route 53)
Pros:
- Unmatched scalability and control
- eu-central-1 (Frankfurt) for GDPR compliance
- RDS Multi-AZ for HA
Cons:
- €80–150/month minimum for equivalent services (EC2 t3.small + RDS t3.micro + CloudFront)
- Requires VPC, security groups, IAM roles, Route 53 — significant DevOps overhead
- No preview deployments out of the box
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:
- Competitive pricing, serverless Cloud Run
- Firebase Hosting for Next.js
Cons:
- Similar ops complexity to AWS
- Less team familiarity
- Firebase Hosting has limited Next.js App Router support
Why not chosen: Team unfamiliarity; less Next.js optimization than Vercel.
Option D: Self-hosted (Hetzner VPS / dedicated)
Pros:
- Cheapest at scale (€6/month for CX21)
- Full control
Cons:
- Full ops responsibility — nginx, SSL certs, PM2, backups, security updates, monitoring
- No preview deployments
- Single point of failure without manual HA setup
Why not chosen: Ops burden unacceptable for a 2-person team at MVP stage.
4. Consequences
4.1 Positive Consequences
- Zero infrastructure management time — team focuses on product
- Preview URLs per PR via Vercel — enables QA without staging environments
- PostgreSQL managed by Railway — automatic backups, connection pooling
4.2 Negative Consequences
- Railway limits: 2GB RAM, shared CPU on Starter — may require upgrade at 500+ concurrent users — Mitigation: upgrade to Railway Pro (€20/mo) at scale
- If Railway service is disrupted, requires migration to AWS or Fly.io — Mitigation: keep Terraform modules ready for AWS deployment (in
infrastructure/terraform/)
4.3 Scaling Path
- MVP (<500 users): Vercel Hobby + Railway Starter (€21/mo)
- Growth (500–2,000 users): Vercel Pro + Railway Pro (€40–50/mo); add Redis for session caching if needed
- Scale (2,000+ users): Migrate API to AWS ECS/Fargate, RDS Multi-AZ; keep Vercel for frontend
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:
- All monetary fields must be
Decimal(mapped toNUMERIC(19,4)) — ORM must support this natively - Migrations must be version-controlled and safe to run via CI/CD (
prisma migrate deploy) - Prisma Client must generate TypeScript types from schema — no manual type maintenance
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:
- Declarative schema —
schema.prismais the single source of truth - Auto-generated TypeScript client with exact types from schema
@db.Decimalmaps directly toNUMERIC(19,4)— no manual conversionprisma.$transaction()for atomic operations (essential for double-entry)- Prisma middleware enables
LoggedActionaudit trail implementation - First-class VS Code extension with IntelliSense
prisma migrate deployis CI/CD-safe (no interactive prompts)
Cons:
- N+1 query risk with nested relations (must use
include: { ... }) - Raw SQL sometimes needed for complex financial aggregation queries
- Prisma's
Decimaltype requires care when passing to@bilko/core(which usesdecimal.js)
Option B: Drizzle ORM
Pros:
- Excellent TypeScript inference (schema defined in TypeScript, not a DSL)
- Faster query performance than Prisma in benchmarks
- Growing ecosystem with strong community
Cons:
- At time of decision (early 2026), Drizzle's migration system was less mature than Prisma Migrate
- Decimal type support required additional configuration
- Fewer examples for financial/accounting use cases
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:
- Very mature — exists since 2016
- Supports both decorator-based and data-mapper patterns
Cons:
- Decorator-based entities require
experimentalDecorators— adds TypeScript config complexity - TypeScript inference is weaker than Prisma — more
anyin practice - Decimal support requires
@Column({ type: 'decimal', precision: 19, scale: 4 })on every monetary column
Why not chosen: Inferior TypeScript DX compared to Prisma; decorator-heavy pattern conflicts with functional service layer.
Option D: Knex.js (query builder)
Pros:
- Lightweight, full SQL control
- Works with any DB driver
Cons:
- Not an ORM — no type generation from schema
- Requires manually maintaining TypeScript interfaces for every table
- No migration tooling comparable to Prisma Migrate
Why not chosen: No type generation means maintaining types manually — unacceptable for a 15-model schema.
4. Consequences
4.1 Positive Consequences
- Schema changes are captured in versioned migration files (
packages/database/prisma/migrations/) npx prisma generateregenerates the client after any schema changeprisma.$transaction()makes double-entry atomic — both GL entries commit or neither does
4.2 Negative Consequences
- Complex aggregation queries (VAT report, trial balance) require raw SQL via
prisma.$queryRaw— Mitigation: isolate raw queries inReportService, document each query's purpose
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:
- TypeScript types must match runtime validation — manual type + validator duplication causes drift bugs
- Financial fields (amounts, dates, currency codes) require strict validation patterns
- Validation schemas are used as the source of truth for request body types in route handlers
Security forces:
- All user input must be validated before any DB query or business logic (OWASP A03 Injection prevention)
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:
- TypeScript-first —
z.infer<typeof schema>generates the type automatically - Single source of truth — schema IS the type
- Composable —
z.object().merge(),z.discriminatedUnion(), partial schemas - Tree-shakeable — only imports what's used
.parse()throwsZodErrorwith field-level messages — maps directly to422responses- Excellent ecosystem integration (tRPC, react-hook-form, etc. — useful in v2)
Cons:
- Slightly verbose for complex unions
- Bundle size (~13KB) — acceptable for server-side
Option B: Yup
Pros:
- Mature — exists since 2016
- Async validation support
Cons:
- TypeScript support is an afterthought — inference is weaker than Zod
- Schema and type must be maintained separately in practice
- More verbose
.shape()API
Why not chosen: Inferior TypeScript inference — requires manual type maintenance.
Option C: Joi
Pros:
- Very mature — Hapi ecosystem
- Rich validation methods
Cons:
- JavaScript library — TypeScript types are maintained separately (
@hapi/joitypes have gaps) - No built-in TypeScript inference — requires manual
interfacedefinitions - Verbose for TypeScript projects
Why not chosen: Not TypeScript-native; dual maintenance of types and schemas.
Option D: class-validator + class-transformer
Pros:
- Decorator-based — familiar to NestJS/Java developers
- Works well with class instances
Cons:
- Requires
experimentalDecoratorsTypeScript config - Only works with class instances — requires
plainToClass()transformation before validation - Incompatible with plain object service layer design
- Tightly coupled to NestJS patterns we are not using
Why not chosen: Requires class instance pattern — incompatible with functional service layer.
4. Consequences
4.1 Positive Consequences
- Zero type drift between validation schema and TypeScript types
422responses automatically include field-level error messages from Zod- Shared sub-schemas enforce consistent validation across endpoints (e.g., all UUID fields validated the same way)
4.2 Negative Consequences
- Zod errors must be mapped to Bilko's standard error format in
errorHandlermiddleware — Mitigation: already implemented inapps/api/src/middleware/error-handler.ts
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:
- Dashboard, invoice list, and report pages are data-heavy — RSC reduces client-side JS bundle by fetching data server-side
- Bilko has multiple layout levels: root layout (sidebar + topbar), auth layout, public layout
- Next.js 15 App Router enables
layout.tsxnesting — perfect for Bilko's multi-level navigation
Business forces:
- App Router is Next.js's strategic direction — Pages Router will be maintained but not enhanced
- Vercel's deployment optimizations (ISR, edge functions) are App Router-first
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:
- React Server Components for data-fetch pages (invoice list, reports, dashboard)
- Client Components (
"use client") for interactive UI (invoice wizard, date pickers, forms) - Nested layouts (
layout.tsx) for shared navigation:RootLayout → AppLayout (sidebar) → PageLayout - Server Actions for form submissions (planned for v1.1 — v1.0 uses API calls from Client Components)
3. Alternatives Considered
Option A: Next.js App Router ← Selected
Pros:
- RSC reduces client JS bundle — invoice list page ships no JS for the list rendering itself
- Nested layouts eliminate per-page layout boilerplate
loading.tsx/error.tsxconventions for granular loading states- Vercel deployment optimized for App Router (edge streaming, ISR)
- Future-proof — Pages Router receives no new features
Cons:
- App Router mental model is newer — some patterns (client/server component boundary) require learning
- Some third-party libraries not yet compatible with RSC (
"use client"workarounds needed) - Debugging is more complex (server vs. client stack traces differ)
Option B: Next.js Pages Router
Pros:
- Stable, well-documented, massive example library
- No client/server component boundary to reason about
Cons:
- No React Server Components — all data fetching in
getServerSidePropsor client-side - Layout patterns are more manual (no
layout.tsxnesting) - Will not receive new features — effectively legacy
Why not chosen: Legacy approach; loses RSC performance benefits; will require migration later anyway.
Option C: Remix
Pros:
- Excellent loader/action model for data fetching and mutations
- Very good TypeScript support
- No client/server boundary confusion (different mental model)
Cons:
- Cannot deploy on Vercel without adapter configuration
- Learning curve for team familiar with Next.js
- Smaller ecosystem than Next.js
- Less integrated with React Server Components ecosystem
Why not chosen: Team familiarity with Next.js; Vercel deployment optimization; RSC is a better long-term bet.
Option D: SvelteKit
Pros:
- Excellent performance (minimal JS, true reactivity without VDOM)
- Simpler mental model than React
- Good TypeScript support
Cons:
- Different language (Svelte), not TypeScript + JSX — requires team retraining
- Smaller ecosystem — fewer accounting/UI component libraries
- Cannot share React types with backend TypeScript code
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
- Invoice list, reports, and dashboard pages are server-rendered — faster TTFB and LCP
- No prop-drilling for layout —
layout.tsxprovides shared sidebar/topbar automatically - RSC reduces Time to Interactive — less JavaScript shipped to browser
4.2 Negative Consequences
- Client/server boundary requires care — passing non-serializable props to Client Components causes runtime errors — Mitigation: ESLint rule
react-compilerwarns on component boundary violations
4.3 Technical Debt Created
- Current v1.0 uses mock data from
lib/mock-data.tsin Client Components — these must be replaced with RSC data fetching when the backend API is built — Plan: replace all mock data with RSC fetches in v1.1 (backend integration milestone)
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
- Executive Summary
- Competitor Profiles
- Feature Comparison Matrix
- Pricing Comparison Matrix
- API Pattern Analysis
- Security Comparison
- Balkan Market Gap Analysis
- 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:
- 🟢 Croatia HR-FISK 2.0 is LIVE (January 1, 2026) — mandatory B2B e-invoicing for all VAT-registered businesses. This is an urgent compliance window.
- 🟡 Serbia SEF B2B delay — B2B mandate postponed to January 2028. Voluntary SEF use opens March 30, 2026. G2G/B2G already mandatory.
- 🟡 BiH e-invoicing advancing — FBiH Fiscalization Bill approved by House of Peoples (Dec 2025). Mandatory proposed from Jan 2026, full transition by Jan 2027.
Key opportunities from this research:
- Global players (Zoho Books, Wave, FreshBooks) have zero Balkan compliance features — high switching cost for current users
- Fiken's success with a simple flat pricing model at ~€18/month proves SMBs pay for simplicity + compliance
- FreeAgent's bank integration model (free with partner bank) is a growth hack applicable to Balkan banking partners
- No competitor offers native SEF + HR-FISK + UIO (BiH) in a single product
- Wave's freemium to paid conversion playbook is directly applicable (free invoicing → paid for bank feeds + payroll)
- 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
- Type: REST API v2
- Auth: OAuth 2.0 (developer applications) + API key (simple integrations)
- Documentation:
api.fiken.no/api/v2/docs/ - Key capabilities: Find/create invoices, manage contacts, submit to Altinn
- Integrations: WooCommerce, PrestaShop, OpenCart, 100+ via partner ecosystem
- Webhook support: Yes (invoice events, payment events)
- Note: API access is now a paid add-on (NOK 99/month)
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
- ✅ Norway: Full VAT (Altinn), EHF e-invoicing, payroll A-melding
- ✅ GDPR: Basic compliance (Visma group)
- ❌ No Balkan market support
Strengths vs. Bilko
- 10+ years of product refinement
- Altinn integration is seamless — set-and-forget VAT
- Accountant partner network as distribution channel
- Simple, clean UI — no accounting jargon
- Modular add-on model gives flexibility
Weaknesses (Bilko Opportunities)
- Norway-only: no Balkan compliance, no multi-country support
- API now behind a paywall (add-on) — limits developer ecosystem
- Acquired by Visma — may lose startup agility
- No mobile app (PWA only)
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
- Type: REST API v2
- Auth: OAuth 2.0 (3-legged, authorization code flow)
- Documentation:
dev.freeagent.com/docs/ - Key capabilities: Invoices, contacts, expenses, bank transactions, timeslips, projects
- Rate limits: Not publicly specified
- SDKs: Community PHP client, Node.js client
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
- ✅ UK: Full HMRC integration (MTD VAT, Self Assessment, Corporation Tax, RTI payroll)
- ✅ GDPR compliant
- ❌ No EU VAT support beyond UK
- ❌ No Balkan market support
Strengths vs. Bilko
- Bank partnership model — free software via banking relationship drives massive acquisition
- Deep HMRC integration is unmatched
- RTI payroll built-in
- Strong accountant partner ecosystem
Weaknesses (Bilko Opportunities)
- UK-only compliance — no expansion to Balkan markets
- Acquired by NatWest — innovation constrained
- Pricing opaque (is it "free" or not?)
- No multi-currency support for international businesses
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
- Type: REST API
- Auth: API key (per-organization)
- Documentation:
developers.holded.com - Key capabilities: Invoices, contacts, products, accounting entries, warehouses, employees
- Integrations: 700+ native integrations (Zapier, WooCommerce, Shopify, Salesforce)
- Webhook support: Yes
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
- ✅ Spain: Full IVA (VAT), Verifactu e-invoicing, Spanish CoA, SII reporting
- ✅ Latin America: Mexico, Colombia, Chile, Argentina editions
- ✅ GDPR compliant
- ❌ No Balkan market support
- ❌ No SEF, HR-FISK, or BiH UIO compliance
Strengths vs. Bilko
- ERP breadth — one platform for all business operations
- 700+ integrations ecosystem
- AEAT Verifactu certification — model for Bilko's SEF/HR-FISK certifications
- Strong in Spanish-speaking markets = template for Balkan language expansion
Weaknesses (Bilko Opportunities)
- Complexity — overkill for micro SMBs
- Per-user pricing gets expensive for teams
- Spanish-language focused UI — no Balkan language support
- No Balkan regulatory compliance
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
- Type: GraphQL API (public)
- Auth: OAuth 2.0 (authorization code flow); Pro subscription required for third-party OAuth
- Endpoint:
https://gql.waveapps.com/graphql/public - Documentation:
developer.waveapps.com - Key capabilities: Businesses, invoices, customers, products, accounting transactions
- Rate limits: Yes (429 on excess)
- Webhooks: Supported (invoice events, payment events)
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
- ✅ US: Sales tax support, 1099 forms
- ✅ Canada: GST/HST/PST
- ⚠️ GDPR: Limited — data hosted in US, privacy shield concerns
- ❌ No EU VAT reporting
- ❌ No Balkan market support (no SEF, HR-FISK, UIO)
- ❌ Payroll US/Canada only
Strengths vs. Bilko
- Free plan as user acquisition — massive funnel (4M+ users)
- GraphQL API is developer-friendly
- Simple, clean UX — minimal learning curve
- H&R Block backing provides financial services expansion
Weaknesses (Bilko Opportunities)
- US/Canada-centric — no EU VAT, no Balkan compliance
- Degrading free tier (CSV export removed, OAuth now Pro-only) — trust erosion
- No multi-currency support at scale
- Data residency in US = GDPR risk for EU users
- Payroll only for North America
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
- Type: REST API v3
- Auth: OAuth 2.0 (authorization code flow); supports server-based apps and self-client
- Documentation:
zoho.com/books/api/v3/ - Key capabilities: Full CRUD for all entities; webhooks; bulk operations
- Rate limits: Per-organization API call limits (varies by plan)
- Developer console: Built-in API Usage dashboard
- SDKs: Official SDKs for Python, Java, Node.js, PHP, Ruby
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
- ✅ 16+ country editions: India (GST), UK (MTD VAT), UAE, Saudi Arabia, Australia, US, Canada, Singapore, South Africa, Kenya, Bahrain, Oman, Qatar
- ✅ GDPR compliant with EU data residency option
- ✅ Multi-currency, 170+ currencies
- ⚠️ Serbia/BiH/Croatia: Global edition available but NO native SEF, HR-FISK, or BiH UIO compliance — manual VAT reporting only
- ❌ No Balkan-specific CoA templates
- ❌ No SEF e-invoicing integration
- ❌ No HR-FISK FINA certificate flow
Strengths vs. Bilko
- Most comprehensive global coverage (16 country editions)
- Strong API with official SDKs
- SOC 2 Type II + ISO 27001 certified
- Free tier for very small businesses
- Zoho ecosystem (CRM, HR, Projects) — powerful if customer uses Zoho suite
Weaknesses (Bilko Opportunities)
- No Balkan compliance whatsoever (Serbia, BiH, Croatia treated as "global" with manual VAT)
- Complex for small businesses — many features they never use
- Per-org pricing gets expensive at higher tiers
- UI dated compared to Fiken/FreshBooks
- Indian-first product — Balkan UX and language not considered
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
- Type: REST API (JSON)
- Auth: OAuth 2.0 (authorization code flow)
- Documentation:
freshbooks.com/api - Key capabilities: Clients, invoices, expenses, time entries, projects, estimates, payments
- Rate limits: Per-application limits (not publicly specified)
- Webhooks: Supported (invoice created/updated/sent/paid, expense created, payment received)
- Integrations: 100+ native integrations (Stripe, Slack, HubSpot, Shopify)
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
- ✅ Canada/US: GST/HST, 1099 reporting
- ✅ UK: VAT (manual export for HMRC)
- ✅ GDPR: Basic compliance
- ⚠️ EU VAT: Manual reporting only — no native filing
- ❌ No Balkan compliance (no SEF, HR-FISK, UIO)
- ❌ No Balkan-language support
Strengths vs. Bilko
- SOC 2 Type 1 — trust signal for enterprise/SMB procurement
- Best invoicing UX in class — highly rated by non-accountants
- Strong client portal — professional experience for end-customers
- Phone support is rare in SaaS accounting — differentiator for SMBs
Weaknesses (Bilko Opportunities)
- No Balkan compliance
- Client-count pricing (Lite: 5 clients) is restrictive
- Time tracking focus makes it less suitable for pure accounting
- No native payroll for most markets
- 2FA not enforced — security gap
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
- ❌ No accounting compliance (not a double-entry accounting system)
- ❌ No VAT filing
- ❌ No e-invoicing compliance
- ❌ No regulatory reporting
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:
- Slovenia: minimax.si
- Croatia: minimax.hr
- Serbia: minimax.rs (via partner network)
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
- Only regional SaaS competitor with active presence in both Croatia and Serbia
- Fiskalizacija 2.0 already implemented — ahead of Bilko on Croatia compliance
- Local language support (Slovenian, Croatian, Serbian)
- Mobile app available (iOS + Android)
- Part of Seyfor — enterprise backing and resources
- 30-day free trial (same as Bilko target)
- EDI e-invoice network via BizBox partnership
Weaknesses (Bilko Opportunities)
- No BiH coverage — Bilko can own the BiH market
- Pricing opaque — no self-serve sign-up, must contact sales → friction
- Originated in Slovenia — Croatian/Serbian UI may feel "Slovenian-first"
- No modern open banking integration for Balkans
- No modern API ecosystem (developer-facing)
- Seyfor acquisition (2024) may introduce corporate sluggishness
- Limited in-language support for Serbian/Bosnian users
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
- Long market presence in Balkans (SL/HR/RS)
- Full ERP scope — suitable for manufacturing, distribution
- Accounting firm channel well-established
Weaknesses (Bilko Opportunities)
- Not SaaS — license + implementation cost is a high barrier for SMBs
- Outdated UX — no modern web UI
- Complex to set up and maintain
- No mobile app
- High total cost of ownership
- Not suitable for self-serve SMB onboarding
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
- ✅ Serbia: VAT (PDV), eTax integration, Serbian CoA, payroll
- ✅ Slovenia: Local compliance (separate platform)
- ❌ Croatia: Not documented (separate Shopify app only)
- ❌ BiH: Not supported
- ⚠️ SEF e-invoicing: Listed as compliant with Serbian legislation; SEF status unverified
Strengths vs. Bilko
- Free BASIC plan — strong acquisition for micro-businesses in Serbia
- Cheapest paid plan in market: €15/month for full accounting
- Native Serbian compliance (VAT, eTax, payroll)
- Serbian-language interface
- Long track record in Serbia
Weaknesses (Bilko Opportunities)
- Legacy UX — outdated web interface, poor mobile experience
- No mobile app
- Serbia-only (no Croatia, no BiH)
- No modern API or developer platform
- No open banking integration
- Phone support costs extra (€25–30/month) — unfriendly for SMBs
- Free plan is single-user only — limits small team use
- SEF e-invoicing status unclear (not prominently advertised)
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
- Sweet spot: €15–25/month flat per-organization (not per-user) — maximizes SMB adoption
- No per-user pricing — Holded's model penalizes growth; Fiken's flat model is loved by SMBs
- Free trial is table stakes — all global competitors offer it
- e-racuni.com has a free BASIC plan — Bilko should consider a free or deeply discounted starter to compete in Serbia
- Minimax pricing opacity is a competitive weakness — Bilko's transparent self-serve pricing is a differentiator
- Compliance included in base price — no add-on fees for SEF or HR-FISK integration. This is a key differentiator.
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
-
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.
-
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.
-
Webhooks are expected — invoice sent, payment received, expense approved are minimum webhook events. Important for accountant tools and ERP integrations.
-
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).
-
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.
-
Zoho's versioning model — v3 API with stable versioning and developer console is the gold standard. Bilko should version from day one (
/api/v1/). -
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
-
2FA is optional everywhere — no competitor enforces 2FA. Bilko can differentiate by requiring 2FA for org owners (compliance-first positioning).
-
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.
-
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.
-
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.
-
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:
- Entry tier: €15–18/month per organization (Starter — invoicing + expenses + basic reports)
- Growth tier: €25–30/month per organization (+ SEF/HR-FISK integration, advanced reports, bank feeds)
- Pro tier: €45–55/month per organization (+ multi-entity, API access, priority support)
- Free trial: 30 days, no credit card required (industry standard)
- Consider free BASIC tier for Serbia specifically to compete with e-racuni.com's free plan
- NOT freemium by default: Compliance-heavy product requires onboarding support; free plan creates support burden without strong conversion path
8.4 Feature Priority (Based on Competitive Gaps)
Build first (MVP — what all global competitors lack):
- SEF integration (Serbia B2B e-invoicing — voluntary from Mar 2026, mandatory 2028)
- HR-FISK 2.0 integration (Croatia — ALREADY MANDATORY since Jan 2026 — urgent!)
- VAT calculations per country (RS 20%/10%, BA 17%, HR 25%/13%/5%)
- Country-specific Chart of Accounts (RS, BA FBiH/RS, HR)
- 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
- Fiken Pricing — fiken.no/priser (fetched Feb 2026 — single plan NOK 209/month confirmed)
- Fiken Reviews Jan 2026 — SoftwareWorld
- Fiken API Documentation
- FreeAgent Pricing — freeagent.com
- FreeAgent NatWest Partnership — natwest.com
- FreeAgent Developer Docs — OAuth
- Holded Pricing — holded.com
- Holded 2026 — GetApp
- Wave Pricing — waveapps.com
- Wave Accounting Review 2026 — NerdWallet
- Zoho Books Pricing — zoho.com/us/books/pricing
- Zoho Books 2026 — Capterra
- FreshBooks Pricing — freshbooks.com
- FreshBooks Pricing 2026 — SaaSWorthy
- Pancake App
Regional Competitors
- Minimax.hr — Croatia
- Minimax Fiskalizacija 2.0 — Help
- Minimax Accounting — Google Play
- Minimax Accounting — App Store
- SAOP / Seyfor — Bizmatch
- Seyfor acquires MIT informatika
- Pantheon ERP — datalab.eu
- Pantheon License Prices
- Pantheon Pricing 2026 — G2
- e-racuni.com Serbia pricing (fetched Feb 2026 — BASIC free, ADVANCED €15, PREMIUM €35)
- e-racuni ADVANCED package
- e-racuni PREMIUM package
Regulatory Sources
- Croatia HR-FISK 2.0 — Mandatory B2B Jan 2026 — EDICOM
- Croatia Fiskalizacija 2.0 — Marosa VAT
- Croatia E-Invoicing VATupdate Jan 2026
- Serbia SEF B2B delay to 2028 — Fiscal Solutions
- Serbia VAT Law Amendments 2026 — VATupdate
- Serbia SEF Status — Comarch
- BiH E-Invoicing Law Advancing — VATupdate Nov 2025
- BiH E-Invoicing — VATupdate Dec 2025
- Serbia SEF E-Invoicing — DDDInvoices
- BiH E-Invoicing — DDDInvoices
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
Quick Links
- Project Handbook
- Pipeline
- High-Level Design
- Low-Level Design
- Validation Report
- Open Banking Strategy
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
- PR: https://github.com/johnatbasicas/bilko/pull/267
- Merge commit:
3f56ab5198fd37b43cbbf8a917d7ce4236d42104 - Stage Cloud Build:
7b413a82-256a-4cfc-8917-b0b06376d850= SUCCESS - Stage API image promoted to demo:
europe-north1-docker.pkg.dev/tribal-sign-487920-k0/bilko/api:stage-3f56ab5@sha256:5ca9a347c56375757fd563980034307f2d1d87327d1958b4fbd593c4b34741c6 - Demo revision:
bilko-api-demo-mc103057-3f56ab5 - Demo traffic: 100% to
bilko-api-demo-mc103057-3f56ab5
Changes
- Ktor Netty groups configured:
connectionGroupSize=2,workerGroupSize=4,callGroupSize=32. - Demo Cloud Run deploy config adjusted to
concurrency=1,min-instances=1,max-instances=5,cpu-throttling=false.
Validation evidence
- Pre-deploy recurrence evidence:
/tmp/alai/d7bced9a/evidence-bilko-demo-flaky/health-probe-resume-20260606.json(10/20 pass, 10/20 abort/timeouts). - P2P pre-verifier PASS:
mesh-thr-969f5997-357c-49a1-8f51-00de356f781a; evidence/tmp/alai/company-mesh-auto-responder/2026-06-06T17-38-47-783Z-mesh-msg-65457e77-674c-4e86-854f-0a9165a7c829.json. - Local validation:
cd /tmp/bilko-wt-hang/apps/api && gradle test --tests no.alai.bilko.auth.JwtServiceTest=> BUILD SUCCESSFUL. - GitHub Actions CI: run
27069304656=> SUCCESS. - Demo no-traffic smoke:
/tmp/alai/d7bced9a/evidence-bilko-demo-flaky/health-probe-smoke-3f56ab5-20260606T195531Z.json=> 20/20 pass. - Post-promote custom-domain probe:
/tmp/alai/d7bced9a/evidence-bilko-demo-flaky/health-probe-post-promote-custom-20260606T195559Z.json=> 40/40 pass. - Sustained watcher:
/tmp/alai/d7bced9a/evidence-bilko-demo-flaky/sustained-health-watch-3f56ab5-20260606T195637Z.json=> 149/150 pass over ~76 minutes. One client-sideAbortErrorat 2026-06-06T21:00:27Z; Cloud Run logs for the same revision/window showed no non-200 and no >1s latency entries. - Cloud Run log check:
/tmp/alai/d7bced9a/evidence-bilko-demo-flaky/cloudrun-logs-sustained-window-3f56ab5-20260606T211402Z.json=> HTTP status counts 98 x 200, slow_or_non200=0. - Final custom-domain probe after recurrence window:
/tmp/alai/d7bced9a/evidence-bilko-demo-flaky/health-probe-final-custom-3f56ab5-20260606T211421Z.json=> 30/30 pass. - Final direct Cloud Run probe after recurrence window:
/tmp/alai/d7bced9a/evidence-bilko-demo-flaky/health-probe-final-direct-3f56ab5-20260606T211430Z.json=> 30/30 pass. - Final Cloud Run log check:
/tmp/alai/d7bced9a/evidence-bilko-demo-flaky/cloudrun-logs-final-window-3f56ab5-20260606T211451Z.json=> HTTP status counts 98 x 200, slow_or_non200=0.
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.