Architecture

High-Level Design (HLD)

Bilko — High-Level Design (HLD)

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


Table of Contents

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

1. System Overview

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

Key design goals:

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


2. Monorepo Structure

The project uses Turborepo for monorepo management.

Bilko/
├── apps/
│   ├── web/                   # Next.js 15 frontend (App Router)
│   └── api/                   # Express + TypeScript backend
├── packages/
│   ├── database/              # Prisma schema + Prisma Client (@bilko/database)
│   ├── core/                  # Accounting engine (@bilko/core)
│   ├── country-rs/            # Serbia plugin (@bilko/country-rs)
│   ├── country-ba/            # Bosnia & Herzegovina plugin (@bilko/country-ba)
│   ├── country-hr/            # Croatia plugin (@bilko/country-hr)
│   └── ui/                    # Shared UI scaffold (empty, placeholder)
├── infrastructure/
│   ├── terraform/             # AWS infrastructure as code
│   ├── docker/                # Dockerfiles and docker-compose
│   ├── nginx/                 # Nginx reverse proxy config
│   ├── pm2/                   # PM2 process manager config
│   └── 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/15min 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
Infrastructure AWS (EC2, RDS, S3, CloudFront) Reliable, eu-central-1 region close to Balkan users
IaC Terraform Declarative infrastructure, reproducible environments

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 CRUD on invoices, expenses, transactions, reports
viewer Read-only access to all data

7. Authentication Architecture

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

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

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

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

Token storage:

Security:


8. Multi-Currency Architecture

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

Key fields on monetary entities:

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

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

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

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


9. Country Plugin System

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

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

Country-specific data:

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

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


10. Infrastructure Overview

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

    subgraph CDN["CloudFront"]
        CF["CloudFront Distribution\n(bilko.io → S3/Next.js)"]
    end

    subgraph Compute["EC2 (eu-central-1)"]
        NG["Nginx (reverse proxy)"]
        PM2["PM2 (process manager)"]
        API["Express API\n(Node.js)"]
        WEB["Next.js Frontend\n(standalone build)"]
    end

    subgraph Data["Data Layer"]
        RDS["RDS PostgreSQL 15\n(Multi-AZ)"]
        S3["S3 (backups, assets)"]
        R2["Cloudflare R2\n(PDFs, receipts)"]
    end

    subgraph Monitor["Monitoring"]
        CW["CloudWatch\n(logs, alarms)"]
    end

    D1 --> CF --> NG
    D2 --> NG
    NG --> PM2
    PM2 --> API
    PM2 --> WEB
    API --> RDS
    API --> R2
    API --> CW

Key infrastructure decisions:


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/15min per IP (general); stricter limit on /auth/login and /auth/register
Authentication JWT access token (15min) + refresh token (7d, httpOnly cookie)
Authorization RBAC checked per endpoint; organizationScope middleware enforces tenancy
Password storage bcrypt, 12 salt rounds
Audit trail LoggedAction table — append-only, captures all INSERT/UPDATE/DELETE with user, timestamp, old/new values
Money precision NUMERIC(19,4) everywhere; Decimal.js in business logic
Transaction immutability Transaction.locked = true makes records unmodifiable
SQL injection Prisma parameterized queries — no raw SQL in business logic
Secret management Environment variables; never committed to repository

Low-Level Design (LLD)

Bilko — Low-Level Design (LLD)

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


Table of Contents

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

1. API Endpoint Specifications

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

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

1.1 Health

GET /api/v1/health

No auth required.

Response 200:

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

1.2 Authentication (/auth)

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

POST /api/v1/auth/register

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

Request body:

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

Response 201:

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

Errors: 409 DUPLICATE (email exists), 400 VALIDATION_ERROR


POST /api/v1/auth/login

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

Request body:

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

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

Errors: 401 UNAUTHORIZED (invalid credentials)


POST /api/v1/auth/refresh

Uses refreshToken cookie. Issues new access token.

Response 200:

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

Errors: 401 NO_TOKEN, 401 TOKEN_EXPIRED, 401 INVALID_TOKEN


POST /api/v1/auth/logout

Clears refreshToken cookie.

Response 204: No content.


GET /api/v1/auth/me

Requires Authorization: Bearer <accessToken>.

Response 200:

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

1.3 Invoices (/invoices)

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

GET /api/v1/invoices

List invoices with pagination and filtering.

Query params:

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

Response 200:

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

GET /api/v1/invoices/:id

Get single invoice with all line items.

Response 200:

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

Errors: 404 NOT_FOUND


POST /api/v1/invoices

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

Request body:

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

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

Errors: 404 NOT_FOUND (customer), 400 VALIDATION_ERROR


PUT /api/v1/invoices/:id

Update invoice. Only allowed when status = draft.

Request body (partial):

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

Errors: 404 NOT_FOUND, 400 BAD_REQUEST (not draft)


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

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

Request body:

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

Actions and effects:

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

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


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

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


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

Send invoice email to customer.

Request body:

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

Response 200:

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

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


DELETE /api/v1/invoices/:id

Delete invoice. Only allowed when status = draft.

Response 204: No content.

Errors: 404 NOT_FOUND, 400 BAD_REQUEST (not draft)


1.4 Expenses (/expenses)

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

GET /api/v1/expenses

List with pagination.

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


GET /api/v1/expenses/:id

Response 200:

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

POST /api/v1/expenses

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

Request body:

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

PUT /api/v1/expenses/:id

Update expense. Only pending status.


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

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

Response 200: Updated expense with status: approved


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

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

Response 200: Updated expense with status: paid


DELETE /api/v1/expenses/:id

Delete expense. Only pending status.


1.5 Contacts (/contacts)

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

GET /api/v1/contacts

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

GET /api/v1/contacts/:id

POST /api/v1/contacts

Request body:

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

PUT /api/v1/contacts/:id

DELETE /api/v1/contacts/:id

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


1.6 Accounts (Chart of Accounts) (/accounts)

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

GET /api/v1/accounts

Query params: typeId, isActive, includeBalances (boolean)

Response 200:

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

POST /api/v1/accounts

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

PUT /api/v1/accounts/:id


1.7 Transactions (General Ledger) (/transactions)

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

GET /api/v1/transactions

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

Response 200:

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

GET /api/v1/transactions/:id

Full transaction detail including account type information.

POST /api/v1/transactions

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

Request body:

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

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


1.8 Reports (/reports)

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

GET /api/v1/reports/dashboard

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

GET /api/v1/reports/profit-loss

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

Response 200:

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

GET /api/v1/reports/balance-sheet

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

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

GET /api/v1/reports/cash-flow

Query params: from, to

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

GET /api/v1/reports/vat

Query params: from, to

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

GET /api/v1/reports/trial-balance

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

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

GET /api/v1/reports/general-ledger

Query params: accountId (optional), from, to

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


1.9 Banking (/bank-accounts)

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

GET /api/v1/bank-accounts

List all bank accounts with balances.

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

Single bank account with recent transactions.

POST /api/v1/bank-accounts

Request body:

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

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

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

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

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

Response:

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

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

Request body:

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

1.10 Settings

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

GET /api/v1/organization

PUT /api/v1/organization

Requires owner or admin role.

Request body:

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

GET /api/v1/users

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

POST /api/v1/users/invite

Request body:

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

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

Requires owner role only.

Request body:

{ "role": "admin" }

DELETE /api/v1/users/:id

Requires owner role. Cannot delete self.

GET /api/v1/currencies

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

GET /api/v1/exchange-rates

Query params: baseCurrency, targetCurrency, date

GET /api/v1/settings/tax-rates

Get org-level tax rate overrides.

PUT /api/v1/settings/tax-rates

Requires owner or admin.


2. Database Schema Documentation

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

2.1 Entity Relationship Diagram

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

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

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

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

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

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

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

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

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

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

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

    Currency {
        CHAR(3) code PK
        VARCHAR(100) name
        VARCHAR(10) symbol
        SMALLINT decimalPlaces "default: 2"
        BOOLEAN isActive
        TIMESTAMP createdAt
    }

    ExchangeRate {
        UUID id PK
        CHAR(3) baseCurrency FK
        CHAR(3) targetCurrency FK
        DECIMAL(12_6) rate
        DATE effectiveDate
        VARCHAR(50) source
        TIMESTAMP lastUpdated
    }

    LoggedAction {
        BIGINT eventId PK "autoincrement"
        TEXT schemaName
        TEXT tableName
        UUID userId FK "nullable"
        TIMESTAMP actionTimestamp
        ENUM action "INSERT|UPDATE|DELETE"
        JSONB rowData "full row snapshot"
        JSONB changedFields "diff for UPDATE"
        TEXT queryText
        INET clientIp
        TEXT applicationName "default: fiken-clone-api"
    }

    SchemaVersion {
        VARCHAR(20) version PK
        TIMESTAMP appliedAt
        TEXT description
    }

    Organization ||--o{ User : has
    Organization ||--o{ Account : owns
    Organization ||--o{ Contact : owns
    Organization ||--o{ Invoice : owns
    Organization ||--o{ Expense : owns
    Organization ||--o{ Transaction : owns
    Organization ||--o{ BankAccount : owns
    AccountType ||--o{ Account : classifies
    Account ||--o{ Account : "parent-child"
    Contact ||--o{ Invoice : "billed to"
    Contact ||--o{ Expense : "billed from"
    Invoice ||--o{ InvoiceItem : contains
    Account ||--o{ InvoiceItem : "revenue account"
    Account ||--o{ Expense : "expense account"
    Account ||--o{ BankAccount : links
    Account ||--o{ Transaction : "debit side"
    Account ||--o{ Transaction : "credit side"
    BankAccount ||--o{ BankTransaction : holds
    Currency ||--o{ ExchangeRate : "base"
    Currency ||--o{ ExchangeRate : "target"
    User ||--o{ LoggedAction : audits

2.2 Key Indexes

Table Index Columns Purpose
users idx_users_organization organizationId User lookup by org
users idx_users_email email Login lookup
accounts idx_accounts_organization organizationId List accounts by org
accounts Unique organizationId, code Prevent duplicate account codes
invoices idx_invoices_organization organizationId List invoices by org
invoices idx_invoices_status status Filter by status
invoices idx_invoices_due_date dueDate Overdue detection
invoices idx_invoices_org_status_date organizationId, status, invoiceDate Complex report queries
transactions idx_transactions_org_date organizationId, transactionDate Date range queries
transactions idx_transactions_reference referenceType, referenceId Find transactions for an invoice/expense
exchange_rates idx_exchange_rates_pair baseCurrency, targetCurrency Currency pair lookup
exchange_rates Unique baseCurrency, targetCurrency, effectiveDate One rate per pair per day
logged_actions idx_logged_actions_timestamp actionTimestamp Audit log queries by time

3. Service Layer Design

All services follow the same pattern:

3.1 InvoiceService

File: apps/api/src/services/invoice.service.ts

Method Description
listInvoices(orgId, params) Paginated list with filters
getInvoice(orgId, id) Single invoice with items
createInvoice(orgId, userId, data) Create draft, calculate amounts, lock exchange rate
updateInvoice(orgId, id, data) Update draft only, recalculate if items changed
changeInvoiceStatus(orgId, id, data) Dispatches to sendInvoice(), markInvoicePaid(), cancelInvoice()
deleteInvoice(orgId, id) Delete draft only
generateInvoiceNumber(orgId) INV-YYYY-NNN sequential
getExchangeRate(from, to, date) DB lookup, falls back to 1.0 with warning
sendInvoice(invoice) Prisma tx: create DR Receivable/CR Revenue + update status
markInvoicePaid(invoice, paidAt) Prisma tx: create DR Bank/CR Receivable + update status

3.2 ExpenseService

File: apps/api/src/services/expense.service.ts

Method Description
listExpenses(orgId, params) Paginated list with filters
getExpense(orgId, id) Single expense
createExpense(orgId, userId, data) Create pending, lock exchange rate
updateExpense(orgId, id, data) Update pending only
approveExpense(orgId, id, userId) Prisma tx: create DR Expense/CR Payable + update status
payExpense(orgId, id) Prisma tx: create DR Payable/CR Bank + update status
deleteExpense(orgId, id) Delete pending only

3.3 ContactService

File: apps/api/src/services/contact.service.ts

Method Description
listContacts(orgId, params) Paginated list with type filter
getContact(orgId, id) Single contact
createContact(orgId, data) Create contact
updateContact(orgId, id, data) Update contact
deleteContact(orgId, id) Soft delete (isActive = false)

3.4 AccountService

File: apps/api/src/services/account.service.ts

Method Description
listAccounts(orgId, params) List with optional balance calculation
createAccount(orgId, data) Create account (checks code uniqueness)
updateAccount(orgId, id, data) Update account metadata

3.5 ReportService

File: apps/api/src/services/report.service.ts

Method Description
getDashboard(orgId) Aggregate MTD metrics
getProfitLoss(orgId, query) Revenue vs expense by account, net profit
getBalanceSheet(orgId, query) Assets, liabilities, equity as of date
getCashFlow(orgId, query) Operating/investing/financing cash flows
getVATReport(orgId, query) Output VAT (invoices) vs input VAT (expenses), net
getTrialBalance(orgId, query) All accounts with debit/credit totals, balanced check
getGeneralLedger(orgId, query) Per-account transaction history

3.6 BankingService

File: apps/api/src/services/banking.service.ts

Method Description
listBankAccounts(orgId) List all active bank accounts
getBankAccount(orgId, id) Single account with recent transactions
createBankAccount(orgId, data) Create bank account linked to GL account
listBankTransactions(orgId, bankAccountId, params) Paginated bank transactions
importBankStatement(orgId, bankAccountId, csvContent) Parse CSV, detect duplicates, insert
reconcileTransaction(orgId, bankAccountId, body) Match bank transaction to GL transaction

3.7 SettingsService

File: apps/api/src/services/settings.service.ts

Method Description
getOrganization(orgId) Organization details
updateOrganization(orgId, data) Update organization metadata
listUsers(orgId, params) List users in org
inviteUser(orgId, data) Create user with temporary password
changeUserRole(orgId, userId, requesterId, data) Change role (cannot demote self)
deleteUser(orgId, userId, requesterId) Remove user (cannot delete self)
listCurrencies() All active currencies
getExchangeRate(params) Get rate for currency pair on date
getTaxRates(orgId) Get org tax rate config
updateTaxRates(orgId, data) Update tax rate config

4. Middleware Stack

File: apps/api/src/app.ts

Order is critical. Each middleware passes control to next() or sends error response.

Request
   │
   ▼
1. helmet()              — Sets security headers (CSP, HSTS, X-Frame-Options: deny, noSniff)
   │
   ▼
2. cors()                — Validates Origin header against whitelist [bilko.io, localhost:3000]
   │                       credentials: true (allows cookies)
   ▼
3. express.json()        — Parses request body as JSON (limit: 10mb)
   │
   ▼
4. express.urlencoded()  — Parses URL-encoded bodies (limit: 10mb)
   │
   ▼
5. cookieParser()        — Parses cookie header, makes cookies accessible via req.cookies
   │
   ▼
6. apiLimiter            — Rate limit: 100 req per 15 min per IP (applied to /api/v1/*)
   │   authLimiter       — Stricter rate limit on /auth/login and /auth/register
   ▼
7. routes                — Mounts all route modules at /api/v1
   │
   ├── authGuard()       — Verifies JWT Bearer token, attaches req.user
   │                       Source: apps/api/src/middleware/auth.ts
   │
   ├── organizationScope() — Validates req.user.organizationId (currently no-op, used as anchor)
   │                         Source: apps/api/src/middleware/org-scope.ts
   │
   ├── validate(schema)  — Validates req.body or req.query against Zod schema
   │                       Source: apps/api/src/middleware/validate.ts
   │
   └── routeHandler      — Business logic (calls service layer)
   │
   ▼
8. errorHandler()        — Centralized error handler (MUST be last)
                           Source: apps/api/src/middleware/error-handler.ts
                           Translates AppError → HTTP status + JSON

Error response format:

{
  "error": "Invoice not found",
  "code": "NOT_FOUND",
  "details": {}
}

HTTP status codes used:


5. Double-Entry Bookkeeping Implementation

Core library: packages/core/src/accounting/index.ts Prisma model: Transaction in packages/database/prisma/schema.prisma

5.1 Fundamental Rule

Every financial event creates exactly one Transaction record with:

5.2 validateDoubleEntry() (core engine)

// packages/core/src/accounting/index.ts
export function validateDoubleEntry(lines: JournalEntryLine[]): boolean {
  // Returns false if: < 2 lines, negative amounts, unbalanced
  let totalDebits = new Decimal(0);
  let totalCredits = new Decimal(0);
  for (const line of lines) {
    const amount = new Decimal(line.amount);
    if (amount.lte(0)) return false;
    if (line.side === 'debit') totalDebits = totalDebits.plus(amount);
    else totalCredits = totalCredits.plus(amount);
  }
  return totalDebits.eq(totalCredits);
}

5.3 Transaction Creation Patterns

Invoice sent (DR Receivable / CR Revenue):

DR  Accounts Receivable (code: 12x)   +120,000 RSD
    CR  Revenue (code: 6xx)                       +120,000 RSD

Payment received (DR Bank / CR Receivable):

DR  Bank Account (code: 10x)          +120,000 RSD
    CR  Accounts Receivable (code: 12x)            +120,000 RSD

Expense approved (DR Expense / CR Payable):

DR  Expense Account (code: 5xx)       +5,000 RSD
    CR  Accounts Payable (code: 22x)               +5,000 RSD

Expense paid (DR Payable / CR Bank):

DR  Accounts Payable (code: 22x)      +5,000 RSD
    CR  Bank Account (code: 10x)                    +5,000 RSD

5.4 Account Lookup Strategy

Services find accounts by account type ID + code prefix:

Code prefixes (Balkan chart of accounts):

5.5 Trial Balance

// packages/core/src/accounting/index.ts
export function calculateTrialBalance(transactions: JournalEntry[]): TrialBalance {
  // Groups by accountNumber, sums debits and credits
  // Returns: { rows[], totalDebits, totalCredits, isBalanced }
  // isBalanced = totalDebits.eq(totalCredits)
}

6. Tax Calculation Logic Per Country

Core module: packages/core/src/tax/index.ts Country modules: packages/country-{rs|ba|hr}/src/tax/index.ts

6.1 Serbia (RS)

File: packages/country-rs/src/tax/index.ts

Rate Value Applies To
Standard 20% Most taxable supplies
Reduced 10% Basic food, medicine, newspapers, public transport, utilities
Zero/Exempt 0% Exports, international transport, financial services
export const serbianVATRates = {
  standard: '20', reduced: '10', zero: '0', exempt: '0'
}

// VAT registration: mandatory above 8M RSD annual revenue
export const SERBIAN_VAT_THRESHOLD = '8000000';
// Pausal (simplified) regime: below 6M RSD
export const SERBIAN_PAUSAL_THRESHOLD = '6000000';
// Corporate income tax
export const SERBIAN_CIT_RATE = '15'; // flat 15%

Key function:

export function calculateSerbianPDV(amount: MonetaryAmount, rate = 'standard'): string {
  // Returns: net.times(rateDecimal).dividedBy(100).toFixed(2)
}

6.2 Bosnia & Herzegovina (BA)

File: packages/country-ba/src/tax/index.ts

Rate Value Applies To
Standard 17% All taxable supplies (single rate, no reduced)
Zero 0% Exports
export const bosnianVATRates = { standard: '17', zero: '0' }
// Registration threshold: 100,000 BAM
export const BIH_VAT_THRESHOLD = '100000';
// CIT: 10% for both FBiH and RS entities
export const BIH_CIT_RATES = { fbih: '10', rs: '10' }
// WHT: FBiH dividends 5%, RS dividends 10%
export const BIH_WHT_RATES = { fbih: { dividends: '5', interest: '10' }, rs: { dividends: '10', ... } }

6.3 Croatia (HR)

File: packages/country-hr/src/tax/index.ts

Rate Value Applies To
Standard 25% Most taxable supplies
Reduced 13% Food products, accommodation, utilities
Super-reduced 5% Books, medicines, newspapers
Zero 0% Intra-EU transport, international transport
export const croatianVATRates = { standard: '25', reduced: '13', superReduced: '5', zero: '0' }
// Registration threshold: 60,000 EUR (aligned with EU 2025)
export const CROATIAN_VAT_THRESHOLD = '60000';
// CIT: progressive — 10% if revenue < 1M EUR, 18% if >= 1M EUR
export const CROATIAN_CIT_RATES = { small: '10', standard: '18', threshold: '1000000' }

6.4 Generic VAT Calculation (Core Engine)

// packages/core/src/tax/index.ts
export function calculateVAT(amount: MonetaryAmount, rate: MonetaryAmount): VATResult {
  const base = new Decimal(amount);       // net amount
  const vatRate = new Decimal(rate);
  const tax = base.times(vatRate).dividedBy(100);
  const total = base.plus(tax);
  return {
    base: new Decimal(base.toFixed(4)),
    tax: new Decimal(tax.toFixed(4)),
    total: new Decimal(total.toFixed(4)),
  };
}

export function calculateNetFromGross(grossAmount: MonetaryAmount, vatRate: MonetaryAmount): VATResult {
  // Reverse VAT: gross / (1 + rate/100)
  const divisor = new Decimal(100).plus(new Decimal(vatRate)).dividedBy(100);
  const base = new Decimal(grossAmount).dividedBy(divisor);
  ...
}

7. Invoice Lifecycle

7.1 Status Machine

draft ──[send]──► sent ──[mark-paid]──► paid
  │                 │
  │            [cancel]
  │                 │
  └────[cancel]──► cancelled
                sent ──[overdue cron]──► overdue ──[mark-paid]──► paid
                viewed ──[mark-paid]──► paid

7.2 Numbering

Invoice numbers are generated sequentially per organization per year: INV-YYYY-NNN (e.g., INV-2026-001). The service queries the last invoice number with the current year prefix and increments.

private async generateInvoiceNumber(organizationId: string): Promise<string> {
  const year = new Date().getFullYear();
  const prefix = `INV-${year}-`;
  const lastInvoice = await prisma.invoice.findFirst({
    where: { organizationId, invoiceNumber: { startsWith: prefix } },
    orderBy: { invoiceNumber: 'desc' },
  });
  const nextNumber = lastInvoice ? parseInt(lastInvoice.invoiceNumber.split('-')[2]) + 1 : 1;
  return `${prefix}${String(nextNumber).padStart(3, '0')}`;
}

7.3 Amount Calculation

On create/update:

  1. For each line item: lineTotal = quantity × unitPrice
  2. taxAmount per line: lineTotal × taxRate / 100
  3. subtotal = Σ lineTotals
  4. taxAmount = Σ lineTax amounts
  5. totalAmount = subtotal + taxAmount
  6. baseAmount = totalAmount × exchangeRate

All using Decimal.js — never JavaScript number.


8. Bank Import Flow

Source: packages/core/src/bank-import/index.ts

8.1 CSV Format

Date,Amount,Currency,Direction,Counterparty,Reference,Description
2026-02-01,5000.00,RSD,inbound,Acme Client,INV-2026-001,Invoice payment

Supported date formats: YYYY-MM-DD, DD.MM.YYYY, DD/MM/YYYY

8.2 Import Process

POST /api/v1/bank-accounts/:id/import
  │
  ├── parseCSV(csvContent) → BankTransaction[]
  │     - Split by newline, skip header
  │     - Parse each field: date, amount, currency, direction, reference
  │     - Generate deterministic ID for dedup: hash(date|amount|currency|reference|lineIndex)
  │
  ├── detectDuplicates(existingTxs, importedTxs)
  │     - Fingerprint: YYYY-MM-DD|amount|currency|reference
  │     - Returns list of duplicate transactions
  │
  ├── Filter out duplicates
  │
  └── Insert new BankTransactions into database
        Returns: { imported: N, duplicates: M, errors: K }

8.3 Reconciliation

Manual reconciliation links a BankTransaction to a Transaction (GL entry):

POST /api/v1/bank-accounts/:id/reconcile
  body: { bankTransactionId, transactionId }
  │
  ├── Verify both belong to organization
  ├── Set BankTransaction.reconciled = true
  ├── Set BankTransaction.matchedTransactionId = transactionId
  └── Set Transaction.reconciled = true

9. Core Engine Modules

Package: @bilko/core (packages/core/src/)

Module File Purpose
accounting src/accounting/index.ts validateDoubleEntry, createJournalEntry, calculateTrialBalance
tax src/tax/index.ts calculateVAT, calculateNetFromGross, getVATRates, calculateCIT
multi-currency src/multi-currency/index.ts convertCurrency, lockExchangeRate, calculateForexGainLoss
bank-import src/bank-import/index.ts parseCSV, detectDuplicates
invoicing src/invoicing/index.ts Invoice computation helpers
chart-of-accounts src/chart-of-accounts/index.ts Chart structure definitions
reporting src/reporting/index.ts Report calculation utilities

Key constraint: MonetaryAmount = string | Decimal — JavaScript number is never used for monetary values anywhere in the core engine.

Validation Report

Bilko Validation Report

Date: 2026-02-20 Validator: John (AI Director) — Gate Validation Phase Project ID: bbd77cc0

Executive Summary

7 out of 8 gates PASS. Bilko is architecturally sound with comprehensive documentation, validated schema, and working frontend prototype. Gate 8 (CEO Approval) remains PENDING as required. No blocking issues found. Ready for executive review.

Key Findings:


Gate Results

Gate 1: Market Research — PASS

Evidence: ~/system/specs/bilko-prd.md (lines 11-22) Findings:

Issues: None


Gate 2: Competitive Analysis — PASS

Evidence: ~/system/specs/bilko-prd.md (implicit in positioning), ~/system/specs/bilko-tech-stack.md (lines 45-49, alternatives sections) Findings:

Issues: None


Gate 3: Tech Stack Decision — PASS

Evidence: ~/system/specs/bilko-tech-stack.md (full doc), /Users/makinja/ALAI/products/Bilko/apps/web/package.json Findings:

Issues: None


Gate 4: Product Requirements (PRD) — PASS

Evidence: ~/system/specs/bilko-prd.md (137 lines) Findings:

Cross-validation with schema:

Issues: None


Gate 5: Database Schema — PASS

Evidence: /Users/makinja/ALAI/products/Bilko/packages/database/prisma/schema.prisma (485 lines), docs/backend/DATABASE-SCHEMA.md (600+ lines) Findings:

Schema Coverage (15 models):

PRD Feature Validation:

  1. ✅ Invoicing & Estimates — Invoice model with line items, VAT calculation, multi-currency, status tracking
  2. ✅ Expense Tracking — Expense model with categories, receipt URL, payment method
  3. ✅ Bank Integration — BankAccount + BankTransaction models with reconciliation flags
  4. ✅ Financial Reporting — Transaction + Account models support P&L, Balance Sheet, Cash Flow
  5. ✅ VAT/Tax Management — InvoiceItem.taxRate, Expense.taxAmount
  6. ✅ Double-Entry Bookkeeping — Transaction model with debitAccount + creditAccount, NormalBalance enum
  7. ✅ Multi-Device Access — API-first architecture (supports web + mobile PWA)
  8. ✅ User Collaboration — User model with role enum, LoggedAction audit trail
  9. ✅ Security — LoggedAction immutable audit, password hashing, 2FA fields (twoFactorEnabled, twoFactorSecret)

Critical Validations:

No Phantom Features:

Issues: None


Gate 6: UI/UX Design — PASS

Evidence: ~/system/specs/bilko-wireframes.md (634 lines), apps/web/ implementation (10 pages), docs/frontend/ (5 files) Findings:

Wireframe Coverage:

Implemented Pages (10):

  1. /dashboard — Dashboard with metrics + charts
  2. /invoices — Invoice list with search/filter
  3. /invoices/new — 6-step invoice wizard
  4. /expenses — Expense list
  5. /purchases — Alias to expenses
  6. /banking — Placeholder (wireframe pending)
  7. /reports — Reports hub
  8. /reports/vat — VAT report
  9. /settings — User settings
  10. / (root) — Redirects to dashboard

Design System Consistency:

Cross-validation (tailwind.config.ts vs DESIGN-SYSTEM.md):

Responsive Design:

Issues: None


Gate 7: Regulatory Compliance — PASS

Evidence: docs/regulatory/ (4 files: SERBIA-SEF.md, BIH-PDV.md, CROATIA-ERACUN.md, CHART-OF-ACCOUNTS.md) Findings:

Serbia (SERBIA-SEF.md — 351 lines):

Bosnia & Herzegovina (BIH-PDV.md — 310 lines):

Croatia (CROATIA-ERACUN.md — 404 lines):

Chart of Accounts (CHART-OF-ACCOUNTS.md — 523 lines):

Tax Rates Cross-Check:

MVP Blockers:

Issues: None (2 MEDIUM-confidence items are not MVP blockers)


Gate 8: CEO Approval — PENDING

Evidence: Awaiting Alem review Findings:

Executive Summary for CEO:

  1. Business Case:

    • TAM: €50-150M (348K businesses across Serbia, BiH, Croatia)
    • Forcing function: Croatia 2026 e-invoicing mandate
    • Pricing: €8-25/month (competitive with Fiken, undercuts QuickBooks)
    • Bootstrap budget: €2K MVP, €11-17K Phase 1
  2. Technical Architecture:

    • Next.js 15 + React 19 + PostgreSQL + Prisma (proven stack)
    • Frontend: 10 pages implemented with mock data (ready for API integration)
    • Database: 15 models, fully validated against PRD
    • Hosting: €21/mo MVP (Vercel + Railway)
  3. Regulatory Compliance:

    • All 3 target countries researched (Serbia, BiH, Croatia)
    • Tax rates verified, e-invoicing requirements documented
    • Chart of Accounts standards identified
    • No blocking compliance issues
  4. Documentation Quality:

    • 23 documentation files, 12,127 lines total
    • Backend specification complete (50 API endpoints)
    • Frontend specification complete (design system + component inventory)
    • Testing strategy defined (Vitest + Supertest + Playwright)
    • Security architecture planned (JWT, RBAC, encryption)
  5. Resource Plan:

    • Timeline: 8-10 weeks MVP
    • Team: 1 developer (€3-5K/month), 1 accounting advisor (€500/month)
    • Next step: Hire developer, finalize brand name (Bilko reserved)
  6. Risk Assessment:

    • LOW RISK: BiH e-invoicing pending (can use PDF invoices initially)
    • LOW RISK: Serbia SEF integration requires digital cert (defer to post-MVP)
    • MEDIUM RISK: Competitive market (mitigated by Balkan localization)
  7. Go-to-Market Strategy:

    • Launch: Serbia first (largest market, SEF integration differentiator)
    • Expand: Croatia (e-invoicing mandate = forced adoption)
    • Expand: BiH (when e-invoicing regulations finalized)

Recommendation: APPROVE — All gates validated, architecture sound, regulatory research complete, no blocking issues.

Next Steps (if approved):

  1. Hire backend developer (€3-5K/month)
  2. Hire accounting advisor (€500/month, Serbia-based)
  3. Backend implementation (8-10 weeks)
  4. Beta testing with 5 SMBs + 3 accountants
  5. Launch Serbia MVP

Issues: None


Cross-Document Consistency Check

CLAUDE.md files:

Specs vs Docs:

No Contradictions Found:


Issues Found

# Severity Gate Issue Recommendation
1 INFO 7 Serbia digital certificate requirement has MEDIUM confidence Consult local accounting advisor before SEF integration
2 INFO 7 BiH e-invoicing regulations pending (draft law in Parliament) Monitor UNO/ITA website, can launch with PDF invoices initially

No HIGH-severity issues. No MEDIUM-severity issues blocking MVP.


Conclusion

Bilko has passed all 7 pre-approval gates with ZERO blocking issues. The project demonstrates:

  1. Thorough research — Real market data, real competitors, regulatory compliance verified
  2. Sound architecture — Database schema validated, double-entry enforced, multi-currency correct
  3. Comprehensive documentation — 23 files, 12,127 lines, covering backend, frontend, infrastructure, security, testing, regulatory
  4. Working prototype — 10 pages implemented, design system consistent, mock data ready for API replacement
  5. No hallucinations — All file paths valid, all companies real, all numbers cross-validated

READY FOR GATE 8 CEO APPROVAL.


Validation completed by: John (AI Director) Timestamp: 2026-02-20T11:45:00Z Validator confidence: HIGH (all source files read and cross-validated)

Bilko — Project Handbook

Bilko — Balkan Accounting SaaS

BookStack — Provjeri PRVO

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

Quick Info

Branding

Tech Stack

Project Structure

Bilko/
├── apps/
│   ├── web/          # Next.js 15 frontend — 8+ pages, MOCK DATA
│   └── api/          # Express backend — EMPTY (see api/CLAUDE.md)
├── packages/
│   ├── database/     # Prisma schema — 15 models, FULLY DEFINED
│   └── ui/           # Shared UI — empty scaffold
├── docs/             # TO BE CREATED
├── CLAUDE.md         # This file
└── PIPELINE.md       # Gate tracker

Frontend Status (apps/web/)

IMPLEMENTED:

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

Database Status (packages/database/)

FULLY DEFINED: 15 models in prisma/schema.prisma

KEY DECISIONS:

Backend Status (apps/api/)

NOT BUILT YET. See apps/api/CLAUDE.md for target architecture. When building, follow docs/backend/API-REFERENCE.md (to be created).

Development Rules

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

Specs Location

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

Documentation

Pipeline Gate Tracker

Bilko Pipeline — 8-Gate Tracker

Overview

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

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

Gate Definitions

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

Current Status

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

Gate Validation Summary (2026-02-20)

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

Gate 4: Product Requirements — PASS

Gate 5: Database Schema — PASS

Gate 6: UI/UX Design — PASS

Gate 7: Regulatory Compliance — PASS

Gate 8: CEO Approval — PASS

Approved by Alem on 2026-02-20

CODE UNFROZEN — Backend development started

Deliverables:

Backend Status (2026-02-20):

Next Steps:

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

Status: DEVELOPMENT IN PROGRESS

All 8 gates PASSED — Project approved and active

Decision Log

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

Notes

References

ADR-022 — Document Archive Strategy

MC #100025 | Published 2026-05-08 | Status: Approved (Pattern 3 — Skybound)
Related: SPEC-022COMPLIANCE-022

ADR-022: Document Archive Strategy for Paperless-ngx Integration

Status: Proposed Date: 2026-05-08 Author: Skybound (ALAI SaaS Architecture) Related: MC #100025, MC #100004 (IMAP→Paperless pipe)

---

Context

Business Need

Bilko generates high-value, low-frequency documents requiring long-term archival in a centralized, searchable repository:

Current state: documents generated in-app (PDF via pdfkit), stored in Cloudflare R2 (configured, see BUILD-BLUEPRINT.md line 64), but no archival pipe to Paperless-ngx at archive.alai.no.

CEO question (2026-05-08): "Does Bilko have email→Paperless integration?" Answer: NO. This ADR selects the archival pattern before implementation begins.

Paperless-ngx Environment

Bilko Technical Constraints

From BUILD-BLUEPRINT.md:

Paperless-ngx Multi-Tenant Capabilities

Paperless-ngx is NOT multi-tenant at the DB schema level. Tenant isolation MUST be enforced via:

1. Tags (e.g., org:uuid-abc123) 2. Correspondent field (one correspondent per tenant, e.g., "Org: Firma AS") 3. Document Type field (e.g., "Invoice", "Contract", "Care Plan") 4. Custom Fields (optional key-value metadata)

All three can be set via POST /api/documents/post_document/ API.

---

Decision

Bilko will write documents to a Cloudflare R2 bucket (already in use) with metadata attached (organizationId, documentType, timestamp). A separate Cloud Run job (or Cron Worker, TBD in implementation phase) reads the queue and uploads to Paperless-ngx via direct API call, applying multi-tenant tags (org:uuid-xxx), correspondent, and document type.

Fallback during outages: If archiver job fails or Paperless is unavailable, documents remain in R2 with idempotent retry semantics. Bilko user experience is never degraded by Paperless downtime.

---

Decision Drivers

CriterionWeightPattern 1 (Email)Pattern 2 (Direct API)Pattern 3 (Blob Queue)
---------------------------------------------------------------------------------------------
Multi-tenant scopingHIGH3/54/55/5
Bilko couplingHIGH5/52/55/5
Paperless couplingHIGH4/51/55/5
Retry/idempotencyHIGH2/53/55/5
Auth modelMED5/52/54/5
Dev velocityMED5/54/53/5
Ops surfaceMED4/55/53/5
Cross-cloud friendlinessMED5/53/55/5
Dedup strategyLOW2/54/55/5
Scalability (>1k docs/day)LOW2/55/55/5
TOTAL (weighted sum)3.6/53.2/54.6/5

Scoring rationale:

---

Consequences

Positive

1. Bilko never blocks on Paperless downtime. User uploads document, gets immediate success (R2 write ~50ms), archival happens async. 2. Idempotent retry semantics. Worker crashes mid-upload? R2 object still there, retry on next cron run (dedupe via object key or Paperless custom_fields SHA256). 3. Multi-tenant isolation enforced at archival layer. Worker reads organizationId from R2 metadata → applies tags=org:uuid-abc123 + correspondent="Firma AS (uuid-abc123)" in Paperless. Search in Paperless UI: filter by tag = instant tenant-scoped results. 4. Scales to additional archive targets. Worker can fan-out to Paperless + S3 Glacier + OneDrive (future). Bilko unchanged. 5. Zero cross-cloud hot-path latency. Bilko writes to R2 (same Cloudflare edge region as app), worker polls async. 6. Reuses existing R2 bucket. No new storage provisioning. R2 lifecycle policy can auto-delete after N days post-archive (cost optimization).

Negative

1. Eventual consistency. Document archived 1–15 minutes after user upload (depends on worker cron interval). If CEO searches Paperless 30 seconds after upload, doc not yet there. 2. Additional ops surface. Worker must be monitored (cron health check, dead-letter queue for failed uploads). 3. Dev velocity slower than Pattern 1. Must scaffold worker + deploy pipeline + monitoring.

Neutral

1. Auth surface expands slightly. Worker holds CF Access token + Paperless API token. Rotation = worker redeploy or Secret Manager update (already standard for GCP Cloud Run). 2. R2 becomes queue. If worker stops (VM crash, deployment), R2 accumulates unprocessed docs. Recovery = restart worker, process backlog.

---

Alternatives Considered

Pattern 1 — App→Email→Paperless (Relay)

How it works: Bilko backend sends document as attachment to dedicated inbox (e.g., bilko-archive@alai.no). Daemon (MC #100004 pipe) polls inbox, uploads to Paperless.

Pros:

Cons:

Rejection rationale: Multi-tenant scoping via email subject/filename parsing is fragile. Email attachment size limits block future use cases (e.g., scanned multi-page contracts = 50MB PDF). No idempotent retry (email duplicates on send retry).

---

Pattern 2 — App→Direct Paperless API (Push)

How it works: Bilko backend calls POST https://archive.alai.no/api/documents/post_document/ directly with app-scoped CF Access service token + Paperless API token. Synchronous upload during user request.

Pros:

Cons:

Rejection rationale: Paperless availability becomes Bilko UX blocker. User uploads signed contract, archive.alai.no is down, user sees "Upload failed" even though contract PDF saved to R2. Unacceptable UX degradation for external dependency. Cross-cloud latency (250ms) in hot path for low-value sync feedback.

---

Pattern 3 — App→Shared Blob→Archiver Job (Batch) [RECOMMENDED]

How it works: Bilko writes document to Cloudflare R2 bucket (alai-bilko-archive-queue/ prefix or separate bucket) with metadata:

{
  "organizationId": "uuid-abc123",
  "organizationName": "Firma AS",
  "documentType": "invoice",
  "invoiceNumber": "2024-001",
  "timestamp": "2026-05-08T10:30:00Z",
  "sha256": "abc123...def"
}

Separate Cloud Run job (cron every 5 minutes, or Cloud Tasks queue) reads R2 objects, uploads to Paperless via POST /api/documents/post_document/ with:

After successful upload, worker deletes R2 object (or moves to archived/ prefix). On failure, object remains, retry on next cron run.

Pros:

Cons:

Why this pattern wins:

1. Bilko UX never degrades. Paperless down? User still uploads doc successfully (R2 write). Worker retries until Paperless recovers. 2. Multi-tenant isolation enforced structurally. Worker applies org:{uuid} tag from R2 metadata. No chance of cross-tenant leak (Paperless search by tag = instant tenant filter). 3. Scales to 10,000 orgs × 100 docs/day. R2 = unlimited storage, worker processes batch (100 docs/run = 6 seconds at 60ms/doc). 4. Idempotent by design. R2 object key = content hash. Worker crash mid-upload? Re-run processes same doc, Paperless dedupes via custom_fields.sha256. 5. Reuses existing Bilko infrastructure. R2 bucket already configured (BUILD-BLUEPRINT line 64). Worker = new Cloud Run service (Terraform module = 20 lines).

Implementation complexity accepted because:

---

Implementation Spec (High-Level)

Phase 1: Bilko Backend Changes (CodeCraft)

1. Add R2 archive write function in apps/api/src/main/kotlin/no/alai/bilko/services/ArchiveService.kt:

suspend fun archiveDocument(
    organizationId: UUID,
    organizationName: String,
    documentType: String,  // "invoice" | "contract" | "care_plan"
    documentBuffer: ByteArray,
    metadata: Map  // { "invoiceNumber": "2024-001", ... }
): String {
    val sha256 = documentBuffer.sha256()
    val objectKey = "archive-queue/${organizationId}/${documentType}/${sha256}.pdf"

s3Client.putObject( bucket = "alai-bilko-files", key = objectKey, body = documentBuffer, metadata = mapOf( "organizationId" to organizationId.toString(), "organizationName" to organizationName, "documentType" to documentType, "timestamp" to Instant.now().toString(), "sha256" to sha256 ) + metadata )

return objectKey }

2. Call archiveDocument() after invoice PDF generation in InvoiceService.generatePDF():

val pdfBuffer = pdfGenerator.generate(invoice)
s3Client.putObject(...)  // existing code
archiveService.archiveDocument(
    organizationId = invoice.organizationId,
    organizationName = organization.name,
    documentType = "invoice",
    documentBuffer = pdfBuffer,
    metadata = mapOf("invoiceNumber" to invoice.number)
)

3. Same pattern for contracts, care plans, onboarding docs.

Phase 2: Archiver Worker (CodeCraft + FlowForge)

1. New Cloud Run service bilko-archiver-worker (Kotlin/Ktor or Node.js, TBD):

// apps/archiver-worker/src/main/kotlin/no/alai/bilko/archiver/Main.kt

fun main() { val s3Client = S3Client(/* R2 config */) val paperlessClient = PaperlessClient( baseUrl = "https://archive.alai.no", cfAccessClientId = System.getenv("CF_ACCESS_CLIENT_ID"), cfAccessClientSecret = System.getenv("CF_ACCESS_CLIENT_SECRET"), apiToken = System.getenv("PAPERLESS_API_TOKEN") )

runBlocking { val objects = s3Client.listObjectsV2("alai-bilko-files", prefix = "archive-queue/") objects.forEach { obj -> try { val metadata = obj.metadata val documentBuffer = s3Client.getObject(obj.key)

// Check if already uploaded (dedup) val existing = paperlessClient.searchBySHA256(metadata["sha256"]!!) if (existing != null) { logger.info("Document ${obj.key} already archived as Paperless #${existing.id}, skipping") s3Client.deleteObject(obj.key) return@forEach }

// Upload to Paperless val paperlessDoc = paperlessClient.uploadDocument( document = documentBuffer, title = "${metadata["documentType"]} - ${metadata["organizationName"]}", correspondent = "${metadata["organizationName"]} (${metadata["organizationId"]})", documentType = metadata["documentType"]!!.capitalize(), tags = listOf("org:${metadata["organizationId"]}", metadata["documentType"]!!, "bilko"), customFields = mapOf( "sha256" to metadata["sha256"]!!, "uploadedAt" to metadata["timestamp"]!!, "organizationId" to metadata["organizationId"]!! ) )

logger.info("Archived ${obj.key} → Paperless #${paperlessDoc.id}") s3Client.deleteObject(obj.key)

} catch (e: Exception) { logger.error("Failed to archive ${obj.key}: ${e.message}", e) // Leave object in R2, retry on next run } } } }

2. Deploy as Cloud Run job (triggered by Cloud Scheduler every 5 minutes):

infrastructure/gcp/terraform/modules/archiver-worker/main.tf

resource "google_cloud_run_v2_job" "bilko_archiver_worker" { name = "bilko-archiver-worker" location = var.region

template { template { containers { image = "europe-north1-docker.pkg.dev/${var.project_id}/bilko/archiver-worker:latest"

env { name = "CF_ACCESS_CLIENT_ID" value_source { secret_key_ref { secret = "cf-access-client-id" version = "latest" } } } env { name = "CF_ACCESS_CLIENT_SECRET" value_source { secret_key_ref { secret = "cf-access-client-secret" version = "latest" } } } env { name = "PAPERLESS_API_TOKEN" value_source { secret_key_ref { secret = "paperless-api-token" version = "latest" } } } }

timeout = "600s" # 10min max } } }

resource "google_cloud_scheduler_job" "archiver_trigger" { name = "bilko-archiver-cron" schedule = "*/5 * * * *" # Every 5 minutes time_zone = "Europe/Oslo"

http_target { uri = "https://${var.region}-run.googleapis.com/apis/run.googleapis.com/v1/namespaces/${var.project_id}/jobs/${google_cloud_run_v2_job.bilko_archiver_worker.name}:run" http_method = "POST"

oauth_token { service_account_email = google_service_account.archiver_worker.email } } }

3. Monitoring dashboard (Cloud Monitoring): - Queue depth (R2 objects in archive-queue/ prefix) — alert if >500 - Worker success rate — alert if <95% over 1h - Worker execution time — alert if >300s - Paperless API error rate — alert if >5% over 15min

Phase 3: Paperless-ngx Configuration (FlowForge + Proveo)

1. Create Paperless correspondents (one per Bilko org, OR dynamic via worker): - Option A: Worker auto-creates correspondent if not exists (POST /api/correspondents/ with name="Firma AS (uuid-abc123)"). - Option B: Manual setup (CEO creates correspondent in Paperless UI for each new Bilko customer). Recommend Option A for scalability.

2. Create Paperless document types: - Invoice - Contract - Care Plan - Onboarding Document - Incident Report

3. Create Paperless custom fields: - sha256 (text, unique identifier for dedup) - organizationId (text, Bilko tenant UUID) - uploadedAt (datetime, original upload timestamp) - invoiceNumber (text, optional) - contractId (text, optional)

4. Tag taxonomy: - org:{uuid} (one tag per Bilko tenant, e.g., org:abc-123-def) - invoice | contract | care-plan | onboarding | incident - bilko (source system tag)

Phase 4: Retention Policy (Dr. Sarah Chen — Healthcare Compliance)

Question for CEO:

1. How long to keep docs in R2 after successful Paperless upload? - Option A: Delete immediately (worker deletes R2 object after Paperless confirms upload). - Option B: Keep 30 days (R2 lifecycle policy auto-deletes after 30d). Allows re-upload if Paperless doc accidentally deleted. - Recommendation: Option A (immediate delete). Paperless is source of truth post-archival. R2 = queue only.

2. Paperless retention policy? - Invoices: 7 years (Norway Bokføringsloven, Serbia/Croatia equivalent) - Contracts: Indefinite (until contract expires + 5 years) - Care plans: 10 years (HIPAA if US expansion, GDPR Article 17 deletion rights) - Recommendation: Configure per-document-type in Paperless via workflow rules (out of scope for this ADR).

3. GDPR Article 17 (Right to Erasure) handling? - When Bilko org deletes account (GDPR erasure request), worker must: 1. Query Paperless GET /api/documents/?tags__name=org:{uuid} 2. Delete all matching docs DELETE /api/documents/{id}/ 3. Delete correspondent DELETE /api/correspondents/{id}/ - Recommendation: Separate MC for GDPR compliance (erasure worker). Out of scope for archival MVP.

---

Stakeholders

---

Open Questions for CEO

1. Worker cron interval: 5 minutes (recommended) vs 15 minutes (lower Cloud Run invocation cost)? - 5min = faster archival, users see docs in Paperless <6min after upload. - 15min = lower cost (~$0.50/month vs ~$1.50/month for Cloud Run invocations), acceptable delay for archival use case. - Awaiting CEO decision.

2. R2 retention after upload: Delete immediately (recommended) vs keep 30 days (safety buffer)? - Immediate = lower storage cost, cleaner queue. - 30 days = allows re-upload if Paperless doc accidentally deleted (rare edge case). - Awaiting CEO decision.

3. Multi-tenant correspondent strategy in Paperless: - Option A: One correspondent per Bilko org (e.g., "Firma AS (uuid-abc123)"). Pro: clean correspondent filter in Paperless UI. Con: 10,000 orgs = 10,000 correspondents (Paperless UI clutter). - Option B: Single correspondent "Bilko" + rely on org:{uuid} tags for tenant isolation. Pro: clean Paperless correspondent list. Con: must always filter by tag (cannot filter by correspondent alone). - Recommendation: Option A (one correspondent per org). Paperless search by correspondent is more intuitive than tag filter for non-technical users (CEO searching for customer docs). - Awaiting CEO decision.

---

References

---

Next Steps (Child MCs)

Upon CEO approval of Pattern 3:

1. MC #TBD (CodeCraft): Implement ArchiveService.kt in Bilko backend + call from InvoiceService.generatePDF(). Estimate: 2h. Priority: M. 2. MC #TBD (CodeCraft): Scaffold archiver worker (apps/archiver-worker/) with R2→Paperless upload logic + dedup via SHA256. Estimate: 4h. Priority: M. 3. MC #TBD (FlowForge): Deploy archiver worker as Cloud Run job + Cloud Scheduler cron (Terraform IaC). Estimate: 3h. Priority: M. 4. MC #TBD (FlowForge): Provision CF Access service token for archiver worker + store in Secret Manager. Estimate: 1h. Priority: M. 5. MC #TBD (Proveo): End-to-end validation — upload test invoice in Bilko stage, verify appears in Paperless with org:{uuid} tag + correspondent. Estimate: 2h. Priority: M. 6. MC #TBD (Skillforge): BookStack runbook page for archiver worker (troubleshooting, monitoring dashboard links, manual queue drain). Estimate: 1h. Priority: L.

Total estimate: 13h across 3 specialists (CodeCraft 6h, FlowForge 4h, Proveo 2h, Skillforge 1h).

---

Decision Status: Awaiting CEO approval on:

1. Pattern 3 acceptance (vs Pattern 1 or 2) 2. Worker cron interval (5min vs 15min) 3. R2 retention policy (immediate delete vs 30d) 4. Paperless correspondent strategy (one-per-org vs single "Bilko" correspondent)

Next action: CEO review → approve → create 6 child MCs → dispatch to CodeCraft/FlowForge/Proveo/Skillforge.

SPEC-022 — Document Archive Implementation

MC #100025 | Published 2026-05-08 | Status: Approved (Pattern 3 — Skybound)
Related: ADR-022COMPLIANCE-022

SPEC-022: Document Archive Implementation — Pattern 3 (Blob Queue)

Status: Draft — awaiting CodeCraft + FlowForge dispatch Date: 2026-05-08 Author: CodeCraft (MC #100025, Subtask 2) ADR: ADR-022-document-archive-strategy.md (Pattern 3 selected, 4.6/5 weighted score) CEO Decisions baked in: D1 (5min cron), D2 (delete immediately on success), D3 (one correspondent per org) Related: MC #100025 (this task), MC #100004 (IMAP→Paperless pipe, BookStack #2862)

---

1. Overview

Pattern 3 (App→Shared Blob→Archiver Job) is the selected architecture for Bilko→Paperless-ngx document archival. Bilko backend writes generated PDFs plus a .meta.json sidecar to a dedicated Cloudflare R2 staging bucket (bilko-archive-queue). A separate Cloud Run job (archiver-worker) polls the queue on a 5-minute cron (per CEO decision D1), uploads each document to Paperless-ngx at archive.alai.no via the Paperless REST API, and deletes the R2 object immediately upon confirmed upload (per CEO decision D2). Multi-tenant isolation is enforced by the worker reading organizationId from R2 object metadata and applying an org: tag plus a per-org correspondent (per CEO decision D3) on every Paperless document. Bilko user experience is never degraded by Paperless downtime; R2 is the authoritative queue and all retry semantics live in the worker. See ADR-022 §Decision Drivers for the full pattern comparison and rejection rationale for Patterns 1 and 2.

---

2. Components

ComponentLocationTypePurpose
-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
ArchiveServiceapps/api/src/main/kotlin/no/alai/bilko/services/ArchiveService.ktNew Kotlin serviceWrites PDF + .meta.json sidecar to R2 bilko-archive-queue bucket; returns ArchiveJobId
R2 bucket bilko-archive-queueCloudflare R2 (separate from existing AWS_S3_BUCKET)New bucketStaging queue for pending Paperless uploads
R2 bucket bilko-archive-dlqCloudflare R2New bucketDead-letter queue for objects that failed 3 upload attempts
archiver-workerapps/archiver-worker/New Cloud Run job (Node.js — see §10)Polls R2 → uploads to Paperless → deletes R2 objects
Cloud Scheduler triggerGCP Cloud Scheduler bilko-archiver-cronNew scheduler jobFires archiver-worker Cloud Run job every 5 minutes (per CEO decision D1)
Flyway migration V_archive_statusapps/api/src/main/resources/db/migration/New migrationAdds archive_status, archive_job_id, paperless_doc_url, archived_at columns to invoices and future document tables
ArchiveAuditLogapps/api/src/main/kotlin/no/alai/bilko/model/ArchiveAuditLog.kt + Flyway migrationNew DB tablePer-document archive status: pending, archived, failed
Bilko DB table org_paperless_cachePostgreSQL, Flyway migrationNew tableCaches organizationId → paperless_correspondent_id and organizationId → paperless_org_tag_id to avoid repeat API calls

---

3. Interfaces

3.1 ArchiveService — Kotlin signature

// File: apps/api/src/main/kotlin/no/alai/bilko/services/ArchiveService.kt
// Package: no.alai.bilko.services

data class SourceDoc( val organizationId: UUID, val organizationName: String, val documentType: DocumentType, // enum: INVOICE | CONTRACT | CARE_PLAN | INCIDENT_REPORT | ONBOARDING val documentBuffer: ByteArray, // raw PDF bytes val sha256: String, // hex SHA-256 of documentBuffer val metadata: Map // e.g. { "invoiceNumber": "2024-001", "contractId": "abc" } )

data class ArchiveOptions( val priority: ArchivePriority = ArchivePriority.NORMAL // NORMAL | HIGH (for future urgency flag) )

// Return type — opaque job ID (R2 object key) typealias ArchiveJobId = String

// Primary entry point — called by InvoiceService, ContractService, etc. // Throws ArchiveWriteException (wraps R2 S3 error) on R2 write failure. // NEVER throws on Paperless unavailability (async path). suspend fun archive(sourceDoc: SourceDoc, options: ArchiveOptions = ArchiveOptions()): ArchiveJobId

Callers (e.g. InvoiceService.generatePDF()) catch ArchiveWriteException and return HTTP 503 to the user with body {"error": "Document archived pending. Retry in 5 minutes.", "code": "ARCHIVE_QUEUE_FAILURE"}. The Bilko UX is decoupled per ADR-022 §Consequences (Positive #1): R2 write failure is the only user-visible failure; Paperless unavailability is invisible to the user.

3.2 R2 Object Schema

Object key convention:

org///.pdf
org///.meta.json

Example:

org/550e8400-e29b-41d4-a716-446655440000/invoice/a1b2c3d4...ef.pdf
org/550e8400-e29b-41d4-a716-446655440000/invoice/a1b2c3d4...ef.meta.json

Using SHA-256 as the object key suffix provides idempotent R2 writes: re-upload of identical PDF bytes overwrites the same key (R2 last-writer-wins), preventing queue bloat on Bilko retry paths.

.meta.json schema:

{
  "schemaVersion": "1",
  "r2Uuid": "",
  "organizationId": "550e8400-e29b-41d4-a716-446655440000",
  "organizationName": "Firma AS",
  "documentType": "invoice",
  "bilkoDocumentId": "",
  "invoiceNumber": "2024-001",
  "contractId": null,
  "timestamp": "2026-05-08T10:30:00Z",
  "sha256": "a1b2c3d4...ef",
  "retryCount": 0,
  "lastAttemptAt": null,
  "lastError": null
}

Content-type: PDF object → application/pdf. .meta.jsonapplication/json.

Retention class: Standard (no Infrequent Access — objects are short-lived by design).

3.3 Worker → Paperless API call

The worker calls POST https://archive.alai.no/api/documents/post_document/ as multipart/form-data:

POST /api/documents/post_document/
Host: archive.alai.no
CF-Access-Client-Id: 
CF-Access-Client-Secret: 
Authorization: Token 
Content-Type: multipart/form-data

Fields: document — PDF binary (required) title — " — " (e.g. "Invoice — Firma AS 2026-05-08") correspondent — (integer, pre-resolved by worker — see §5) document_type — (integer, mapped from documentType enum) tags — [, , , ] created — custom_fields — [{"field": , "value": ""}, {"field": , "value": ""}, {"field": , "value": ""}]

The worker resolves correspondent_id, document_type_id, and tag IDs prior to the upload call, using the org_paperless_cache Bilko DB table (see §5). All IDs are integers assigned by Paperless on creation.

Reuse of paperless-upload.js: The worker is Node.js (see §10 for language decision). It may directly import or inline logic equivalent to ~/system/tools/paperless-upload.js (MC #100004). The worker should NOT import the file at runtime from the system tools path — instead, the logic (multipart form construction, CF Access header injection, retry) is copied into apps/archiver-worker/src/paperlessClient.js with full ownership by the Bilko repo. This avoids coupling the Bilko Cloud Run container to the ALAI system tools directory.

Worker side-effect on success (D2):

1. DELETE R2 PDF object key. 2. DELETE R2 .meta.json sidecar key (per CEO decision D2 — delete immediately, no buffer). 3. UPDATE Bilko DB archive_audit_log row: archive_status = 'archived', paperless_doc_url = , archived_at = NOW(). 4. UPDATE Bilko DB source document table (e.g. invoices): archive_status = 'archived', paperless_doc_url = , archived_at = NOW().

Worker updates the Bilko DB via a thin internal HTTP endpoint on bilko-api Cloud Run service (authenticated with a shared internal API key stored in Secret Manager), OR directly via Cloud SQL connection if the worker runs in the same GCP VPC. Recommendation: internal HTTP endpoint on bilko-api is safer (no direct DB access from worker, follows existing service boundary). Endpoint: PATCH /internal/v1/archive-audit/{bilkoDocumentId} — worker-to-api call, not user-facing.

---

4. Auth Model

4.1 Bilko backend → R2 (write to bilko-archive-queue)

Reuses existing R2 credentials already in Bilko Cloud Run environment: AWS_S3_ENDPOINT, AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_REGION, AWS_S3_BUCKET. A new env var AWS_S3_ARCHIVE_BUCKET=bilko-archive-queue routes ArchiveService writes to the separate archive bucket. The same Cloudflare R2 token is scoped to both buckets via R2 token policy. No new SA credential needed for Bilko backend.

4.2 Worker → R2 (read + delete from bilko-archive-queue)

Recommendation: separate R2 API token for the worker, scoped to bilko-archive-queue and bilko-archive-dlq with read + delete permissions. Do NOT share the Bilko production R2 token (which has write access to the main receipts/PDF bucket) with the worker. Principle of least privilege: worker should not be able to touch the main Bilko file storage bucket.

Worker credential: new Cloudflare R2 API token stored in GCP Secret Manager as bilko-archiver-r2-access-key-id and bilko-archiver-r2-secret-access-key. Provisioned by FlowForge as part of the worker deployment Terraform module.

4.3 Worker → Paperless (archive.alai.no)

Worker requires two credentials:

Recommendation: create a new dedicated Bitwarden item Paperless API Token — bilko-archiver-worker (separate from the existing Paperless API Token — anvil item referenced in MC #100004).

Rationale: the existing anvil token is shared with the IMAP→Paperless daemon (MC #100004). If the worker token is rotated (e.g. Bilko security incident), the IMAP daemon must not be affected. Separate tokens allow independent rotation. Both tokens have equal Paperless API access (same permissions) but are separate credentials with separate audit trails in Paperless and Bitwarden.

Similarly, create a new CF Access service token bilko-archiver-worker in Cloudflare Zero Trust, separate from any existing archive-alai-no CF Access token. Stored as: bilko-archiver-cf-access-client-id and bilko-archiver-cf-access-client-secret in GCP Secret Manager.

4.4 Bilko backend → Paperless

FORBIDDEN. The Bilko backend NEVER calls Paperless directly. Pattern 3 rationale from ADR-022 §Pattern 2 rejection: "Paperless becomes hot-path dependency — if archive.alai.no is down, Bilko document upload fails. User sees error." The R2 queue decouples Bilko from Paperless availability entirely.

---

5. Multi-Tenant Scoping

5.1 Correspondent strategy (per CEO decision D3 — one per org)

Correspondent name pattern: org- (e.g. org-550e8400-e29b-41d4-a716-446655440000).

On first archive for a new organization, the worker calls:

POST https://archive.alai.no/api/correspondents/
{ "name": "org-", "match": "", "matching_algorithm": 0, "is_insensitive": false }

The returned correspondent.id is stored in Bilko DB table org_paperless_cache:

CREATE TABLE org_paperless_cache (
    organization_id UUID PRIMARY KEY REFERENCES organizations(id),
    paperless_correspondent_id INTEGER NOT NULL,
    paperless_org_tag_id INTEGER NOT NULL,
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

On subsequent archives for the same org, the worker reads from org_paperless_cache (HTTP GET to bilko-api internal endpoint GET /internal/v1/paperless-cache/{organizationId}). Cache miss triggers correspondent + tag creation and cache write. This avoids one Paperless API round-trip per document after first archive.

The human-readable org name (organizationName from the .meta.json) is NOT used as the Paperless correspondent name — org- is canonical to prevent name collisions and to survive org renames in Bilko.

5.2 Tag strategy

Every archived document receives exactly these tags:

TagPurposeCreated by
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
org:Tenant isolation — one tag per Bilko orgWorker on first archive for org
doc-type:invoice (or contract, care-plan, incident-report, onboarding)Document type filterWorker — static set, pre-created in Paperless during initial setup
bilko-sourceIdentifies all documents archived from Bilko (across all orgs)Pre-created in Paperless during initial setup
bilko-source-uuid:Idempotency dedup key — prevents duplicate Paperless documentsWorker — unique per document

The worker stores paperless_org_tag_id in org_paperless_cache alongside paperless_correspondent_id. Document-type tag IDs and the bilko-source tag ID are stored in worker environment config (PAPERLESS_TAG_IDS_MAP env var as JSON: {"invoice": 12, "contract": 13, ...}). These are set once during initial Paperless setup and do not change.

5.3 Tenant search in Paperless

To retrieve all documents for an org:

GET https://archive.alai.no/api/documents/?tags__id__in=&page=1&page_size=25

For filtering by doc type within an org:

GET https://archive.alai.no/api/documents/?tags__id__in=,

This is the canonical Paperless query pattern. Cross-tenant queries are impossible if the caller only has access to their own org_tag_id. (Note: Paperless does not natively enforce per-tag ACLs — isolation is enforced by the Bilko application layer controlling which org_tag_id each user can query.)

---

6. Retention Policy

6.1 R2 staging bucket (bilko-archive-queue)

Rationale (ADR-022 §Open Questions, CEO decision D2): Paperless is source of truth post-archival. R2 is a queue, not a backup. .meta.json on each failure. Object will be retried on next cron invocation. bucket and sends alert (Slack or email to dev@alai.no, the existing alert address per BUILD-BLUEPRINT line 302). Object in DLQ retained for 7 days then auto-deleted via R2 lifecycle rule. trigger alert (Cloud Monitoring metric → alert policy). This catches worker failures that leave objects stranded without incrementing retry count.

6.2 Paperless retention

TBD — pending legal/compliance review by Dr. Sarah Chen (S3, healthcare compliance). Interim recommendations based on applicable law:

Document TypeRecommended RetentionLegal Basis
-----------------------------------------------------------------------------------------------------------------------------
Invoices7 yearsNorway Bokføringsloven §13; Serbia Zakon o računovodstvu; BiH equivalent
ContractsIndefinite until expiry + 5 yearsStandard contract law (Norway, Serbia, BiH, Croatia)
Care plans25 yearsNHS/CQC standard (applicable if Bilko expands to UK healthcare)
Incident reports7 yearsGeneral audit retention standard
Onboarding documents5 years post-customer-offboardingGDPR Art. 5(1)(e) storage limitation

Paperless retention enforcement is OUT OF SCOPE for this implementation phase. Configure via Paperless Workflow rules in a subsequent MC (FlowForge + Dr. Sarah Chen).

---

7. Retry Semantics

7.1 Worker retry loop

On each 5-minute cron invocation (per CEO decision D1), the worker:

1. Lists all objects under org/ prefix in bilko-archive-queue (R2 ListObjectsV2 equivalent). 2. For each .meta.json sidecar found (PDF existence implied by paired key): a. Read retryCount. If retryCount >= 3: move to DLQ, skip. b. Fetch corresponding PDF bytes. c. Attempt Paperless dedup check: GET /api/documents/?custom_fields__value= — if document already exists in Paperless (Bilko double-run), skip upload, DELETE R2 object, update Bilko DB (idempotent cleanup). d. Attempt upload. On success: DELETE R2 objects, update Bilko DB audit log. e. On failure: increment retryCount in .meta.json, write updated .meta.json back to R2, log error. Leave PDF object in place.

7.2 Idempotency

R2 object key = org///.pdf. If Bilko backend calls ArchiveService.archive() twice for the same document (e.g. invoice regenerated), R2 write is idempotent (same key, same bytes). Worker sees one object, uploads once.

Paperless dedup via bilko-source-uuid: tag: if worker runs twice before completing a DELETE (e.g. crash between upload and delete), the second run finds the tag already present in Paperless and skips re-upload. Only deletes R2 + updates Bilko DB.

7.3 DLQ and alerting

Object moved to bilko-archive-dlq after 3 failures. Alert fires to dev@alai.no (Cloud Monitoring alert via existing TF_VAR_alert_email in BUILD-BLUEPRINT line 302). DLQ objects require manual triage — either re-queue by moving back to bilko-archive-queue (resets retryCount) or manually upload to Paperless and delete from DLQ.

---

8. Error Handling

8.1 Bilko backend — R2 write failure

ArchiveService.archive() throws ArchiveWriteException. Caller (e.g. InvoiceService.generatePDF()) catches and:

Per ADR-022 §Consequences (Positive #1): R2 write failure is a degraded but non-blocking UX state. The invoice PDF is already saved to the main Bilko R2 bucket. Only archival is deferred.

8.2 Worker — Paperless API errors

ErrorAction
------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
401 UnauthorizedToken expired/rotated. Alert dev@alai.no immediately. Worker stops processing (do not retry — all subsequent calls will also 401). Manual token rotation required.
403 ForbiddenCF Access token issue. Same action as 401.
429 Rate LimitedExponential backoff within single cron run: wait 2s, 4s, 8s (cap at 30s). If still failing after 3 attempts, leave object in R2 for next cron.
500/502/503Retry up to 3 times within cron run with exponential backoff (2s, 4s, 8s). If all fail, increment retryCount in .meta.json, leave for next cron.
Network timeoutSame as 5xx. Worker fetch() timeout = 30 seconds per request.

8.3 Worker — Cloud Run job retry policy

Cloud Scheduler retry policy: max 3 retries with 30s backoff on Cloud Run job invocation failure (distinction: this is job-launch failure, not Paperless upload failure — separate from §7 object-level retries). If the job crashes mid-run, objects remain in R2 and are processed on next cron invocation.

8.4 Worker — structured logging

All worker log lines emit JSON to stdout (Cloud Run log aggregation reads stdout). Required fields:

{
  "severity": "INFO|WARNING|ERROR",
  "timestamp": "",
  "r2Key": "

---

9. Observability

9.1 Worker metrics (Cloud Monitoring custom metrics or stdout-JSON)

MetricTypeDescription
--------------------------------------------------------------------------------------------------------------------------
archive_jobs_processed_totalCounterTotal R2 objects successfully uploaded to Paperless
archive_jobs_failed_totalCounterTotal R2 objects that failed upload (all retry attempts)
archive_queue_depthGaugeCount of objects currently in bilko-archive-queue (R2 ListObjectsV2 at job start)
archive_e2e_latency_secondsHistogramTime from R2 object timestamp in .meta.json to confirmed Paperless upload
archive_dlq_depthGaugeCount of objects in bilko-archive-dlq (alert if > 0)

Metrics emitted as structured log lines (Cloud Run → Cloud Logging → Log-based metrics) OR via Cloud Monitoring custom metric API from worker. Recommendation: log-based metrics (simpler, no extra SDK dependency in worker). Cloud Monitoring log-based metric filter on action field.

9.2 Alert policies

ConditionSeverityChannel
-------------------------------------------------------------------------------------------------------
archive_dlq_depth > 0P1dev@alai.no (existing Cloud Monitoring alert email)
archive_queue_depth > 500 for 15 minutesP2dev@alai.no — worker may have stopped
Worker job not invoked in >10 minutesP2Cloud Scheduler missed execution alert
archive_jobs_failed_total > 5 in 1 hourP2dev@alai.no
Paperless 401 in worker logsP1dev@alai.no — token rotation required

9.3 Bilko DB audit log

Every document that passes through ArchiveService.archive() gets a row in archive_audit_log:

CREATE TABLE archive_audit_log (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    organization_id UUID NOT NULL REFERENCES organizations(id),
    bilko_document_id UUID NOT NULL,     -- FK to invoices.id, contracts.id, etc.
    document_type VARCHAR(50) NOT NULL,
    r2_object_key TEXT NOT NULL,
    sha256 TEXT NOT NULL,
    archive_status VARCHAR(20) NOT NULL DEFAULT 'pending',  -- pending | archived | failed
    paperless_doc_id INTEGER,
    paperless_doc_url TEXT,
    archived_at TIMESTAMPTZ,
    retry_count INTEGER NOT NULL DEFAULT 0,
    last_error TEXT,
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_archive_audit_log_org ON archive_audit_log(organization_id);
CREATE INDEX idx_archive_audit_log_doc ON archive_audit_log(bilko_document_id);
CREATE INDEX idx_archive_audit_log_status ON archive_audit_log(archive_status) WHERE archive_status != 'archived';

The worker updates this table via PATCH /internal/v1/archive-audit/{bilkoDocumentId} on the bilko-api service (authenticated internal call). The bilko-api internal endpoint is protected by a shared secret (INTERNAL_API_KEY) stored in Secret Manager, injected into both bilko-api Cloud Run service and archiver-worker Cloud Run job at deploy time.

---

10. Open Questions for Next Phase

1. Worker language — Kotlin vs Node.js: ADR-022 §Phase 2 lists "Kotlin/Ktor or Node.js, TBD." Recommendation: Node.js, reusing paperless-upload.js logic (inlined into apps/archiver-worker/src/paperlessClient.js). Rationale: (a) faster to ship — Node worker requires zero Gradle/JVM setup, shorter Docker image, simpler Cloud Run job config; (b) the heaviest logic (multipart Paperless upload) already exists in Node (MC #100004); (c) Kotlin adds value for domain-heavy Bilko services, not for a thin queue-poller. Downside: two runtimes in the Bilko repo (Kotlin + Node). Acceptable given worker is a standalone job in apps/archiver-worker/, isolated from apps/api/. **CodeCraft must confirm this choice before implementation starts.**

2. Backfill for existing Bilko documents not yet archived: Out of scope for first ship. All pre-existing invoices, contracts in Bilko DB are unarchived. A backfill worker (one-shot Cloud Run job, reads Bilko DB → writes to R2 queue → worker picks up) is a natural Phase 2 task. Create child MC when this phase ships.

3. DR — Paperless VM outage >24h: Worker retries indefinitely (R2 objects accumulate). At 24h queue backlog (estimated ~2,880 cron invocations), archive_queue_depth > 500 alert fires to ops. Worker will self-heal on Paperless recovery without intervention. If VM is permanently lost: restore Paperless from Azure VM backup (existing backup schedule assumed — verify with FlowForge). R2 queue is the authoritative backlog; no documents are lost.

4. GDPR Art. 17 erasure flow: When a Bilko org deletes their account, all archived documents must be deleted from Paperless (DELETE /api/documents/{id}) and the correspondent deleted (DELETE /api/correspondents/{id}). This is a separate erasure worker, out of scope for this implementation phase. File child MC at same time as backfill MC (Phase 2).

5. Bilko internal API endpoint auth (/internal/v1/): The worker-to-api callback for updating archive_audit_log requires an internal auth mechanism. Shared secret (INTERNAL_API_KEY) is recommended for MVP. mTLS (Cloud Run service-to-service auth via OIDC token) is more secure and already supported by GCP — recommend upgrading to mTLS in Phase 2. Child MC for FlowForge.

---

References

COMPLIANCE-022 — Archive Review (HIPAA/GDPR/CQC)

MC #100025 | Published 2026-05-08 | Status: Approved (Pattern 3 — Skybound) / Compliance gate pending (Dr. Sarah Chen M3+M5 blockers)
Related: ADR-022SPEC-022
⚠️ PRE-EMPTIVE BLOCKERS — Pattern 3 cannot ship to production with EU personal data until: See section 9 for full MUST list.

COMPLIANCE-022: Healthcare & Privacy Compliance Review

Bilko Document Archive — Pattern 3 (Blob Queue) ADR-022 / SPEC-022

Reviewer: Dr. Sarah Chen, Healthcare IT Systems Architect Date: 2026-05-08 MC: #100025 Subtask 3 of 5 Status: Final — sign-off conditions in §10

---

1. Scope — Applicable Regulations

Jurisdiction and context

Bilko is a Balkan accounting SaaS (Serbia, BiH, Croatia), EU residency claimed (GCP europe-north1), operated by ALAI Holding AS (Norway). The doc types named in ADR-022 §Context include care plans and incident reports. Those two types trigger healthcare regulatory scope even in an accounting product.

Regulations evaluated

RegulationTriggerApplies?
-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
GDPR / EU GDPR (Regulation 2016/679)EU residency, Balkan clients in EU data space, special category Art. 9 data possible in care plansYES — primary
HITECH Act (US)Only if Bilko serves US-based covered entities or their BAs. No US presence confirmed in BUILD-BLUEPRINT.NOT YET — but architecture must not preclude compliance if US expansion occurs
HIPAA Privacy + Security RulesSame trigger as HITECH.NOT YET — apply when US expansion scoped
CQC / Health and Social Care Act 2008Only if Bilko serves UK-registered domiciliary care agencies. Not confirmed.NOT YET — same comment
NIS2 Directive (EU 2022/2555)ALAI Holding AS as digital infrastructure provider processing health data above medium-enterprise threshold. Likely not in scope at current scale but architecture must support NIS2-compliant incident response by design.MONITOR — review at 50+ orgs
Norway Bokføringsloven §13Invoices, financial records, 7-year retentionYES — invoices
Serbia Zakon o računovodstvu / Croatia equivalentsSame financial retentionYES — domain packages
GDPR Art. 17 (Right to Erasure)Active for all EU data subjectsYES — open gap in SPEC-022 §10.4
GDPR Art. 28 (Sub-processor chain)ALAI Azure VM Paperless is a sub-processor of BilkoYES — gap in both documents

For care plans and incident reports: GDPR Art. 6(1)(b) (contract performance) as primary basis; Art. 9(2)(h) (health/social care purposes) as special-category basis. This must be reflected in Bilko's Privacy Notice and any DPA issued to tenants.

---

2. Data Classification

Document TypeGDPR ClassificationSpecial Category (Art. 9)?Financial Record?Recommended Paperless Tag
----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
InvoicePersonal data (contact name, address, VAT ID)NoYes (Bokføringsloven, 7y)data-class:financial
ContractPersonal data (signatories, company data)NoQuasi-financial (5y post-expiry)data-class:legal
Care planSpecial category health dataYES — diagnosis, medication, functional statusNodata-class:health sensitivity:high
Incident reportSpecial category health/social dataYES — if describes injury, clinical eventPotentiallydata-class:health sensitivity:high
Onboarding documentPersonal data (identity verification, scanned ID)No (unless medical screen)Nodata-class:identity

Tag strategy amendment

SPEC-022 §5.2 defines four tag types: org:, doc-type:*, bilko-source, bilko-source-uuid:.

Missing: data classification tags. The PAPERLESS_TAG_IDS_MAP env var must include entries for data-class:health, data-class:financial, data-class:legal, data-class:identity, and sensitivity:high. These are required for:

---

3. Audit Trail Requirements

3.1 What SPEC-022 §9.3 provides

archive_audit_log records: who queued the archive (implicit — ArchiveService called in user request context), R2 object key, sha256, status transitions, timestamps, Paperless doc ID, retry count, errors.

This covers the archival pipeline itself adequately.

3.2 Critical gap — per-access logging for archived documents

SPEC-022 contains no provision for logging human access to archived documents in Paperless.

When a CEO-level user or ALAI admin opens a care plan or incident report in the Paperless UI at archive.alai.no, there is no audit record in any Bilko system.

GDPR Art. 5(1)(f) (integrity and confidentiality) and, when US healthcare clients are added, HIPAA §164.312(b) (audit controls) require that every access to records containing personal or health data is logged with:

Paperless-ngx does not natively emit per-document access logs to an external SIEM. It maintains an internal Django audit trail (auditlog tables), but that trail lives on the Azure VM and is not exported to GCP Cloud Logging where Bilko's other audit records live.

Gap: no tamper-evident export of Paperless access logs.

3.3 Retention of access logs

GDPR Article 5 + Recital 39 require demonstrability — logs must be retained long enough to respond to a subject access request or supervisory authority inquiry. Minimum: same retention as the documents they describe. For care plans (25 years per SPEC-022 §6.2), access logs must survive 25 years. For invoices, 7 years.

SPEC-022 §6.2 is silent on access log retention.

3.4 Tamper evidence

The archive_audit_log table in Bilko DB is defined in SPEC-022 §9.3. It has no tamper-evidence mechanism (no hash chaining, no write-once constraint beyond application code). PostgreSQL row-level updates are possible for any user with DB access.

Minimum required: ensure archive_audit_log has no application-level UPDATE path for created_at and core fields (sha256, organization_id, bilko_document_id). A DB-level check constraint or trigger preventing modification of those columns after insert provides tamper-resistance without requiring a separate append-only log infrastructure.

---

4. Access Control Deltas

4.1 Worker process — least privilege (SPEC-022 §4)

SPEC-022 §4.2 recommends a separate R2 API token for the worker scoped to the two archive buckets. SPEC-022 §4.3 recommends a separate Paperless API token. Both are correct. No gap here from an access control standpoint.

4.2 Human admin access to Paperless — unaddressed

Neither ADR-022 nor SPEC-022 defines who may log into archive.alai.no as a human user and what they can access. Currently, the only documented credential is alembasic (Bitwarden item referenced in ADR-022 §Context). That is a single superuser account.

For multi-tenant data containing health records:

Required additions:

with access scoped to bilko-source tagged documents only, no sensitivity:high filter bypass. doc types are live. A named admin account with restricted permissions per document type is required. UI relies entirely on the discipline of human users to filter by org: tag. This is not adequate for healthcare data.

4.3 Cross-tenant containment — tag-based vs. physical separation

SPEC-022 §5.3 states: "Cross-tenant queries are impossible if the caller only has access to their own org_tag_id. Isolation is enforced by the Bilko application layer controlling which org_tag_id each user can query."

This is correct for machine-to-machine access (worker reads, API queries). It is **not sufficient for human access** to the Paperless UI, where all documents from all tenants are visible to any logged-in user. Until Paperless supports per-tag or per-user-group ACLs (which it does not as of v2.x), physical separation — one Paperless instance per tenant — is the only way to enforce tenant isolation for human UI access.

**Recommendation (SHOULD — not an immediate ship blocker provided care plans are not in scope for MVP):** Before enabling care plan or incident report archival through this pipeline, deploy per-tenant Paperless instances or ensure the Paperless UI is not accessible to any human user other than a designated compliance officer who has executed an appropriate access agreement and whose access is logged separately.

4.4 Break-glass access procedure

Neither document defines a break-glass procedure: how does ALAI access a specific tenant's archived documents if the Bilko DB org_paperless_cache is corrupted or unavailable?

Required: Document and test a break-glass procedure: (a) query Paperless directly by org: tag using the bilko-ops service account, (b) log the access reason and approver, (c) notify the affected tenant within 72 hours if the access was to health data.

---

5. Sub-Processor Analysis

5.1 The data flow chain

Bilko tenant (data subject's data)
  → Bilko Cloud Run API (controller / data processor acting on behalf of tenant)
    → Cloudflare R2 (sub-processor #1 — staging queue)
      → archiver-worker Cloud Run (internal processor — ALAI infrastructure)
        → ALAI Azure VM / Paperless-ngx (sub-processor #2 — long-term storage)

5.2 Gap: no GDPR Art. 28 chain documented

GDPR Art. 28(4) requires that where a processor engages a sub-processor, the same data protection obligations as set out in the controller-processor contract are imposed on the sub-processor.

ADR-022 notes "Paperless-ngx at archive.alai.no = ALAI Azure VM (separate org from Bilko tenants). Cross-org data flow = sub-processor relationship; needs DPA articulation" — and then defers to this review. SPEC-022 does not address it at all.

Minimum required DPA chain articulation:

1. Bilko's Terms of Service / DPA with each tenant must list: - Cloudflare (R2) as a sub-processor - ALAI Holding AS hosting (Azure VM, Paperless) as a sub-processor

2. The existing ALAI AI Services Legal Pack (BookStack shelf https://docs.alai.no/shelves/ai-services-legal-pack, TOMs published) provides a DPA template. That template must be extended with a Schedule listing sub-processors and their processing purposes. For the archive pipeline: Purpose = "Long-term document retention for audit and compliance purposes"; Location = EU (Azure westeurope); Retention per §6.2 of SPEC-022.

3. ALAI must have a DPA with Microsoft Azure (for the VM hosting Paperless). Standard Microsoft Online Services DPA covers this if the Azure subscription is enrolled — verify this is in place.

4. Bilko tenants uploading care plans or incident reports must be explicitly informed (Privacy Notice update) that health data is stored in Paperless on an ALAI-operated EU server.

5.3 Cloudflare R2 sub-processor status

Cloudflare R2 is covered by Cloudflare's standard Data Processing Addendum. ALAI should confirm it is signed as part of the Cloudflare account setup. The R2 bucket must be configured to a confirmed EU jurisdiction (Cloudflare R2 location hint WEUR or EEUR).

---

6. Encryption Requirements

6.1 At rest — R2 (staging queue)

Cloudflare R2 provides AES-256 encryption at rest by default for all objects. No customer-managed key option was selected per SPEC-022 §4. For current Bilko document types (invoices, contracts), platform-managed encryption is adequate. For care plans and incident reports (special category health data), consider whether tenant-controlled encryption keys are a contractual requirement with any healthcare clients before that doc type goes live.

6.2 At rest — Paperless on Azure VM

SPEC-022 does not confirm disk encryption on the Azure VM hosting Paperless. Azure VM OS disks are not encrypted by default — Azure Disk Encryption (ADE using BitLocker/DM-Crypt) or server-side encryption with customer-managed keys must be explicitly enabled. This must be verified by FlowForge before any healthcare document type is archived.

Required (MUST): Confirm Azure VM hosting Paperless has disk encryption enabled. Run az vm encryption show --name --resource-group and include output in the ship checklist evidence.

6.3 In transit — GCP Cloud Run to R2

Cloudflare R2 S3-compatible API enforces TLS 1.2+ on all endpoints. Confirmed adequate.

6.4 In transit — archiver-worker to Paperless (archive.alai.no)

ADR-022 §Context: Paperless is "behind Cloudflare Access (service token required)". Cloudflare Access enforces HTTPS on all traffic to the origin. The origin-to-Cloudflare tunnel should use Cloudflare Tunnel (cloudflared) or an authenticated origin pull — confirm this is configured so the Azure VM does not expose port 443 directly to the internet.

If the Azure VM is exposed directly (no cloudflared), a misconfigured security group could allow direct HTTP access bypassing CF Access entirely. FlowForge must confirm the network path.

6.5 Field-level encryption

Field-level encryption of PDF content is not feasible within this architecture and is not required at this stage. The PDF is the record. Encryption at transport and at rest is the appropriate control. If any structured extracted fields from care plans are ever stored in Bilko DB as queryable columns, those columns must be treated as special category data and assessed for column-level encryption.

---

7. Erasure / Right to Be Forgotten (GDPR Art. 17)

7.1 Current state

SPEC-022 §10.4 acknowledges erasure as an open question: "a separate erasure worker, out of scope for this implementation phase."

ADR-022 §Phase 4 (Q3) provides a three-step Paperless erasure process (query by org: tag, delete documents, delete correspondent). This is architecturally sound.

7.2 Interim recommendation (required before care plans go live, SHOULD before MVP)

GDPR Art. 17(1) requires that erasure be executed "without undue delay." For a SaaS with a documented erasure process, "without undue delay" means the capability must exist and be operable when a valid erasure request arrives — it does not require automatic self-service.

For MVP (invoices, contracts, onboarding — not health data): an operationally-documented manual erasure procedure is acceptable interim. The procedure must be documented in the RUNBOOK.md and tested before any EU data subject data reaches production.

Manual erasure procedure (document in RUNBOOK.md before MVP ship):

1. Receive verified erasure request (tenant admin or data subject via support ticket). 2. Confirm no legal hold applies (Bokføringsloven 7-year financial record exception — invoices cannot be erased within retention period under legitimate interest override). 3. Delete Bilko DB records for the org (existing DB delete cascade paths — confirm with CodeCraft). 4. Query R2 for any pending queue objects: aws s3 ls s3://bilko-archive-queue/org// and delete all. 5. Query Paperless: GET /api/documents/?tags__id__in=, delete all results. 6. Delete Paperless correspondent: DELETE /api/correspondents/. 7. Delete org_paperless_cache row for the org. 8. Log erasure completion with timestamp and executor identity.

Before care plans or incident reports are archived: an automated erasure worker is required (child MC, FlowForge). Manual erasure for health records under Art. 17 is too slow and too error-prone.

7.3 Financial record exception

Invoices subject to Bokføringsloven §13 (Norway) or equivalent (Serbia, Croatia, BiH) cannot be erased within the mandatory retention period even on Art. 17 request. The Privacy Notice must inform data subjects of this limitation. The erasure procedure must check document type and skip financial records with a logged exception.

---

8. Incident Response / Breach Notification

8.1 Breach scenarios

ScenarioSeverityGDPR notificationResponsible party
-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
Bilko Cloud Run API compromise (R2 staging queue exposed)HIGH if health data in queue72h to supervisory authority (Datatilsynet, Norway; or relevant Balkan DPA)ALAI (as Bilko operator)
Azure VM compromise (Paperless data exposed)HIGH72h — triggers sub-processor notification chain: ALAI Azure → ALAI Bilko team → tenant notificationALAI (as sub-processor); tenant notifies their data subjects
Worker credential leak (CF Access + Paperless API token)MEDIUM-HIGH (allows read of all archived docs across all tenants)72h if PHI/health data accessibleALAI
Cross-tenant Paperless UI access (human error)MEDIUM72h if health data accessedALAI

8.2 Notification chain (required in RUNBOOK.md)

Neither ADR-022 nor SPEC-022 defines a breach notification chain. The following must be documented:

1. Detection: Cloud Monitoring alert (unauthorised 401/403 spike, DLQ depth spike, anomalous ListObjectsV2 calls from unexpected IP) fires to dev@alai.no. 2. Triage: Within 1 hour — ALAI ops determines whether PHI/PII was exposed. 3. Internal declaration: ALAI Compliance (Alem Basic as DPO for current scale) declares breach. 4. Supervisory authority notification: Within 72 hours of awareness — notify Datatilsynet (Norway) via https://www.datatilsynet.no/en/about-privacy/notification-of-a-data-breach/. If Serbian or Croatian data subjects affected: notify relevant authority (POVP, Serbia; AZOP, Croatia) simultaneously. 5. Tenant notification: Within 72 hours — notify affected tenant(s) via documented contact (tenant owner email on record in Bilko DB). 6. Data subject notification: If "likely to result in a high risk to rights and freedoms" (Art. 34), notify data subjects directly. Care plan or incident report exposure = high risk threshold met automatically.

---

MUST — compliance blockers (must fix before production ship)

IDDocumentSectionRequired change
-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
M1SPEC-022§5.2Add data-class:health, data-class:financial, data-class:legal, data-class:identity, and sensitivity:high to PAPERLESS_TAG_IDS_MAP. Worker must apply data-class and (for care plans / incident reports) sensitivity:high tag on every archive call.
M2SPEC-022§9.3Add DB-level protection on archive_audit_log: a Postgres trigger or check constraint must prevent UPDATE of organization_id, sha256, bilko_document_id, and created_at after row insert. Append-only semantics enforced at DB layer, not only application layer.
M3ADR-022 + SPEC-022§4 / §ContextDocument and verify Azure VM disk encryption is enabled before care plans or incident reports are archived. Add to ship checklist: az vm encryption show output as evidence.
M4SPEC-022§10.4Document manual erasure procedure in RUNBOOK.md (see §7.2 of this review) before MVP ship. Must include: financial record exception logic, Paperless deletion steps, audit log of erasure.
M5ADR-022§ConsequencesUpdate Bilko Terms of Service / Privacy Notice and sub-processor DPA to list Cloudflare R2 and ALAI Azure VM (Paperless) as sub-processors per GDPR Art. 28(4). This must exist before any EU personal data flows through the archive pipeline. Must reference ALAI AI Services Legal Pack DPA template on BookStack.
M6SPEC-022§4 / §9Paperless access log export: configure Paperless Django audit log export (or Cloudflare Access request logging for archive.alai.no) to ship access events to Cloud Logging. Access log entries must contain: user/service account identity, document ID, document type, timestamp, source IP. Retain per document class retention period.

SHOULD — best practice (not immediate ship blockers)

IDDocumentSectionRecommended change
---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
S1SPEC-022§5.3Before enabling care plan or incident report doc types, assess whether tag-based isolation in the shared Paperless instance is sufficient or whether a dedicated per-healthcare-tenant Paperless instance is required. Tag isolation is adequate for machine queries but not for human Paperless UI access.
S2SPEC-022§4.3Replace INTERNAL_API_KEY shared secret for worker-to-api callback with GCP Cloud Run service-to-service OIDC auth (already in SPEC-022 §10.5 as Phase 2 item). Shared secret is a credential management risk. This is already flagged; confirm it is a Phase 2 child MC, not indefinitely deferred.
S3ADR-022§Phase 4Create child MC for automated erasure worker before enabling care plan archival. Manual erasure is not appropriate for health data under GDPR Art. 17.
S4SPEC-022§6.2Add care plan retention to 25 years in Paperless Workflow rule (SPEC-022 already notes this as out of scope). File the child MC before health doc types go live. 25-year retention is a CQC/NHS standard; for Balkan jurisdiction equivalents, confirm with local counsel (no equivalent statutory period confirmed for Serbia/BiH/Croatia).
S5SPEC-022§10Add Breach Notification Runbook to RUNBOOK.md (§8.2 of this review) as child MC. Required before any production data flows through the pipeline.
S6ADR-022§ContextVerify Cloudflare R2 bucket bilko-archive-queue location hint is set to WEUR or EEUR to maintain EU data residency. Not confirmed in either document.

---

10. Sign-Off Conditions

The following must be true before Pattern 3 ships to production. Each item maps to a MUST above.

1. [M5] Sub-processor DPA chain published. Bilko ToS / Privacy Notice lists Cloudflare R2 and ALAI Azure VM as sub-processors. Bilko tenant DPA template updated. Evidence: BookStack page with published DPA addendum (reference ALAI AI Services Legal Pack shelf).

2. [M1] Data classification tags deployed in Paperless. data-class:* and sensitivity:high tags exist in Paperless, IDs populated in PAPERLESS_TAG_IDS_MAP, worker applies them. Evidence: Proveo test showing a care plan doc archived with data-class:health + sensitivity:high tags visible in Paperless.

3. [M3] Azure VM disk encryption verified. FlowForge provides az vm encryption show output confirming encryption enabled on the VM hosting Paperless. Evidence: output attached to ship checklist.

4. [M2] Archive audit log tamper-protection deployed. Flyway migration adds DB-level constraint on archive_audit_log. Evidence: Proveo attempts direct SQL UPDATE on created_at and sha256 columns and confirms rejection.

5. [M6] Paperless access log export live. Cloudflare Access request logs for archive.alai.no (or Paperless Django auditlog export) flowing to Cloud Logging. Evidence: Cloud Logging query showing access log entries from a test document retrieval.

6. [M4] RUNBOOK.md updated with manual erasure procedure. Procedure includes financial record exception, Paperless deletion steps, confirmation of org_paperless_cache cleanup. Evidence: Proveo executes erasure procedure end-to-end in staging and documents result.

Pre-emption clause: Items M3 (Azure disk encryption) and M5 (DPA chain) are pre-emptive — they must be resolved before any personal data of any kind is archived in Paperless production. They are not "ship before care plans go live" items; they are "ship before any data flows" items. If either is unresolved at production launch, the pipeline must be restricted to internal test data only via a feature flag.

---

_Reviewed against: ADR-022 (all sections), SPEC-022 (all sections §1–§10), BUILD-BLUEPRINT.md (multi-tenancy model, GCP deployment, R2 config). GDPR 2016/679 Arts. 5, 6, 9, 17, 28, 34; HIPAA §164.312 (noted for future US expansion); CQC Key Lines of Enquiry Safe domain (noted for future UK healthcare expansion); NIS2 Directive 2022/2555 (monitor threshold)._

HR eRačun — Architecture Decision Record (ADR) + Build Plan

STATUS: design accepted; build in progress (WP1+). Production activation PARKED pending legal (B1/B2) + the multi-tenant decision.

NOTE: app-api.bilko.cloud maps to bilko-api-demo — the demo backend serves the bilko.cloud domain; activation is a real-domain decision, not a code toggle.

Status: ACCEPTED
Date: 2026-06-11
Lead Architect: Petter Graff (synthesized from team inputs)
Input Authors: Martin Kleppmann, Bruce Momjian, Markos Zachariadis, Parisa Tabriz
MC: #103453 (architecture documentation) | #103464 (build execution)
Cross-link: Bilko HR eRačun — sveRačun (PostLink) Integration & Status Model
CEO directive: "tim arhitekata, Petter Graff lead, plan → dokumentuju → build, BEZ HAKOVA."


1. Context and Problem

1.1 What Exists

Bilko has a Croatia HR eRačun adapter (SveRacunHrEInvoiceAdapter) with three implemented methods — serialize(), submit(), pollStatus() — and 42 unit tests. A Proveo-verified live TEST submission to the sveRačun (PostLink d.o.o.) TEST API returns HTTP 200 with a documentId. The status mapping (mapStatusPair) correctly implements the real sveRačun two-layer status model (corrected MC #103445).

1.2 The Three Structural Problems

Problem 1 — The wiring gap (critical).
No route, no service, and no persistence layer connects the product UI/API to the adapter. POST /invoices/{id}/submit-to-sef exists for Serbia (RS); HR has no equivalent. PluginHR.submitToFiscalPlatform is NOT_IMPLEMENTED by design (it uses FiscalReceipt, not CanonicalInvoice). An operator cannot submit an HR invoice through Bilko today. This is the primary gap this ADR closes.

Problem 2 — Live double-fiscalization bug (critical, exists in the code today).
SveRacunHttpClient installs HttpRequestRetry globally with retryOnServerErrors(maxRetries = 3). This plugin fires on all 5xx responses — including those returned after sveRačun has already accepted and queued the document (transient 500 on the response-write path). A retry would POST the same UBL XML with the same invoice number to sveRačun a second time. In the Croatian fiscal model, that is a second fiscalization of the same invoice number — a criminal tax offence (Kazneni zakon, čl. 256). This bug exists in the current codebase and must be fixed before any live call, including TEST calls.

Problem 3 — The single-issuer ceiling (architectural).
serialize() reads the XML sender OIB from httpClient.configuredSenderVat, which maps to the global env var SVERACUN_SENDER_VAT. sveRačun's etapa-1 rule requires that the initiator OIB (the API-key-holder) equal the XML sender OIB. With a single global env var, Bilko can only ever submit invoices as one legal entity. A multi-tenant SaaS requires each tenant to issue under their own OIB. The CEO decision parks multi-tenant for production, but the architecture must not hardcode assumptions that prevent it.

1.3 Compliance Blockers from Vlado Brkanić Memo (MC #103443)


2. Decisions

2.1 The IssuerProfile Abstraction (Zachariadis Model C)

Decision: Introduce IssuerProfile as the single abstraction for "who is the legal sender and what credentials does the system use." The adapter and service NEVER read global env vars for sender identity after this change. The demo is built on this abstraction with a single ALAI profile.

Rationale: The PostLink companyVatNumber header is already architecturally separate from the Authorization API key header. The IssuerProfile abstraction now means the production multi-tenant path is a credential-config change, not a code rewrite.

data class IssuerProfile(
    val profileId: UUID,
    val orgId: UUID,                     // FK to organizations.id
    val legalSenderOib: String,          // HR-prefixed, e.g. HR91276104352
    val legalSenderName: String,
    val submissionMode: SubmissionMode,  // DIRECT | INTERMEDIARY
    val apiKeySecretRef: String,         // GCP Secret Manager path — NEVER the raw key
    val sveRacunBaseUrl: String,         // TEST or PROD endpoint
    val intermediaryOib: String? = null,
    val posrednikRef: String? = null,
    val enabled: Boolean
)

enum class SubmissionMode { DIRECT, INTERMEDIARY }

For demo: one row, submissionMode = DIRECT, legalSenderOib = HR91276104352, apiKeySecretRef = "projects/.../secrets/bilko-sveracun-test-api-key/versions/latest".
For production: per-tenant rows, submissionMode = INTERMEDIARY, shared platform key, per-tenant legalSenderOib.

2.2 Adapter Refactor: IssuerProfile Injection Over Env Vars

Decision: SveRacunHrEInvoiceAdapter.serialize() receives senderOib: String explicitly. SveRacunHttpClient is instantiated with per-profile apiKeyOverride and senderVatOverride. The global-env-var constructor path is preserved for tests only; the production code path always resolves via IssuerProfile.

2.3 Retry Policy: Split Send-Path from Poll-Path

Decision: SveRacunHttpClient will use TWO separate HttpClient instances: one for the send path with maxRetries = 0, one for the poll path with maxRetries = 3 and exponential backoff.

Rationale: This is the Kleppmann non-negotiable. The current single HttpClient with global retry is a live bug. Splitting into two instances is the cleanest fix without touching retry configuration in a way that could be accidentally reverted.

// Send path — zero retries; double-submit is a tax offence
private val sendClient = HttpClient(sendEngine) {
    install(HttpTimeout) { requestTimeoutMillis = TIMEOUT_MS; connectTimeoutMillis = 10_000L }
    // NO HttpRequestRetry installed
}

// Poll path — safe to retry; reads are idempotent
private val pollClient = HttpClient(pollEngine) {
    install(HttpTimeout) { requestTimeoutMillis = TIMEOUT_MS; connectTimeoutMillis = 10_000L }
    install(HttpRequestRetry) {
        maxRetries = MAX_RETRIES
        retryOnServerErrors(maxRetries = MAX_RETRIES)
        exponentialDelay(base = 2.0, maxDelayMs = 8_000L)
    }
}

2.4 State Machine (Canonical Definition)

The canonical state machine for an HR eRačun submission in Bilko. All service code and all DB column values reference these states and only these states.

Stateinternal_statussveracun_document_idMeaning
NOT_SUBMITTEDNULL (no row)NULLInvoice exists; no submission row
NUMBER_RESERVEDNUMBER_RESERVEDNULLFiscal number locked; XML serialized; GCS written; HTTP not yet called
SUBMITTEDSUBMITTED<docId>HTTP 200 + documentId received and persisted
SUBMIT_UNCERTAINSUBMIT_UNCERTAINNULLSent (maybe); no documentId received (timeout / conn err / no docId in 200 body)
PENDINGPENDING<docId>sveRačun still processing (UNKNOWN or null external)
ACCEPTEDACCEPTED<docId>Terminal success: internal=OK + external=FISCALIZATION:OK
REJECTEDREJECTED<docId> or NULLTerminal failure: FAILED/UNDELIVERABLE/FISCALIZATION:ERROR/4xx etapa-1
NOT_SUBMITTED    -> NUMBER_RESERVED
NUMBER_RESERVED  -> SUBMITTED | SUBMIT_UNCERTAIN | REJECTED
SUBMITTED        -> PENDING | ACCEPTED | REJECTED
PENDING          -> ACCEPTED | REJECTED | PENDING (keep polling)
SUBMIT_UNCERTAIN -> SUBMITTED (reconcile found docId) | REJECTED (confirmed not found)
ACCEPTED         -> (terminal, immutable)
REJECTED         -> (terminal; operator action + new fiscal number required for re-send)

Forbidden transitions:

Note on naming alignment: Momjian uses APPROVED where Kleppmann uses ACCEPTED. The ADR adopts ACCEPTED to align with EU e-invoicing terminology and the DB CHECK constraint in V77. The adapter's EInvoiceStatus.APPROVED is the adapter-interface value; the service layer translates it to the ACCEPTED DB state.

2.5 Persist-Before / Persist-After Protocol (Kleppmann Non-Negotiable #3)

Every submit call follows this exact ordering:

BEFORE the HTTP call — one DB transaction:

  1. SELECT ... FOR UPDATE on the invoice row (concurrent-submit guard)
  2. Check internal_status NOT IN (NUMBER_RESERVED, SUBMITTED, SUBMIT_UNCERTAIN, PENDING) — reject 409 CONFLICT if already in flight
  3. UPSERT hr_einvoice_number_counters and SELECT ... FOR UPDATE to allocate next fiscal number (gapless, Momjian §1)
  4. Compute idempotencyKey = SHA-256(orgId + "|" + invoiceId + "|" + fiscalInvoiceNumber)
  5. Call adapter.serialize(invoice, senderOib = issuerProfile.legalSenderOib) to build UBL XML bytes
  6. Compute sha256Hex = SHA-256(xmlBytes) (hex string)
  7. Write XML bytes to GCS at {orgId}/{fiscalYear}/{fiscalInvoiceNumber}/{submissionId}.xml (write-once; must succeed before row insert)
  8. INSERT hr_einvoice_submissions row with internal_status = NUMBER_RESERVED
  9. COMMIT

HTTP call (outside any transaction):

AFTER the HTTP call — separate DB transaction:

2.6 OIB Binding Invariant (Tabriz Non-Negotiable)

The service layer (HrEInvoiceService.submitInvoice()) MUST enforce this invariant before any HTTP call:

require(invoice.organizationId == principal.organizationId) { "Invoice org mismatch" }
require(issuerProfile.orgId == principal.organizationId) { "Credential org mismatch" }
require(issuerProfile.legalSenderOib == xmlSenderOib) { "OIB binding violated" }

If any assertion fails: HTTP 422, write LoggedAction with event = "hr_einvoice_oib_binding_violation", do NOT proceed. A broken OIB binding causes Bilko to file a fiscalized tax document under the wrong entity's identity with Porezna uprava — that is tax fraud.

2.7 UNIQUE(invoice_id) on hr_einvoice_submissions (Momjian Non-Negotiable)

The UNIQUE (invoice_id) constraint in migration V77 is the architectural load-bearing constraint for this feature. It must be present in the migration before any submission code is merged. Any code path that attempts to create a second active submission row for the same invoice receives a unique constraint violation — the DB-level guard for double-fiscalization even if service-layer checks have a bug.


3. Target Architecture

3.1 Layered View

[HTTP Route]
  POST /invoices/{id}/submit-to-sveracun
  GET  /invoices/{id}/sveracun-status
  GET  /invoices/{id}/sveracun-xml        (admin debug)
  POST /invoices/{id}/poll-sveracun-status (manual poll trigger)
       |
       | JWT principal -> requirePermission("sveracun:submit")
       | organizationId from JWT (never from request body)
       v
[HrEInvoiceService]
  - IssuerProfileRepository.findByOrgId(orgId) -> IssuerProfile
  - OIB binding invariant assertion (hard, not soft)
  - Persist-before-tx (number allocation, XML serialize, GCS write, DB insert)
  - SveRacunHttpClient(apiKeyOverride, senderVatOverride) — per-profile instantiation
  - SveRacunHrEInvoiceAdapter.submit(xmlBytes, invoice, senderOib) — NO retry
  - Persist-after-tx
  - HrEInvoiceNumberService.reserveNextNumber(orgId, issuerOib, fiscalYear)
  - LoggedAction audit write per submit
       |
       v
[SveRacunHrEInvoiceAdapter]  (already implemented; adapter-level changes only)
  - serialize(invoice, senderOib: String)  — senderOib injected, not from env
  - submit(xmlBytes, invoice) -> SubmitResult
  - pollStatus(documentId, invoice) -> EInvoiceStatus
  - mapStatusPair() (unchanged — correct per MC #103445)

[SveRacunHttpClient]  (two client instances after fix)
  - sendClient (NO retry) -> sendDocument()
  - pollClient (retry OK) -> getInternalStatus(), getExternalStatus()

[Postgres — four new tables via Flyway V75-V78]
  hr_einvoice_issuer_config        (IssuerProfile persistence; one row for demo)
  hr_einvoice_number_counters      (gapless fiscal year sequence via FOR UPDATE)
  hr_einvoice_submissions          (submission lifecycle; UNIQUE(invoice_id))
  hr_einvoice_archive              (integrity manifest; INSERT-only; points to GCS)

[GCS — bilko-hr-einvoice-archive-{env}]
  - Write-once per submission at NUMBER_RESERVED (before HTTP call)
  - Integrity verified on retrieve (SHA-256 re-hash comparison)
  - Retention policy: 4015 days LOCKED (11 years WORM) for prod bucket
  - Demo bucket: same write-once pattern; 90-day retention (not locked)

3.2 IssuerProfileRepository Interface

interface IssuerProfileRepository {
    fun findByOrgId(orgId: UUID): IssuerProfile?
}

// Demo implementation: reads from DB table hr_einvoice_issuer_config (V75 migration)
class DbIssuerProfileRepository(
    private val secretManager: GcpSecretManagerClient
) : IssuerProfileRepository {
    override fun findByOrgId(orgId: UUID): IssuerProfile? {
        // SELECT from hr_einvoice_issuer_config WHERE org_id = ? AND enabled = true
        // Resolve apiKey from GCP Secret Manager by api_key_secret_ref
    }
}

For demo: one row in hr_einvoice_issuer_config with enabled = false by default. A manual UPDATE ... SET enabled = true plus SVERACUN_HR_LIVE = true env flip is required to activate live submit. Two explicit gates, both required, neither accidental.

3.3 Route Pattern (Mirrors SefRoutes.kt)

fun Route.sveRacunRoutes() {
    val service by di<HrEInvoiceService>()

    post("/invoices/{id}/submit-to-sveracun") {
        val principal = call.principal<BilkoPrincipal>()!!
        if (requirePermission(principal, "sveracun:submit")) return@post
        val invoiceId = call.parameters["id"] ?: ...
        val organizationId = principal.organizationId   // from JWT, never from request
        try {
            val result = dbQuery { service.submitInvoice(invoiceId, organizationId, principal) }
            call.respond(HttpStatusCode.OK, mapOf(...))
        } catch (e: OibBindingException) { call.respond(422, ...) }
          catch (e: NotFoundException) { call.respond(404, ...) }
          catch (e: ConflictException) { call.respond(409, ...) }
    }

    get("/invoices/{id}/sveracun-status") { /* requirePermission("sveracun:status") */ }
    get("/invoices/{id}/sveracun-xml") { /* admin only; verify SHA-256 before serving */ }
    post("/invoices/{id}/poll-sveracun-status") { /* manual trigger for demo */ }
}

3.4 Persistence Schema — Flyway V75–V78

V75 — hr_einvoice_issuer_config

Per-tenant IssuerProfile persistence. One row for demo (ALAI, DIRECT mode). RLS on org_id. The api_key_secret_ref column stores the GCP Secret Manager resource name — the raw API key is never stored in the DB.

Key columns: org_id UUID NOT NULL, issuer_oib VARCHAR(13) NOT NULL, api_key_secret_ref VARCHAR(1024) NOT NULL, api_base_url VARCHAR(500) NOT NULL DEFAULT 'https://test.sveracun.hr/api', submission_mode VARCHAR(20) NOT NULL DEFAULT 'DIRECT', enabled BOOLEAN NOT NULL DEFAULT FALSE.
Constraint: UNIQUE (org_id, issuer_oib).

V76 — hr_einvoice_number_counters

Gapless fiscal year invoice number counter. One row per (org_id, issuer_oib, fiscal_year). Allocated via SELECT ... FOR UPDATE inside the BEFORE transaction. Never decrements. Numbers are non-returnable even on submission failure.

Key columns: org_id UUID NOT NULL, issuer_oib VARCHAR(13) NOT NULL, fiscal_year SMALLINT NOT NULL, last_number INTEGER NOT NULL DEFAULT 0.
Constraint: UNIQUE (org_id, issuer_oib, fiscal_year). Year rollover: automatic on UPSERT.

V77 — hr_einvoice_submissions

The submission lifecycle table. One row per invoice (UNIQUE invoice_id). Created at number-reservation time. Updated through polling until terminal.

Key columns: org_id UUID NOT NULL, invoice_id UUID NOT NULL (FK invoices.id ON DELETE RESTRICT), fiscal_invoice_number VARCHAR(20) NOT NULL (format YYYY-NNNNNN), idempotency_key VARCHAR(64) NOT NULL, sveracun_document_id VARCHAR(255) NULL, internal_status VARCHAR(30) NOT NULL DEFAULT 'NUMBER_RESERVED', xml_sha256_hex CHAR(64) NOT NULL, submitted_xml_gcs_path VARCHAR(1024) NOT NULL, submitted_by UUID NOT NULL.

Critical constraints:

V78 — hr_einvoice_archive

Integrity manifest for 11-year UBL XML archival. Append-only. bilko_app role has INSERT-only grant (no UPDATE). All FKs are ON DELETE RESTRICT.

Key columns: submission_id UUID NOT NULL (FK, UNIQUE — one archive row per submission), gcs_bucket VARCHAR(255), gcs_object_path VARCHAR(1024), sha256_hex CHAR(64) NOT NULL, retain_until DATE GENERATED ALWAYS AS ((archived_at AT TIME ZONE 'UTC')::DATE + INTERVAL '11 years') STORED.

Archive is written AFTER ACCEPTED state is confirmed (internal=OK + external=FISCALIZATION:OK). The submitted XML written to GCS at NUMBER_RESERVED is the same bytes; the archive row formalizes it as the compliance record.

RLS on all four tables: Standard Bilko pattern from V46/V55. USING (org_id = NULLIF(current_setting('app.current_org_id', true), '')::UUID). FORCE ROW LEVEL SECURITY on all tables. Phase 2C RESTRICTIVE mode activation is a prod prerequisite.

3.5 GCS Archival

Bucket: bilko-hr-einvoice-archive-{env} (e.g. bilko-hr-einvoice-archive-demo).
Object path: {org_id}/{fiscal_year}/{fiscal_invoice_number}/{submission_id}.xml.
Write timing: at NUMBER_RESERVED, before HTTP call. Same bytes sent to sveRačun.
Write-once enforcement: Cloud Run SA has storage.objects.create only; storage.objects.delete denied.
Prod bucket: retention policy 4015 days LOCKED (WORM).
Demo bucket: same write-once pattern; retention 90 days (not locked).

Integrity verification on every retrieval via /invoices/{id}/sveracun-xml: fetch sha256_hex, download GCS bytes, recompute SHA-256, assert equals. If mismatch: HTTP 500 ARCHIVE_INTEGRITY_FAILURE, alert, do not serve bytes.

3.6 Audit Trail

Every submit, poll, and OIB-binding-violation event writes to LoggedAction (existing append-only table). Log structural/operational metadata only — do NOT log invoice line items, buyer/seller names, amounts, tax IDs, IBAN, API key value, or raw XML body (GDPR + Croatian tax secrecy).


4. Demo vs Production Boundary

"No hacks" means the demo is built on the real schema, real idempotency, real OIB binding invariant, and real state machine — with one issuer instead of many. The demo is not a prototype. It is the production system at scale=1.

CapabilityDemo (build now)Prod (parked / future)
IssuerProfile abstractionYES — one ALAI/DIRECT profile in DBSame table; N tenant rows; INTERMEDIARY mode
Schema V75-V78YES — full schema from day oneSame migrations; no change
OIB binding invariantYES — enforced at service layerSame code; more profiles
UNIQUE(invoice_id) on submissionsYES — in V77 before any submit codeSame constraint
Retry-fix on send pathYES — sendClient (no retry)Same fix
Persist-before/after protocolYES — full protocolSame protocol
SUBMIT_UNCERTAIN stateYES — must be representableSame state
GCS write at NUMBER_RESERVEDYES — write-once, SHA-256Same; LOCKED retention policy added
Gapless numbering (FOR UPDATE)YES — counter table V76Same; per-tenant issuer_oib separates sequences
HR einvoice archive row (V78)YES — written on ACCEPTEDSame; 11-year LOCKED policy for prod
sveRačun base URLTEST (test.sveracun.hr)PROD (hr.sveracun.hr)
SVERACUN_HR_LIVE gateExplicit flip required (default false)PROD env flag; separate secret
IssuerProfile.enabled gateExplicit DB update requiredSame; per-tenant enable flow
Background poll workerManual: POST /invoices/{id}/poll-sveracun-statusScheduled job (Cloud Run Job or scheduler)
GCS retention policy90 days (demo bucket; not locked)4015 days LOCKED (WORM)
RLS modePERMISSIVE (current ADR-017 state)RESTRICTIVE (Phase 2C; Securion gate)
PostLink posrednik contract (B2)Not required; DIRECT modeRequired before multi-tenant; legal track
ALAI Norwegian entity HR OIB (B1)Not required; using existing TEST credsLegal confirmation required
Credit note (InvoiceTypeCode 381)Not built; domain model records the typeMust be built for full B2B accounting
Rate limiting (durable)In-memory sliding window; 10/min, 100/day per orgRedis-backed (Cloud Memorystore)

Items NOT Deferred (frequently deferred in prototype builds; not here)

  1. Flyway migrations V75-V78 — schema before any submit code
  2. The UNIQUE (invoice_id) constraint — non-negotiable from the first migration
  3. The retry fix on sendDocument() — before any live call, including TEST
  4. The OIB binding invariant — runtime enforcement, not just a comment
  5. The GCS write at NUMBER_RESERVED — even for demo; write-once pattern identical to prod
  6. The SUBMIT_UNCERTAIN state — sveRačun TEST is not perfectly reliable
  7. LoggedAction audit write per submit

5. Phased Build Plan (7 Work Packages)

WP1 — Foundation: Schema + Retry Fix + OIB Binding + IssuerProfile

Owner: CodeCraft (backend) | Depends on: None

Must land atomically — all in the same PR, before any route code.

  1. Flyway migrations V75, V76, V77, V78 — all four tables with constraints, indexes, RLS, grants
  2. SveRacunHttpClient: split into sendClient (maxRetries=0) + pollClient (maxRetries=3). Existing 42 tests remain green; add test asserting no retry on sendDocument() for 5xx
  3. IssuerProfile data class + SubmissionMode enum
  4. IssuerProfileRepository interface + DbIssuerProfileRepository
  5. SveRacunHrEInvoiceAdapter.serialize(invoice, senderOib: String) — add senderOib param; remove httpClient.configuredSenderVat usage
  6. HrEInvoiceNumberService.reserveNextNumber(orgId, issuerOib, fiscalYear): String — UPSERT + SELECT FOR UPDATE + increment
  7. OibBindingException + ConflictException exception types

Acceptance criteria: All 42 existing adapter tests pass. Flyway migrate runs clean V74→V78. New test: sendDocument() with 5xx does NOT retry (exactly one call). New test: concurrent reserveNextNumber() produces distinct sequential numbers.

WP2 — Service + Persist Protocol: HrEInvoiceService

Owner: CodeCraft (backend) | Depends on: WP1

  1. HrEInvoiceService.submitInvoice() — full persist-before/after protocol, OIB binding invariant, status gate (409 if in flight), IssuerProfile lookup, GCS write, LoggedAction
  2. HrEInvoiceService.pollAndUpdateStatus() — only if SUBMITTED/PENDING/SUBMIT_UNCERTAIN; archive write on ACCEPTED
  3. HrEInvoiceService.getXmlForDownload() — SHA-256 verification on every retrieval; 500 ARCHIVE_INTEGRITY_FAILURE on mismatch

Acceptance criteria: BEFORE tx written before HTTP call. AFTER tx reflects correct state for each case. OIB binding test: mismatched org → 422 + LoggedAction. Concurrent submit → one succeeds, one gets 409. SHA-256 mismatch on download → 500.

WP3 — Route: SveRacunRoutes

Owner: CodeCraft (backend) | Depends on: WP2

  1. All four route handlers (thin layer over service, mirrors SefRoutes.kt)
  2. Rate limit middleware: 10 submit requests/org/minute, 100/org/day (in-memory ConcurrentHashMap sliding window)
  3. Mount in Application.kt alongside sefRoutes()

Acceptance criteria: Unauthenticated → 401. Insufficient role → 403. Wrong org → 404 (not 403; no existence leak). Already SUBMITTED → 409. SVERACUN_HR_LIVE=false → 501. Rate limit: 101st submit in same day → 429.

WP4 — Infra: GCS Bucket + Secret Wiring

Owner: FlowForge (infra) | Depends on: WP1

  1. Terraform: bilko-hr-einvoice-archive-demo GCS bucket — versioning, write-once IAM, 90-day lifecycle
  2. Verify bilko-sveracun-test-api-key exists and Cloud Run SA has secretmanager.versions.access
  3. Secret rotation runbook documented in BookStack
  4. Terraform: bilko-hr-einvoice-archive-prod bucket definition (commented out; LOCKED retention command documented but not executed)

Acceptance criteria: gcloud storage buckets describe bilko-hr-einvoice-archive-demo shows versioning=enabled and no delete in bilko-api SA binding. CI integration test: DbIssuerProfileRepository.findByOrgId(DEMO_ORG_ID) resolves non-null API key. Terraform plan = zero diff after apply.

WP5 — Dead Code Removal

Owner: CodeCraft (backend) | Depends on: WP3

  1. Delete StorecoveHrFiskEInvoiceAdapter.kt (652 lines, abandoned provider, confirmed CEO decision MC #8675)
  2. Remove DI wiring, test references, import statements

Acceptance criteria: ./gradlew build passes with zero Storecove warnings. grep -r "StorecoveHrFisk" apps/api/src returns zero results.

WP6 — Proveo E2E Validation

Owner: Proveo (Angie Jones) | Depends on: WP3, WP4

  1. Submit a real invoice through the route (SVERACUN_HR_LIVE=true, TEST env, IssuerProfile.enabled=true)
  2. Assert HTTP 200 + non-null documentId received and persisted in DB
  3. Assert GCS object exists and SHA-256(GCS bytes) == xml_sha256_hex from DB
  4. Trigger poll; assert status transitions (PENDING → ACCEPTED on TEST env)
  5. Verify status and XML download routes
  6. Security checks: wrong orgId → 404; already SUBMITTED → 409; invalid OIB → 422; unauthenticated → 401
  7. Rate limit: 101st submit → 429
  8. Audit: LoggedAction row present with correct event, no PII in values
  9. Verify zero retry attempts on sendDocument() via structured log count

Acceptance criteria (PASS/FAIL; no partial credit): Real sveRačun TEST HTTP 200 + documentId. GCS object written and SHA-256 verified. All security checks return expected codes. No PII in LoggedAction. Zero retries on send. No StorecoveHrFisk references in deployed artifact.

WP7 — BookStack Documentation

Owner: Skillforge | Depends on: WP6 (Proveo validation passed)

  1. This ADR page (published)
  2. BookStack page: "HR eRačun — Prod Prerequisites Checklist" (Bilko book, Legal & Compliance chapter) — B1/B2 legal track, Phase 2C RLS activation gate, GCS LOCK command, PostLink posrednik contract steps, Securion gate checklist

6. Open Questions for PostLink (Zachariadis Carry-Forward)

Must be answered before any production activation. Parked in the prod track.

#Question
Q1Posrednik / Intermediary Model: Does sveRačun support an intermediary registration where a single API key holder (Bilko) is authorised to submit on behalf of multiple sender OIBs? If yes: is registration self-service via API or manual per-sender?
Q2companyVatNumber Header Semantics: The existing API separates Authorization (API key) from companyVatNumber (sender OIB). Is this header already the posrednik mechanism, or is etapa-1 currently hardcoded to reject unless the two match?
Q3PROD API Credentials: Rate limits on PROD vs TEST. Is the PROD auth scheme identical? Is there a staging environment with real OIBs but test FINA fiscalization path?
Q4Fiscalization Identifier: When FISCALIZATION:OK is returned, does the response body include a FINA fiscal identifier (ZKI/JIR equivalent)? Field name? Must Bilko store and display it?
Q5REJECTION_REPORT Payload: What structured data is in FISCALIZATION_REJECTION_REPORT? Rejection reason code and free text?
Q6Document Retrieval API: Does sveRačun provide a GET /documents/{id}/download endpoint? Critical for SUBMIT_UNCERTAIN reconciliation path.
Q7List by Sender Reference: Can Bilko query sveRačun for all documents submitted by sender OIB X in the last N hours? Required for SUBMIT_UNCERTAIN reconciliation when no documentId was received.
Q8Norwegian Entity Eligibility (B1): Is ALAI Holding AS (Norwegian org.nr, holding HR OIB HR91276104352) eligible as a platform intermediary under PostLink's terms?
Q9Pricing: Per-document pricing for an intermediary platform account. Setup fee per registered sender OIB.

7. Risk Register

RiskProbabilityImpactMitigation
Crash between HTTP 200 and AFTER tx (Kleppmann §5)Low (Cloud Run reliability)CRITICALClarify Q7 (list-by-reference API) with PostLink. Admin recovery endpoint in WP2 as fallback. Document the gap explicitly.
sveRačun TEST API unreliable during demoMediumHIGHSUBMIT_UNCERTAIN state is representable; demo recovery endpoint allows operator to manually enter docId. Brief the demo presenter.
UNIQUE(invoice_id) constraint blocks a legitimate re-send after REJECTEDLow (by design)LowService layer must support soft-delete of REJECTED row + insert of new row with new fiscal number + incremented attempt_seq. Document the re-send flow.
GCS write fails between number allocation and HTTP callLowMEDIUMIf GCS write fails, rollback DB insert. Number is consumed (non-returnable per B5) but absence of submission row signals no send occurred.
Phase 2C RLS not activated before multi-tenant prodCertain (currently PERMISSIVE)CRITICAL for multi-tenantSecurion prod gate checklist (WP7 BookStack). Block prod activation on this item.
PostLink posrednik contract takes longer than expectedHigh (legal/commercial)HIGH for multi-tenant; LOW for demoDemo runs DIRECT mode; no contract required. Architecture does not change.
sveRačun PROD base URL differs in auth schemeUnknownMEDIUMQ3 to PostLink. The baseUrlOverride + apiKeyOverride parameters allow runtime configuration without code change.
Double-fiscal number if FOR UPDATE not atomic in pgBouncerMedium without careCRITICALUse hr_einvoice_number_counters counter table with SELECT ... FOR UPDATE inside a transaction. pgBouncer transaction pooling mode is fine for FOR UPDATE (released at COMMIT).
Developer accidentally wires env-var path instead of IssuerProfileMediumHIGHThe serialize() signature change (WP1) removes httpClient.configuredSenderVat call; senderOib parameter is required (non-nullable). Caught at compile time.

8. Parked Items (Separate Strategic Decision Required)


9. Architectural Decisions Log (Conflict Resolutions)

State name: ACCEPTED vs APPROVED (Kleppmann vs Momjian).
Kleppmann uses ACCEPTED; Momjian uses APPROVED. Decision: DB column and CHECK constraint use ACCEPTED. EInvoiceStatus.APPROVED remains the adapter-interface value (matches existing interface); service translates to ACCEPTED when writing to DB. Rationale: ACCEPTED matches common EU e-invoicing terminology; APPROVED is the accounting approval concept (different thing).

Archive timing: at NUMBER_RESERVED vs at ACCEPTED (Tabriz vs Momjian).
Tabriz: write XML to GCS inside BEFORE transaction. Momjian: archive only after ACCEPTED. Decision: write XML bytes to GCS at NUMBER_RESERVED (Tabriz wins). Create hr_einvoice_archive integrity manifest row only at ACCEPTED (Momjian wins for the archive table write). Rationale: GCS object = bytes store (available from day one for recovery/audit); archive manifest = compliance record (formalized only when FISCALIZATION:OK confirmed). Both layers required.

SUBMIT_UNCERTAIN: Kleppmann has it; Momjian's original CHECK constraint omits it.
Decision: ADD SUBMIT_UNCERTAIN to V77 CHECK constraint. ADR replaces FAILED (Bilko-internal naming) with SUBMIT_UNCERTAIN (semantically precise for sveRačun poll-only model) and ACCEPTED (aligned with adapter interface). Full CHECK list: NUMBER_RESERVED, SUBMITTED, SUBMIT_UNCERTAIN, PENDING, ACCEPTED, REJECTED. FAILED is retired.

IssuerProfile in DB vs config file (Zachariadis vs simplicity).
Zachariadis recommends DB-backed IssuerProfile for demo. Decision: DB-backed from day one (Momjian V75 table). Rationale: single-row demo config in DB is trivial; gives RLS and audit from the start; is the same code path as multi-tenant production. A config-file implementation would need to be ripped out and replaced.


Petter Graff — Lead Architect, HR eRačun Architecture Team, 2026-06-11
Synthesized from inputs by Martin Kleppmann, Bruce Momjian, Markos Zachariadis, Parisa Tabriz.
MC #103453 (architecture documentation) | MC #103464 (build execution)