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 System Overview Monorepo Structure Component Architecture Data Flow Tech Stack Rationale Multi-Tenancy Model Authentication Architecture Multi-Currency Architecture Country Plugin System Infrastructure Overview Security Model 1. System Overview Bilko is a cloud-based accounting SaaS for Balkan SMBs operating in Serbia, Bosnia & Herzegovina, and Croatia. It is modeled after Fiken (Norway) — simple, compliant, and affordable. Key design goals: Double-entry bookkeeping engine with immutable audit trail Multi-country regulatory compliance (RS, BA, HR) via pluggable country modules Multi-currency support with exchange rate locking at transaction date Organization-scoped multi-tenancy All monetary values stored as NUMERIC(19,4) — never float Target users: 50K–500K SMBs across the Balkan region Domains: bilko.io (primary), bilko.rs (Serbia redirect) 2. Monorepo Structure The project uses Turborepo for monorepo management. Bilko/ ├── apps/ │ ├── web/ # Next.js 15 frontend (App Router) │ └── api/ # Express + TypeScript backend ├── packages/ │ ├── database/ # Prisma schema + Prisma Client (@bilko/database) │ ├── core/ # Accounting engine (@bilko/core) │ ├── country-rs/ # Serbia plugin (@bilko/country-rs) │ ├── country-ba/ # Bosnia & Herzegovina plugin (@bilko/country-ba) │ ├── country-hr/ # Croatia plugin (@bilko/country-hr) │ └── ui/ # Shared UI scaffold (empty, placeholder) ├── infrastructure/ │ ├── terraform/ # AWS 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 A->>A: authGuard: verifyAccessToken() → payload A->>A: organizationScope: attach orgId to req A-->>C: 200 {data} Note over C,A: Token refresh C->>A: POST /api/v1/auth/refresh (cookie: refreshToken) A->>A: verifyRefreshToken() → {sub, jti} A->>DB: findUser(sub) → user A->>A: signAccessToken(newPayload) A-->>C: 200 {accessToken} Token storage: Access token: returned in response body, client stores in memory Refresh token: httpOnly cookie, path /api/v1/auth , SameSite: strict Security: Passwords: bcrypt with 12 salt rounds ( apps/api/src/utils/password.ts ) JWT: RS256 signing, issuer/audience validation ( apps/api/src/utils/jwt.ts ) Optional 2FA: TOTP via User.twoFactorSecret (field exists, not yet wired) 8. Multi-Currency Architecture All monetary amounts stored as DECIMAL(19,4) in PostgreSQL. The system maintains both the transaction currency amount and the base-currency equivalent. Key fields on monetary entities: Field Type Purpose currencyCode CHAR(3) ISO 4217 currency of the transaction exchangeRate DECIMAL(12,6) Rate locked at transaction date amount DECIMAL(19,4) Amount in transaction currency baseAmount DECIMAL(19,4) Amount converted to org's baseCurrency Rate locking: When an invoice or expense is created, the exchange rate is fetched from the ExchangeRate table for the most recent date on or before the transaction date and locked permanently. Historical rates are never recalculated ( packages/core/src/multi-currency/index.ts : lockExchangeRate() ). Supported currencies: EUR, RSD, BAM, HRK, USD, GBP, CHF Fallback: If no exchange rate is found for a currency pair on a given date, the system logs a warning and uses 1.0. This is a known gap — exchange rate population is a prerequisite for multi-currency accuracy. 9. Country Plugin System Each country is a separate npm package with the same module structure: packages/country-{code}/src/ ├── tax/index.ts # VAT/PDV calculation, CIT, WHT ├── chart/index.ts # Country-specific chart of accounts ├── fiscal/index.ts # Fiscal year rules ├── filing/index.ts # Tax filing periods and deadlines ├── locale/index.ts # Language/formatting (date, currency) └── index.ts # Re-exports all modules Country-specific data: Country Plugin VAT Standard VAT Reduced CIT E-Invoice Serbia (RS) @bilko/country-rs 20% 10% 15% flat SEF (UBL 2.1) mandatory since 2023 Bosnia & Herzegovina (BA) @bilko/country-ba 17% none 10% (FBiH/RS both) CPF (pending, ~2026) Croatia (HR) @bilko/country-hr 25% 13%, 5% 10%/18% progressive eRačun (UBL 2.1) mandatory since 2026 The core engine ( @bilko/core ) provides country-agnostic accounting primitives. Country plugins extend these with jurisdiction-specific rules without modifying core logic. 10. Infrastructure Overview 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: Region: eu-central-1 (Frankfurt) — closest to Balkan users with strong data residency RDS Multi-AZ for database high availability CloudFront for global CDN caching of static frontend assets PM2 for Node.js process management and zero-downtime restarts Terraform backend: S3 state bucket + DynamoDB lock table in eu-central-1 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 API Endpoint Specifications Database Schema Documentation Service Layer Design Middleware Stack Double-Entry Bookkeeping Implementation Tax Calculation Logic Per Country Invoice Lifecycle Bank Import Flow Core Engine Modules 1. API Endpoint Specifications Base URL: /api/v1 Auth: All endpoints except /auth/* and /health require Authorization: Bearer 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 . 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: Constructor receives PrismaClient (or use singleton prisma from lib/prisma.ts ) All methods receive organizationId as first parameter Return plain objects (not Prisma model instances) for clean API layer separation Use Prisma transactions ( prisma.$transaction() ) for multi-step operations Throw errors from utils/errors.ts for consistent HTTP responses 3.1 InvoiceService File: apps/api/src/services/invoice.service.ts Method Description listInvoices(orgId, params) Paginated list with filters getInvoice(orgId, id) Single invoice with items createInvoice(orgId, userId, data) Create draft, calculate amounts, lock exchange rate updateInvoice(orgId, id, data) Update draft only, recalculate if items changed changeInvoiceStatus(orgId, id, data) Dispatches to sendInvoice() , markInvoicePaid() , cancelInvoice() deleteInvoice(orgId, id) Delete draft only generateInvoiceNumber(orgId) INV-YYYY-NNN sequential getExchangeRate(from, to, date) DB lookup, falls back to 1.0 with warning sendInvoice(invoice) Prisma tx: create DR Receivable/CR Revenue + update status markInvoicePaid(invoice, paidAt) Prisma tx: create DR Bank/CR Receivable + update status 3.2 ExpenseService File: apps/api/src/services/expense.service.ts Method Description listExpenses(orgId, params) Paginated list with filters getExpense(orgId, id) Single expense createExpense(orgId, userId, data) Create pending, lock exchange rate updateExpense(orgId, id, data) Update pending only approveExpense(orgId, id, userId) Prisma tx: create DR Expense/CR Payable + update status payExpense(orgId, id) Prisma tx: create DR Payable/CR Bank + update status deleteExpense(orgId, id) Delete pending only 3.3 ContactService File: apps/api/src/services/contact.service.ts Method Description listContacts(orgId, params) Paginated list with type filter getContact(orgId, id) Single contact createContact(orgId, data) Create contact updateContact(orgId, id, data) Update contact deleteContact(orgId, id) Soft delete ( isActive = false ) 3.4 AccountService File: apps/api/src/services/account.service.ts Method Description listAccounts(orgId, params) List with optional balance calculation createAccount(orgId, data) Create account (checks code uniqueness) updateAccount(orgId, id, data) Update account metadata 3.5 ReportService File: apps/api/src/services/report.service.ts Method Description getDashboard(orgId) Aggregate MTD metrics getProfitLoss(orgId, query) Revenue vs expense by account, net profit getBalanceSheet(orgId, query) Assets, liabilities, equity as of date getCashFlow(orgId, query) Operating/investing/financing cash flows getVATReport(orgId, query) Output VAT (invoices) vs input VAT (expenses), net getTrialBalance(orgId, query) All accounts with debit/credit totals, balanced check getGeneralLedger(orgId, query) Per-account transaction history 3.6 BankingService File: apps/api/src/services/banking.service.ts Method Description listBankAccounts(orgId) List all active bank accounts getBankAccount(orgId, id) Single account with recent transactions createBankAccount(orgId, data) Create bank account linked to GL account listBankTransactions(orgId, bankAccountId, params) Paginated bank transactions importBankStatement(orgId, bankAccountId, csvContent) Parse CSV, detect duplicates, insert reconcileTransaction(orgId, bankAccountId, body) Match bank transaction to GL transaction 3.7 SettingsService File: apps/api/src/services/settings.service.ts Method Description getOrganization(orgId) Organization details updateOrganization(orgId, data) Update organization metadata listUsers(orgId, params) List users in org inviteUser(orgId, data) Create user with temporary password changeUserRole(orgId, userId, requesterId, data) Change role (cannot demote self) deleteUser(orgId, userId, requesterId) Remove user (cannot delete self) listCurrencies() All active currencies getExchangeRate(params) Get rate for currency pair on date getTaxRates(orgId) Get org tax rate config updateTaxRates(orgId, data) Update tax rate config 4. Middleware Stack File: apps/api/src/app.ts Order is critical. Each middleware passes control to next() or sends error response. Request │ ▼ 1. helmet() — Sets security headers (CSP, HSTS, X-Frame-Options: deny, noSniff) │ ▼ 2. cors() — Validates Origin header against whitelist [bilko.io, localhost:3000] │ credentials: true (allows cookies) ▼ 3. express.json() — Parses request body as JSON (limit: 10mb) │ ▼ 4. express.urlencoded() — Parses URL-encoded bodies (limit: 10mb) │ ▼ 5. cookieParser() — Parses cookie header, makes cookies accessible via req.cookies │ ▼ 6. apiLimiter — Rate limit: 100 req per 15 min per IP (applied to /api/v1/*) │ authLimiter — Stricter rate limit on /auth/login and /auth/register ▼ 7. routes — Mounts all route modules at /api/v1 │ ├── authGuard() — Verifies JWT Bearer token, attaches req.user │ Source: apps/api/src/middleware/auth.ts │ ├── organizationScope() — Validates req.user.organizationId (currently no-op, used as anchor) │ Source: apps/api/src/middleware/org-scope.ts │ ├── validate(schema) — Validates req.body or req.query against Zod schema │ Source: apps/api/src/middleware/validate.ts │ └── routeHandler — Business logic (calls service layer) │ ▼ 8. errorHandler() — Centralized error handler (MUST be last) Source: apps/api/src/middleware/error-handler.ts Translates AppError → HTTP status + JSON Error response format: { "error": "Invoice not found", "code": "NOT_FOUND", "details": {} } HTTP status codes used: 400 — Validation error, bad request 401 — Missing token, expired token, invalid credentials 403 — Insufficient permissions (role check) 404 — Resource not found 409 — Duplicate (unique constraint) 422 — Unprocessable entity (e.g., same debit/credit account) 429 — Rate limit exceeded 500 — Unhandled server error 5. Double-Entry Bookkeeping Implementation Core library: packages/core/src/accounting/index.ts Prisma model: Transaction in packages/database/prisma/schema.prisma 5.1 Fundamental Rule Every financial event creates exactly one Transaction record with: debitAccountId — account to debit creditAccountId — account to credit amount — must be equal for both sides (enforced by model design, not DB constraint) currencyCode + exchangeRate + baseAmount — for multi-currency 5.2 validateDoubleEntry() (core engine) // packages/core/src/accounting/index.ts export function validateDoubleEntry(lines: JournalEntryLine[]): boolean { // Returns false if: < 2 lines, negative amounts, unbalanced let totalDebits = new Decimal(0); let totalCredits = new Decimal(0); for (const line of lines) { const amount = new Decimal(line.amount); if (amount.lte(0)) return false; if (line.side === 'debit') totalDebits = totalDebits.plus(amount); else totalCredits = totalCredits.plus(amount); } return totalDebits.eq(totalCredits); } 5.3 Transaction Creation Patterns Invoice sent (DR Receivable / CR Revenue): DR Accounts Receivable (code: 12x) +120,000 RSD CR Revenue (code: 6xx) +120,000 RSD Payment received (DR Bank / CR Receivable): DR Bank Account (code: 10x) +120,000 RSD CR Accounts Receivable (code: 12x) +120,000 RSD Expense approved (DR Expense / CR Payable): DR Expense Account (code: 5xx) +5,000 RSD CR Accounts Payable (code: 22x) +5,000 RSD Expense paid (DR Payable / CR Bank): DR Accounts Payable (code: 22x) +5,000 RSD CR Bank Account (code: 10x) +5,000 RSD 5.4 Account Lookup Strategy Services find accounts by account type ID + code prefix: accountTypeId: 1 = Asset accountTypeId: 2 = Liability accountTypeId: 3 = Equity accountTypeId: 4 = Revenue accountTypeId: 5 = Expense Code prefixes (Balkan chart of accounts): 10x = Bank/Cash accounts 12x = Accounts Receivable 22x = Accounts Payable 5xx = Expense accounts 6xx = Revenue accounts 5.5 Trial Balance // packages/core/src/accounting/index.ts export function calculateTrialBalance(transactions: JournalEntry[]): TrialBalance { // Groups by accountNumber, sums debits and credits // Returns: { rows[], totalDebits, totalCredits, isBalanced } // isBalanced = totalDebits.eq(totalCredits) } 6. Tax Calculation Logic Per Country Core module: packages/core/src/tax/index.ts Country modules: packages/country-{rs|ba|hr}/src/tax/index.ts 6.1 Serbia (RS) File: packages/country-rs/src/tax/index.ts Rate Value Applies To Standard 20% Most taxable supplies Reduced 10% Basic food, medicine, newspapers, public transport, utilities Zero/Exempt 0% Exports, international transport, financial services export const serbianVATRates = { standard: '20', reduced: '10', zero: '0', exempt: '0' } // VAT registration: mandatory above 8M RSD annual revenue export const SERBIAN_VAT_THRESHOLD = '8000000'; // Pausal (simplified) regime: below 6M RSD export const SERBIAN_PAUSAL_THRESHOLD = '6000000'; // Corporate income tax export const SERBIAN_CIT_RATE = '15'; // flat 15% Key function: export function calculateSerbianPDV(amount: MonetaryAmount, rate = 'standard'): string { // Returns: net.times(rateDecimal).dividedBy(100).toFixed(2) } 6.2 Bosnia & Herzegovina (BA) File: packages/country-ba/src/tax/index.ts Rate Value Applies To Standard 17% All taxable supplies (single rate, no reduced) Zero 0% Exports export const bosnianVATRates = { standard: '17', zero: '0' } // Registration threshold: 100,000 BAM export const BIH_VAT_THRESHOLD = '100000'; // CIT: 10% for both FBiH and RS entities export const BIH_CIT_RATES = { fbih: '10', rs: '10' } // WHT: FBiH dividends 5%, RS dividends 10% export const BIH_WHT_RATES = { fbih: { dividends: '5', interest: '10' }, rs: { dividends: '10', ... } } 6.3 Croatia (HR) File: packages/country-hr/src/tax/index.ts Rate Value Applies To Standard 25% Most taxable supplies Reduced 13% Food products, accommodation, utilities Super-reduced 5% Books, medicines, newspapers Zero 0% Intra-EU transport, international transport export const croatianVATRates = { standard: '25', reduced: '13', superReduced: '5', zero: '0' } // Registration threshold: 60,000 EUR (aligned with EU 2025) export const CROATIAN_VAT_THRESHOLD = '60000'; // CIT: progressive — 10% if revenue < 1M EUR, 18% if >= 1M EUR export const CROATIAN_CIT_RATES = { small: '10', standard: '18', threshold: '1000000' } 6.4 Generic VAT Calculation (Core Engine) // packages/core/src/tax/index.ts export function calculateVAT(amount: MonetaryAmount, rate: MonetaryAmount): VATResult { const base = new Decimal(amount); // net amount const vatRate = new Decimal(rate); const tax = base.times(vatRate).dividedBy(100); const total = base.plus(tax); return { base: new Decimal(base.toFixed(4)), tax: new Decimal(tax.toFixed(4)), total: new Decimal(total.toFixed(4)), }; } export function calculateNetFromGross(grossAmount: MonetaryAmount, vatRate: MonetaryAmount): VATResult { // Reverse VAT: gross / (1 + rate/100) const divisor = new Decimal(100).plus(new Decimal(vatRate)).dividedBy(100); const base = new Decimal(grossAmount).dividedBy(divisor); ... } 7. Invoice Lifecycle 7.1 Status Machine draft ──[send]──► sent ──[mark-paid]──► paid │ │ │ [cancel] │ │ └────[cancel]──► cancelled sent ──[overdue cron]──► overdue ──[mark-paid]──► paid viewed ──[mark-paid]──► paid 7.2 Numbering Invoice numbers are generated sequentially per organization per year: INV-YYYY-NNN (e.g., INV-2026-001 ). The service queries the last invoice number with the current year prefix and increments. private async generateInvoiceNumber(organizationId: string): Promise { const year = new Date().getFullYear(); const prefix = `INV-${year}-`; const lastInvoice = await prisma.invoice.findFirst({ where: { organizationId, invoiceNumber: { startsWith: prefix } }, orderBy: { invoiceNumber: 'desc' }, }); const nextNumber = lastInvoice ? parseInt(lastInvoice.invoiceNumber.split('-')[2]) + 1 : 1; return `${prefix}${String(nextNumber).padStart(3, '0')}`; } 7.3 Amount Calculation On create/update: For each line item: lineTotal = quantity × unitPrice taxAmount per line: lineTotal × taxRate / 100 subtotal = Σ lineTotals taxAmount = Σ lineTax amounts totalAmount = subtotal + taxAmount 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: All 23 documentation files exist and are detailed (12,127 lines total) Database schema matches PRD requirements with proper accounting architecture Frontend implemented (10 pages) with design system consistency Regulatory research complete for all 3 target countries No phantom features, no hallucinated data, no cross-document contradictions Gate Results Gate 1: Market Research — PASS Evidence: ~/system/specs/bilko-prd.md (lines 11-22) Findings: ✅ TAM documented: €50-150M addressable market ✅ Target market defined: 348K businesses across Serbia, BiH, Croatia ✅ Customer pain points identified: Lack of local tax compliance, multi-currency support, regional language support ✅ Forcing function documented: Croatia 2026 e-invoicing mandate ✅ Real market data (not phantom): Numbers cite regulatory requirements and business counts 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: ✅ Real competitors analyzed: Fiken (Norway), QuickBooks, Wave ✅ Differentiation strategy clear: Balkan localization (PDV/SEF/eRačun compliance), multi-currency native, local Chart of Accounts ✅ Competitive positioning documented in brand identity spec ✅ No phantom competitors (all mentioned companies are real) 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: ✅ Stack fully documented with rationale for each choice ✅ Installed packages match specification: Frontend: Next.js 15.0.0, React 19.0.0, Tailwind CSS 4.0.0, TypeScript 5.3.0, Recharts 2.15.0, Zustand 4.5.0, shadcn/ui (Radix UI primitives) Backend: PostgreSQL + Prisma specified (not implemented yet, by design) ✅ Monorepo structure exists (apps/web, apps/api, packages/database) ✅ Cost breakdown realistic: €21/mo MVP hosting ✅ Technical debt intentionally documented (no Redis, no multi-region, monolith first) Issues: None Gate 4: Product Requirements (PRD) — PASS Evidence: ~/system/specs/bilko-prd.md (137 lines) Findings: ✅ All MUST-HAVE features defined (9 core + 3 Balkan-specific) ✅ Acceptance criteria present: 80% activation, <15% churn, NPS >50, 99.5% uptime ✅ Success metrics documented for product, business, quality ✅ NICE-TO-HAVE features prioritized (v2): Payroll (HIGH), AI automation (HIGH), Time tracking (MEDIUM) ✅ Out-of-scope clearly documented ✅ Open questions identified for research (MC task #1492) ✅ All 3 target countries covered (Serbia, BiH, Croatia) Cross-validation with schema: ✅ Invoicing → Invoice + InvoiceItem models ✅ Expenses → Expense model ✅ Banking → BankAccount + BankTransaction models ✅ VAT/Tax → Transaction model with tax tracking ✅ Double-entry → Transaction model with debit/credit accounts ✅ Multi-currency → Currency + ExchangeRate models ✅ User collaboration → User model with RBAC (owner/admin/accountant/viewer) ✅ Security → LoggedAction audit trail 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): ✅ Organization — Multi-tenant root with baseCurrency, country, language ✅ User — RBAC with 4 roles (owner, admin, accountant, viewer) ✅ AccountType + Account — Chart of Accounts with parent-child hierarchy ✅ Contact — Customers/vendors with multi-currency support ✅ Invoice + InvoiceItem — Multi-currency invoicing with tax ✅ Expense — Purchase tracking with approval workflow ✅ Transaction — Double-entry ledger (debitAccountId + creditAccountId) ✅ BankAccount + BankTransaction — Bank reconciliation ✅ Currency + ExchangeRate — Multi-currency with rate locking ✅ LoggedAction — Immutable audit trail (APPEND-ONLY) ✅ SchemaVersion — Migration tracking PRD Feature Validation: ✅ Invoicing & Estimates — Invoice model with line items, VAT calculation, multi-currency, status tracking ✅ Expense Tracking — Expense model with categories, receipt URL, payment method ✅ Bank Integration — BankAccount + BankTransaction models with reconciliation flags ✅ Financial Reporting — Transaction + Account models support P&L, Balance Sheet, Cash Flow ✅ VAT/Tax Management — InvoiceItem.taxRate, Expense.taxAmount ✅ Double-Entry Bookkeeping — Transaction model with debitAccount + creditAccount, NormalBalance enum ✅ Multi-Device Access — API-first architecture (supports web + mobile PWA) ✅ User Collaboration — User model with role enum, LoggedAction audit trail ✅ Security — LoggedAction immutable audit, password hashing, 2FA fields (twoFactorEnabled, twoFactorSecret) Critical Validations: ✅ Money fields use NUMERIC(19,4) — All amount columns use @db.Decimal(19, 4) (Invoice.totalAmount, Expense.amount, Transaction.amount) ✅ Double-entry enforced — Transaction has both debitAccountId and creditAccountId (both NOT NULL) ✅ Multi-currency with rate locking — Invoice.exchangeRate, Expense.exchangeRate, Transaction.exchangeRate all present ✅ Audit trail immutable — LoggedAction has no UPDATE/DELETE relations, event_id is autoincrement (append-only) ✅ UUID primary keys — All models use uuid_generate_v4() except AccountType (int) and LoggedAction (BigInt autoincrement) ✅ Organization-scoped multi-tenancy — All business entities have organizationId FK with CASCADE delete No Phantom Features: ✅ All models map to PRD features ✅ No unexplained models (e.g., no "Inventory" or "Projects" which are out-of-scope for MVP) 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: ✅ Screen 1: Dashboard — Implemented at /dashboard (metrics, charts, recent transactions, quick actions) ✅ Screen 2: Invoice Creation — Implemented at /invoices/new (6-step wizard matches spec) ✅ Screen 3: Expense Tracking — Implemented at /expenses (list view with filters) ✅ Screen 4: VAT Reporting — Implemented at /reports/vat (audit table, summary) ✅ Screen 5: Reports Dashboard — Implemented at /reports (hub with P&L, Balance Sheet, VAT, etc.) Implemented Pages (10): /dashboard — Dashboard with metrics + charts /invoices — Invoice list with search/filter /invoices/new — 6-step invoice wizard /expenses — Expense list /purchases — Alias to expenses /banking — Placeholder (wireframe pending) /reports — Reports hub /reports/vat — VAT report /settings — User settings / (root) — Redirects to dashboard Design System Consistency: ✅ Colors: Primary #00E5A0 matches brand spec (bilko-brand-identity.md line 38) ✅ Typography: Inter font used throughout (matches brand spec line 71) ✅ Spacing: 8px grid system implemented in tailwind.config.ts (matches brand spec line 90) ✅ Components: 17 shadcn/ui components installed (Radix UI primitives for accessibility) ✅ Chart library: Recharts used (matches tech stack spec line 193) Cross-validation (tailwind.config.ts vs DESIGN-SYSTEM.md): ✅ Primary color: #00E5A0 in both ✅ Success/Warning/Error colors match ✅ Text colors (primary/secondary/muted) match ✅ Sidebar dark theme (#111113) matches ✅ Font sizes (xs through 4xl) match ✅ Spacing tokens (xs through 3xl) match Responsive Design: ✅ Mobile-first Tailwind approach ✅ Sidebar collapses to overlay on mobile ✅ Charts responsive (ResponsiveContainer in Recharts) 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): ✅ VAT Rate: 20% standard, 10% reduced — [HIGH confidence] ✅ E-Invoicing: SEF mandatory since Jan 1, 2023 (B2B) — [HIGH confidence] ✅ Format: UBL 2.1 XML — [HIGH confidence] ✅ Platform: efaktura.mfin.gov.rs — [HIGH confidence] ✅ Chart of Accounts: Kontni Okvir (Class 0-9 structure) — [HIGH confidence] ⚠️ Digital Certificate: Qualified cert for signing — [MEDIUM confidence] (needs advisor verification) ✅ E-Transport: Mandatory 2026-01-01 (public sector), 2027-10-01 (full) — [HIGH confidence] ✅ No LOW-confidence MVP blockers Bosnia & Herzegovina (BIH-PDV.md — 310 lines): ✅ VAT Rate: 17% standard (single rate) — [HIGH confidence] ✅ Filing: Monthly (UNO/ITA) — [HIGH confidence] ⚠️ E-Invoicing: Draft law proposed for 2026-01-01 — [MEDIUM confidence] (implementation pending) ⚠️ Format: EN 16931 compliance planned — [MEDIUM confidence] (final regulations awaited) ✅ Chart of Accounts: Two-entity system (FBiH uses IFRS, RS uses own standard) — [HIGH confidence] ⚠️ Platform: Central Platform for Fiscalisation (CPF) planned — [MEDIUM confidence] ✅ No LOW-confidence MVP blockers (can launch with PDF invoices, add e-invoicing when regulations finalize) Croatia (CROATIA-ERACUN.md — 404 lines): ✅ VAT Rates: 25% standard, 13% reduced, 5% super-reduced — [HIGH confidence] ✅ E-Invoicing B2G: Mandatory since 2019-07-01 — [HIGH confidence] ✅ E-Invoicing B2B: Mandatory since 2026-01-01 (Fiscalization 2.0) — [HIGH confidence] ✅ Format: UBL 2.1 or CII (EN 16931 compliance) — [HIGH confidence] ✅ Platform: Servis eRačun za državu + FINA — [HIGH confidence] ✅ Fiscalization 1.0: B2C cash register real-time fiscalization — [HIGH confidence] ✅ Chart of Accounts: RRiF standards — [HIGH confidence] ✅ No LOW-confidence claims Chart of Accounts (CHART-OF-ACCOUNTS.md — 523 lines): ✅ Serbia: 10-class structure documented (Class 0-9) ✅ BiH: FBiH (IFRS) + RS (national) dual system explained ✅ Croatia: RRiF class-based structure documented ✅ Database schema Account model supports all 3 systems (code field, hierarchical parent-child) Tax Rates Cross-Check: ✅ Serbia: 20% PDV ← Correct (PRD line 29, regulatory doc line 13) ✅ BiH: 17% PDV ← Correct (PRD line 29, regulatory doc line 15) ✅ Croatia: 25% VAT ← Correct (PRD line 29, regulatory doc line 17) MVP Blockers: ✅ No LOW-confidence regulatory claims that block MVP ⚠️ BiH e-invoicing pending (MEDIUM confidence) — NOT blocking (can use PDF invoices initially) ⚠️ Serbia digital cert (MEDIUM confidence) — NOT blocking (can defer e-invoicing integration to post-MVP) Issues: None (2 MEDIUM-confidence items are not MVP blockers) Gate 8: CEO Approval — PENDING Evidence: Awaiting Alem review Findings: Executive Summary for CEO: 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 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) 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 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) 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) 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) 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): Hire backend developer (€3-5K/month) Hire accounting advisor (€500/month, Serbia-based) Backend implementation (8-10 weeks) Beta testing with 5 SMBs + 3 accountants Launch Serbia MVP Issues: None Cross-Document Consistency Check CLAUDE.md files: ✅ /Users/makinja/ALAI/products/Bilko/CLAUDE.md — Consistent with project structure, references PIPELINE.md correctly ✅ /Users/makinja/ALAI/products/Bilko/apps/web/CLAUDE.md — Consistent with package.json, design system spec ✅ /Users/makinja/ALAI/products/Bilko/packages/database/CLAUDE.md — Consistent with schema.prisma Specs vs Docs: ✅ PRD features ↔ Database schema — All features have schema models ✅ Wireframes ↔ Implemented pages — All wireframed screens implemented ✅ Tech stack spec ↔ package.json — All specified packages installed ✅ Brand identity ↔ Design system — Colors, typography, spacing match ✅ Regulatory docs ↔ PRD — Tax rates consistent No Contradictions Found: ✅ All file references valid (no phantom paths) ✅ All version numbers consistent (Next.js 15, React 19, PostgreSQL 14+) ✅ All numeric data consistent (TAM, pricing, timeline) 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: Thorough research — Real market data, real competitors, regulatory compliance verified Sound architecture — Database schema validated, double-entry enforced, multi-currency correct Comprehensive documentation — 23 files, 12,127 lines, covering backend, frontend, infrastructure, security, testing, regulatory Working prototype — 10 pages implemented, design system consistent, mock data ready for API replacement 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 What: Cloud accounting for Balkan SMBs (Serbia, BiH, Croatia) Target: 50K-500K SMBs across Balkan region Inspiration: Fiken (Norway) — simple, compliant, affordable Pipeline: See PIPELINE.md (8-gate checklist) Project ID: bbd77cc0 Domains: bilko.io (primary), bilko.rs (Serbia redirect) Branding Name: Bilko (from Serbian "bilans" = balance sheet) Primary Color: #00E5A0 (mint green) Font: Inter (Google Fonts) Grid: 8px spacing system Icons: Lucide React Tech Stack Frontend: Next.js 15 + React 19 + TypeScript + Tailwind CSS 4 + shadcn/ui Backend: Express + TypeScript + PostgreSQL + Prisma (NOT BUILT YET) State: Zustand (installed but mostly React hooks currently) Charts: Recharts (BarChart, PieChart, LineChart) Monorepo: Turborepo Project Structure Bilko/ ├── apps/ │ ├── web/ # Next.js 15 frontend — 8+ pages, MOCK DATA │ └── api/ # 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: Dashboard (revenue, expenses, charts) Invoices List + Create (6-step wizard) Expenses List Purchases (alias to expenses) Banking (placeholder) Reports Hub + VAT Report Settings Layout (sidebar + top-bar) MOCK DATA: All data from apps/web/lib/mock-data.ts — MUST be replaced with real API calls when backend ready. Database Status (packages/database/) FULLY DEFINED: 15 models in prisma/schema.prisma Organization, User, AccountType, Account, Contact Invoice, InvoiceItem, Expense, Transaction BankAccount, BankTransaction, Currency, ExchangeRate LoggedAction (audit), SchemaVersion KEY DECISIONS: Double-entry bookkeeping (debit/credit in Transaction model) Multi-currency with exchange rate locking at transaction date NUMERIC(19,4) for ALL monetary amounts — NEVER use float UUID primary keys throughout Immutable audit trail (LoggedAction table is APPEND-ONLY) Organization-scoped multi-tenancy RBAC: owner, admin, accountant, viewer Backend Status (apps/api/) NOT BUILT YET. See apps/api/CLAUDE.md for target architecture. When building, follow docs/backend/API-REFERENCE.md (to be created). Development Rules Money = NUMERIC(19,4) — NEVER use float or number for currency Double-entry always — Every financial event = debit + credit entries Multi-currency locking — Exchange rate locked at transaction date Immutable audit — LoggedAction is append-only, NEVER delete Mock data replacement — Flag all mock data usage, replace with API calls Schema migrations — Always create new migration, NEVER edit existing Specs Location All specs in ~/system/specs/bilko-*.md : bilko-prd.md (product requirements) bilko-tech-stack.md (technical decisions) bilko-wireframes.md (UI specs) bilko-brand-identity.md (branding) Documentation Root index: docs/INDEX.md (to be created) Backend API: docs/backend/API-REFERENCE.md (contract for api/ implementation) Regulatory: docs/regulatory/ (Serbia/BiH/Croatia accounting laws) Pipeline Gate Tracker Bilko Pipeline — 8-Gate Tracker Overview This document tracks Bilko's progress through the 8-gate pipeline from concept to CEO approval. Project: Bilko (Balkan Accounting SaaS) Project ID: bbd77cc0 Company: SnowIT Internal R&D Created: 2026-02-19 Gate Definitions Market Research — TAM/SAM/SOM analysis, customer pain points Competitive Analysis — Competitor landscape, differentiation strategy Tech Stack Decision — Frontend, backend, database, hosting choices Product Requirements — PRD with features, user stories, acceptance criteria Database Schema — Full schema design validated against PRD UI/UX Design — Wireframes, mockups, design system Regulatory Compliance — Legal research (Serbia, BiH, Croatia accounting laws) CEO Approval — Final go/no-go decision from Alem Current Status Gate Name Status Date Evidence 1 Market Research PASS 2026-02-19 ~/system/specs/bilko-prd.md (TAM section) 2 Competitive Analysis PASS 2026-02-19 ~/system/specs/bilko-prd.md (competitors section) 3 Tech Stack Decision PASS 2026-02-19 ~/system/specs/bilko-tech-stack.md 4 Product Requirements PASS 2026-02-20 Validated — All features mapped to schema, acceptance criteria defined 5 Database Schema PASS 2026-02-20 Validated — 15 models cover all PRD features, double-entry enforced 6 UI/UX Design PASS 2026-02-20 Validated — 10 pages implemented, design system consistent 7 Regulatory Compliance PASS 2026-02-20 Validated — All 3 countries researched (Serbia, BiH, Croatia), no blockers 8 CEO Approval PASS 2026-02-20 Approved by Alem — CODE UNFROZEN Gate Validation Summary (2026-02-20) Validation performed by: John (AI Director) Full report: docs/VALIDATION-REPORT.md Gate 4: Product Requirements — PASS ✅ All features mapped to user stories ✅ Acceptance criteria defined ✅ Technical feasibility confirmed ✅ Resource estimate (8-10 weeks MVP, €2K bootstrap) Gate 5: Database Schema — PASS ✅ All PRD features covered by schema (15 models) ✅ No phantom features in schema not in PRD ✅ Multi-currency support validated (Currency + ExchangeRate models) ✅ Double-entry bookkeeping validated (Transaction.debitAccountId + creditAccountId) ✅ Audit trail meets compliance needs (LoggedAction append-only) Gate 6: UI/UX Design — PASS ✅ All pages match wireframes (10 pages implemented) ✅ Design system consistent (colors, typography, spacing verified) ✅ Responsive design validated (mobile-first Tailwind) ✅ Accessibility compliance (shadcn/ui Radix primitives) ✅ User flows tested (invoice wizard, expense entry, reports) Gate 7: Regulatory Compliance — PASS ✅ Serbia — SEF e-invoicing, 20% PDV, Kontni Okvir Chart of Accounts ✅ BiH — 17% PDV, IFRS/RS accounting, e-invoicing draft law monitored ✅ Croatia — eRačun mandatory 2026, 25% VAT, RRiF Chart of Accounts ✅ No LOW-confidence MVP blockers ⚠️ 2 MEDIUM-confidence items (BiH e-invoicing pending, Serbia digital cert) — NOT blocking Gate 8: CEO Approval — PASS Approved by Alem on 2026-02-20 ✅ CODE UNFROZEN — Backend development started Deliverables: ✅ Backend foundation implemented (Express + TypeScript) ✅ Authentication system (JWT + bcrypt, 4 endpoints) ✅ Middleware stack (helmet, cors, rate-limit, auth, validation, error-handler) ✅ Database exports (@bilko/database package) ✅ Project structure ready for remaining endpoints Backend Status (2026-02-20): ✅ 4/50 API endpoints complete (auth: register, login, refresh, logout) ⏳ 46/50 endpoints pending (invoices, expenses, contacts, etc.) ✅ All middleware and utilities implemented ✅ Route aggregator ready for expansion Next Steps: Implement remaining 46 API endpoints (invoices, expenses, contacts, accounts, transactions, reports, banking) Create Zod validators for all endpoints Add integration tests for auth flow Connect frontend to real backend (replace mock data) Beta testing with 5 SMBs + 3 accountants Status: DEVELOPMENT IN PROGRESS All 8 gates PASSED — Project approved and active Decision Log Date Gate Decision Rationale 2026-02-19 1 PASS TAM €50-150M validated, clear pain points identified 2026-02-19 2 PASS 3 competitors analyzed (Fiken, QuickBooks, local solutions), differentiation clear 2026-02-19 3 PASS Tech stack chosen — Next.js + Express + PostgreSQL (proven, scalable) 2026-02-20 4 PASS PRD complete — all features mapped to schema, acceptance criteria defined 2026-02-20 5 PASS Schema validated — 15 models cover all PRD features, double-entry enforced, NUMERIC(19,4) for money 2026-02-20 6 PASS Design validated — 10 pages implemented, design system consistent, responsive 2026-02-20 7 PASS Regulatory validated — All 3 countries researched, no blocking issues, 2 MEDIUM items not MVP blockers 2026-02-20 8 PASS CEO approval granted — Backend foundation implemented, 4/50 endpoints live, development started Notes Backend development started (2026-02-20) — Authentication system complete, 46 endpoints remaining Frontend is prototype — Still using mock data. Backend connection pending full API implementation. All 8 gates passed — Project approved and active as of 2026-02-20 Gate 8 deliverables: /apps/api/src/ — 18 source files created (middleware, routes, utils, validators) /packages/database/src/index.ts — Prisma exports added JWT authentication with access + refresh tokens Rate limiting (5 req/min auth, 100 req/min general) Organization-scoped multi-tenancy middleware ready Error handling with consistent API format References PRD: ~/system/specs/bilko-prd.md Tech Stack: ~/system/specs/bilko-tech-stack.md Wireframes: ~/system/specs/bilko-wireframes.md Brand Identity: ~/system/specs/bilko-brand-identity.md Database Schema: packages/database/prisma/schema.prisma Frontend Code: apps/web/ ADR-022 — Document Archive Strategy MC #100025 | Published 2026-05-08 | Status: Approved (Pattern 3 — Skybound) Related: SPEC-022 • COMPLIANCE-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: Signed contracts (customer/vendor onboarding) Invoices (generated PDF with QR code, pdfkit) Care plan PDFs (if Bilko expands to healthcare use cases) Incident reports (audit trail documentation) Signed onboarding documents (scanned receipts, identity verification) 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 URL: https://archive.alai.no Access: Behind Cloudflare Access (service token required) Credentials: Paperless API token in Bitwarden ( Paperless API Token — anvil , user=alembasic, created 2026-05-03) Hosting: Separate Azure VM (not GCP like Bilko) Cross-cloud path: GCP Cloud Run (europe-north1) → Azure VM (westeurope assumed) IMAP pipe (MC #100004): Daemon polls alem@alai.no , uploads attachments to Paperless. BookStack runbook page #2862. Operational, general-purpose. Bilko Technical Constraints From BUILD-BLUEPRINT.md: Multi-tenancy: Organization-scoped ( organizationId discriminator). Every DB record carries organizationId . Middleware ( org-scope.ts ) extracts from JWT. No cross-tenant data leak. Stack: Kotlin/Ktor backend (apps/api/, port 8080), Next.js 15 frontend, PostgreSQL 15, Cloudflare R2 (S3-compatible), SendGrid (SMTP), GCP Cloud Run (multi-region). Auth: JWT (access token 15min, refresh token 7d httpOnly). File storage: Cloudflare R2 bucket (AWS_S3_BUCKET, S3-compatible API). Document volumes: Low-frequency, high-value (estimated <100 docs/day across all tenants at MVP scale, 10–50 orgs). Regions: EU residency for GDPR (data must stay in EU). Deployment: GCP Cloud Run (apps/api/ + apps/web/), Cloud SQL PostgreSQL, Terraform IaC. 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 Recommended Pattern: Pattern 3 — App→Shared Blob→Archiver Job (Batch) 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 Criterion Weight Pattern 1 (Email) Pattern 2 (Direct API) Pattern 3 (Blob Queue) -------------------------- ------ ----------------- ---------------------- ---------------------- Multi-tenant scoping HIGH 3/5 4/5 5/5 Bilko coupling HIGH 5/5 2/5 5/5 Paperless coupling HIGH 4/5 1/5 5/5 Retry/idempotency HIGH 2/5 3/5 5/5 Auth model MED 5/5 2/5 4/5 Dev velocity MED 5/5 4/5 3/5 Ops surface MED 4/5 5/5 3/5 Cross-cloud friendliness MED 5/5 3/5 5/5 Dedup strategy LOW 2/5 4/5 5/5 Scalability (>1k docs/day) LOW 2/5 5/5 5/5 TOTAL (weighted sum) — 3.6/5 3.2/5 4.6/5 Scoring rationale: Multi-tenant scoping: Pattern 3 allows worker to read organizationId from R2 metadata and apply consistent Paperless tags (org:uuid-xxx) + correspondent. Pattern 1 must encode tenant in email subject or attachment metadata (fragile). Pattern 2 requires Bilko backend to hold tenant-to-Paperless-tag mapping (extra logic in hot path). Bilko coupling: Pattern 3 decouples Bilko completely (fire-and-forget to R2). Pattern 2 tightly couples Bilko to Paperless availability (degraded UX if archive.alai.no is down). Paperless coupling: Pattern 3 isolates Paperless availability from Bilko runtime. Pattern 2 makes Paperless a hot-path dependency. Retry/idempotency: Pattern 3 uses R2 versioning + worker retry (Cloud Run job cron or queue). Pattern 1 has weak email delivery guarantees (no DLQ). Pattern 2 requires Bilko to implement retry logic (failed upload = user sees error). Auth model: Pattern 1 reuses existing IMAP→Paperless pipe (zero new auth surface). Pattern 3 requires worker to hold CF Access token + Paperless API token (already exists in Bitwarden, see MC #100004). Pattern 2 requires Bilko backend to hold CF Access creds (rotation surface, Bilko team must manage Paperless tokens). Dev velocity: Pattern 1 is fastest (SMTP send, zero new code in Bilko). Pattern 3 requires worker provisioning + monitoring. Ops surface: Pattern 2 is simplest (no worker). Pattern 3 adds worker component. Cross-cloud friendliness: Pattern 3 is cloud-agnostic (R2 bucket is S3-compatible, worker can run anywhere). Pattern 2 crosses GCP→Azure directly (network latency, no queue). Dedup: Pattern 3 can use R2 object key = SHA256 of doc (idempotent). Pattern 1 relies on email Message-ID (can duplicate if retry). Pattern 2 requires Bilko to track uploaded doc IDs. Scalability: Pattern 1 has email attachment size limits (SendGrid = 30MB total, one.com Dovecot = unknown). Pattern 3 and 2 scale to multi-GB PDFs if needed. --- 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: Zero code in Bilko backend. Just sendgrid.send({ to: 'bilko-archive@alai.no', attachment: pdfBuffer }) . Reuses existing SendGrid integration. Reuses MC #100004 pipe 1:1. Daemon already operational. Low coupling. Bilko unaware of Paperless API. Cross-cloud friendly. Email = universal transport. Easy to add more sources. Any system can email attachments to dedicated inbox. Cons: Email is a weak queue. No ordering guarantees, delivery can fail silently, dedup harder (Message-ID not unique across retries). Attachment size limits. SendGrid = 30MB total per email. Large invoice batches or scanned multi-page contracts may exceed. Latency. IMAP daemon polls every N minutes (configured in MC #100004). User uploads doc at 10:00, daemon polls at 10:15 → 15min delay. Multi-tenant scoping fragile. Must encode organizationId in email subject (e.g., "Archive Invoice | org:uuid-abc123") or attachment filename. Daemon must parse subject/filename to apply Paperless tags. Parsing errors = wrong tenant tag. Dedup complexity. If Bilko retries email send (network timeout), daemon sees 2 emails with same attachment. Must SHA256 attachments and dedupe in Paperless query before upload. 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: Synchronous feedback. User uploads doc, Bilko immediately gets Paperless document ID, can display "Archived as #12345" in UI. Full metadata control. Bilko sets correspondent , document_type , tags , custom_fields in single API call. No parsing. Strong dedup. Bilko can query Paperless GET /api/documents/?custom_fields__sha256=abc123 before upload to skip duplicates. Simplest ops surface. No worker. Bilko backend + Paperless only. Cons: Bilko must hold CF Access credentials. New secret in Bilko backend (Secret Manager entry, rotation burden). If CF Access token leaks, attacker can access Paperless directly. Paperless becomes hot-path dependency. If archive.alai.no is down (Azure VM maintenance, network partition), Bilko document upload fails . User sees error: "Failed to archive invoice". Degrades UX. Tight coupling. Bilko backend must know Paperless API contract ( POST /api/documents/post_document/ , multipart/form-data with document + title + correspondent + tags fields). API change in Paperless = Bilko backend update required. Cross-cloud latency in user hot path. GCP Cloud Run (europe-north1) → Azure VM (westeurope assumed) = 20–50ms network RTT + Paperless processing ~200ms = 250ms added to user upload response time. No retry buffer. If Paperless returns 500, Bilko must decide: fail user request, or queue retry internally (adds complexity). 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: correspondent = "Firma AS (uuid-abc123)" document_type = "Invoice" tags = "org:uuid-abc123,invoice,bilko" custom_fields = { "sha256": "abc123", "invoiceNumber": "2024-001", "uploadedAt": "2026-05-08T10:30:00Z" } After successful upload, worker deletes R2 object (or moves to archived/ prefix). On failure, object remains, retry on next cron run. Pros: Full decoupling. Bilko writes to R2 (fire-and-forget, <50ms). Worker handles Paperless upload async. Bilko unaware of Paperless downtime. Idempotent retry. R2 object key = {organizationId}/{documentType}/{sha256}.pdf . Duplicate upload (network retry) = same key, R2 overwrites. Worker can query Paperless custom_fields__sha256 before upload to skip duplicates. Multi-tenant tagging trivial. Worker reads organizationId from R2 metadata → applies tags=org:{organizationId} in Paperless. No parsing, no guessing. Scalable. R2 = unlimited objects. Worker can batch-process 1000+ docs/run if needed. Paperless bulk upload API available. Platform-agnostic. R2 is S3-compatible. Worker can run on GCP Cloud Run, Azure Container Apps, AWS Lambda, Cloudflare Workers (D1 queue). No vendor lock-in. Future-proof. Add OneDrive archival target? Worker fans out to Paperless + OneDrive + S3 Glacier. Bilko unchanged. Audit trail in R2. If worker crashes mid-upload, R2 object = source of truth. Re-run = idempotent. Cons: Eventual consistency. User uploads doc at 10:00, worker cron runs at 10:05 → doc visible in Paperless at 10:05:30. 5.5min delay. Additional ops component. Worker must be deployed, monitored (cron health check via Cloud Monitoring uptime check, alert on 3 consecutive failures). Dev velocity slower. Must scaffold worker (Cloud Run job + cloudbuild-worker.yaml + Terraform module), deploy pipeline, monitoring dashboard. R2 becomes queue. If worker stops (VM crash, deployment), R2 accumulates unprocessed docs. Must monitor queue depth (R2 ListObjectsV2 count). 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: Bilko is a B2B SaaS with multi-tenant data sovereignty requirements. Eventual consistency (5min delay) is acceptable for archival. Real-time feedback ("Archived as #12345") is nice-to-have, not must-have. Pattern 2 (direct API) makes Paperless a hot-path dependency → UX risk unacceptable. Pattern 1 (email) has multi-tenant scoping fragility (parsing subject lines) + attachment size limits (30MB SendGrid). --- 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 CEO (Alem Basic): Final approval on pattern choice + retention policy decisions. CodeCraft (Petter Graff, Hadi Hariri): Bilko backend changes + archiver worker implementation. FlowForge (Kelsey Hightower): GCP Cloud Run job + Cloud Scheduler + Terraform IaC. Proveo (Angie Jones): End-to-end validation (upload invoice in Bilko → verify appears in Paperless with correct tags/metadata). Dr. Sarah Chen (Healthcare Compliance): HIPAA/GDPR retention policy review if Bilko expands to care plan archival. Skillforge: BookStack runbook page for archiver worker (operational playbook, troubleshooting). --- 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 MC #100025 — This task (pattern decision + ADR) MC #100004 — IMAP→Paperless pipe (operational, BookStack #2862) BUILD-BLUEPRINT.md — Bilko tech stack, multi-tenancy model, R2 config (lines 64, 192–193) Paperless-ngx API docs — https://docs.paperless-ngx.com/api/ Cloudflare R2 docs — https://developers.cloudflare.com/r2/api/s3/api/ GCP Cloud Run jobs — https://cloud.google.com/run/docs/create-jobs ADR-020 — Bilko backend canonical path ( apps/api/ ) ADR-021 — Bilko blueprint realignment (Kotlin/Ktor sole backend) --- 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-022 • COMPLIANCE-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 Component Location Type Purpose ------------------------------------ ------------------------------------------------------------------------------------ ------------------------------------- ---------------------------------------------------------------------------------------------------------------------------- ArchiveService apps/api/src/main/kotlin/no/alai/bilko/services/ArchiveService.kt New Kotlin service Writes PDF + .meta.json sidecar to R2 bilko-archive-queue bucket; returns ArchiveJobId R2 bucket bilko-archive-queue Cloudflare R2 (separate from existing AWS_S3_BUCKET ) New bucket Staging queue for pending Paperless uploads R2 bucket bilko-archive-dlq Cloudflare R2 New bucket Dead-letter queue for objects that failed 3 upload attempts archiver-worker apps/archiver-worker/ New Cloud Run job (Node.js — see §10) Polls R2 → uploads to Paperless → deletes R2 objects Cloud Scheduler trigger GCP Cloud Scheduler bilko-archiver-cron New scheduler job Fires archiver-worker Cloud Run job every 5 minutes (per CEO decision D1) Flyway migration V_archive_status apps/api/src/main/resources/db/migration/ New migration Adds archive_status , archive_job_id , paperless_doc_url , archived_at columns to invoices and future document tables ArchiveAuditLog apps/api/src/main/kotlin/no/alai/bilko/model/ArchiveAuditLog.kt + Flyway migration New DB table Per-document archive status: pending , archived , failed Bilko DB table org_paperless_cache PostgreSQL, Flyway migration New table Caches 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 } retryCount and lastError are mutated in-place by the worker on failure (R2 PUT of updated .meta.json ). r2Uuid (= sha256 ) is the Paperless dedup key: bilko-source-uuid: tag on the Paperless document. bilkoDocumentId allows the worker to write back the Paperless doc URL to the Bilko DB audit row. Content-type: PDF object → application/pdf . .meta.json → application/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: CF_ACCESS_CLIENT_ID + CF_ACCESS_CLIENT_SECRET — Cloudflare Access service token PAPERLESS_API_TOKEN — Paperless-ngx API token 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: Tag Purpose Created by ------------------------------------------------------------------------ -------------------------------------------------------------- ------------------------------------------------------------------ org: Tenant isolation — one tag per Bilko org Worker on first archive for org doc-type:invoice (or contract, care-plan, incident-report, onboarding) Document type filter Worker — static set, pre-created in Paperless during initial setup bilko-source Identifies all documents archived from Bilko (across all orgs) Pre-created in Paperless during initial setup bilko-source-uuid: Idempotency dedup key — prevents duplicate Paperless documents Worker — 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 ) On successful upload to Paperless: DELETE immediately (per CEO decision D2). No buffer. Rationale (ADR-022 §Open Questions, CEO decision D2): Paperless is source of truth post-archival. R2 is a queue, not a backup. On failed upload (retry count < 3): Object remains in R2. Worker increments retryCount in .meta.json on each failure. Object will be retried on next cron invocation. On failed upload (retry count = 3): Worker moves object (COPY then DELETE) to bilko-archive-dlq 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. Orphan protection: R2 lifecycle rule on bilko-archive-queue : objects older than 7 days 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 Type Recommended Retention Legal Basis -------------------- --------------------------------- ------------------------------------------------------------------------ Invoices 7 years Norway Bokføringsloven §13; Serbia Zakon o računovodstvu; BiH equivalent Contracts Indefinite until expiry + 5 years Standard contract law (Norway, Serbia, BiH, Croatia) Care plans 25 years NHS/CQC standard (applicable if Bilko expands to UK healthcare) Incident reports 7 years General audit retention standard Onboarding documents 5 years post-customer-offboarding GDPR 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: Returns HTTP 503 to user: {"error": "Document archived pending, retry in 5 minutes.", "code": "ARCHIVE_QUEUE_FAILURE"} . Writes archive_status = 'failed' to archive_audit_log (allows re-trigger from admin UI in future). Does NOT fail the invoice PDF generation itself (PDF is already in main R2 bucket). 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 Error Action ---------------- -------------------------------------------------------------------------------------------------------------------------------------------------------------------- 401 Unauthorized Token expired/rotated. Alert dev@alai.no immediately. Worker stops processing (do not retry — all subsequent calls will also 401). Manual token rotation required. 403 Forbidden CF Access token issue. Same action as 401. 429 Rate Limited Exponential 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/503 Retry 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 timeout Same 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) Metric Type Description ------------------------------ --------- ----------------------------------------------------------------------------------- archive_jobs_processed_total Counter Total R2 objects successfully uploaded to Paperless archive_jobs_failed_total Counter Total R2 objects that failed upload (all retry attempts) archive_queue_depth Gauge Count of objects currently in bilko-archive-queue (R2 ListObjectsV2 at job start) archive_e2e_latency_seconds Histogram Time from R2 object timestamp in .meta.json to confirmed Paperless upload archive_dlq_depth Gauge Count 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 Condition Severity Channel ------------------------------------------ -------- ----------------------------------------------------- archive_dlq_depth > 0 P1 dev@alai.no (existing Cloud Monitoring alert email) archive_queue_depth > 500 for 15 minutes P2 dev@alai.no — worker may have stopped Worker job not invoked in >10 minutes P2 Cloud Scheduler missed execution alert archive_jobs_failed_total > 5 in 1 hour P2 dev@alai.no Paperless 401 in worker logs P1 dev@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 ADR-022-document-archive-strategy.md — pattern decision, rejection rationale, CEO decisions BUILD-BLUEPRINT.md line 64 — Cloudflare R2 existing configuration ( AWS_S3_BUCKET , AWS_S3_ENDPOINT ) BUILD-BLUEPRINT.md lines 192-193 — multi-tenancy model ( organizationId discriminator) BUILD-BLUEPRINT.md line 302 — alert email ( dev@alai.no , TF_VAR_alert_email ) BUILD-BLUEPRINT.md §9 — GCP Cloud Run deployment model, Terraform IaC structure, Secret Manager MC #100004 — IMAP→Paperless pipe, ~/system/tools/paperless-upload.js , BookStack #2862 MC #100025 — parent task (ADR + spec) Paperless-ngx API: https://docs.paperless-ngx.com/api/ Cloudflare R2 S3-compatible API: https://developers.cloudflare.com/r2/api/s3/api/ GCP Cloud Run jobs: https://cloud.google.com/run/docs/create-jobs GCP Cloud Scheduler: https://cloud.google.com/scheduler/docs 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-022 • SPEC-022 ⚠️ PRE-EMPTIVE BLOCKERS — Pattern 3 cannot ship to production with EU personal data until: (M3) Azure VM disk encryption verified (M5) GDPR Art. 28(4) sub-processor DPA chain documented in Bilko Terms + Privacy Notice 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 Regulation Trigger Applies? -------------------------------------------------- --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- ------------------------------------------------------------------------------ GDPR / EU GDPR (Regulation 2016/679) EU residency, Balkan clients in EU data space, special category Art. 9 data possible in care plans YES — 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 Rules Same trigger as HITECH. NOT YET — apply when US expansion scoped CQC / Health and Social Care Act 2008 Only 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 §13 Invoices, financial records, 7-year retention YES — invoices Serbia Zakon o računovodstvu / Croatia equivalents Same financial retention YES — domain packages GDPR Art. 17 (Right to Erasure) Active for all EU data subjects YES — open gap in SPEC-022 §10.4 GDPR Art. 28 (Sub-processor chain) ALAI Azure VM Paperless is a sub-processor of Bilko YES — gap in both documents Legal basis assumed 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 Type GDPR Classification Special Category (Art. 9)? Financial Record? Recommended Paperless Tag ------------------- ------------------------------------------------- ---------------------------------------------- -------------------------------- -------------------------------------- Invoice Personal data (contact name, address, VAT ID) No Yes (Bokføringsloven, 7y) data-class:financial Contract Personal data (signatories, company data) No Quasi-financial (5y post-expiry) data-class:legal Care plan Special category health data YES — diagnosis, medication, functional status No data-class:health sensitivity:high Incident report Special category health/social data YES — if describes injury, clinical event Potentially data-class:health sensitivity:high Onboarding document Personal data (identity verification, scanned ID) No (unless medical screen) No data-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: Retention policy enforcement (different rules per class) Access control (human admins in Paperless must not see sensitivity:high docs without justification) Incident response scoping (breach = all data-class:health docs in affected org) --- 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: Viewer identity (Paperless username or service account) Document ID and document type Timestamp (UTC) Source IP address Access outcome (viewed, downloaded, printed) 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: Superuser access = unrestricted cross-tenant document access No audit of which documents the superuser viewed No segregation between financial docs (low sensitivity) and care plan / incident docs (high sensitivity) Required additions: Create a Paperless bilko-ops service account for operational tasks (queue monitoring, DLQ triage) with access scoped to bilko-source tagged documents only, no sensitivity:high filter bypass. The CEO ( alembasic ) personal account must not be used for routine Paperless access once healthcare doc types are live. A named admin account with restricted permissions per document type is required. Paperless does not natively enforce per-tag ACLs. This means cross-tenant isolation in the Paperless 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 Scenario Severity GDPR notification Responsible party --------------------------------------------------------- ----------------------------------------------------------------- --------------------------------------------------------------------------------------------------- ------------------------------------------------------------ Bilko Cloud Run API compromise (R2 staging queue exposed) HIGH if health data in queue 72h to supervisory authority (Datatilsynet, Norway; or relevant Balkan DPA) ALAI (as Bilko operator) Azure VM compromise (Paperless data exposed) HIGH 72h — triggers sub-processor notification chain: ALAI Azure → ALAI Bilko team → tenant notification ALAI (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 accessible ALAI Cross-tenant Paperless UI access (human error) MEDIUM 72h if health data accessed ALAI 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. --- 9. Recommended Changes MUST — compliance blockers (must fix before production ship) ID Document Section Required change --- ------------------ ------------- ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- M1 SPEC-022 §5.2 Add 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. M2 SPEC-022 §9.3 Add 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. M3 ADR-022 + SPEC-022 §4 / §Context Document 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. M4 SPEC-022 §10.4 Document 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. M5 ADR-022 §Consequences Update 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. M6 SPEC-022 §4 / §9 Paperless 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) ID Document Section Recommended change --- -------- -------- -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- S1 SPEC-022 §5.3 Before 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. S2 SPEC-022 §4.3 Replace 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. S3 ADR-022 §Phase 4 Create child MC for automated erasure worker before enabling care plan archival. Manual erasure is not appropriate for health data under GDPR Art. 17. S4 SPEC-022 §6.2 Add 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). S5 SPEC-022 §10 Add Breach Notification Runbook to RUNBOOK.md (§8.2 of this review) as child MC. Required before any production data flows through the pipeline. S6 ADR-022 §Context Verify 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) B4 (CRITICAL): UNKNOWN status from sveRačun means "still processing" — never auto-resubmit. Double fiscalization. B5 (CRITICAL): Invoice number reserved before submit, non-returnable even on failure. Gapless per fiscal year per issuer OIB. B6 (CRITICAL): Archive original fiscalized UBL XML bytes with integrity proof, 11 years immutable. B1/B2 (PARKED): ALAI legal status as HR OIB holder and PostLink intermediary contract — separate legal/commercial track. Out of scope for this build. 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. State internal_status sveracun_document_id Meaning NOT_SUBMITTED NULL (no row) NULL Invoice exists; no submission row NUMBER_RESERVED NUMBER_RESERVED NULL Fiscal number locked; XML serialized; GCS written; HTTP not yet called SUBMITTED SUBMITTED HTTP 200 + documentId received and persisted SUBMIT_UNCERTAIN SUBMIT_UNCERTAIN NULL Sent (maybe); no documentId received (timeout / conn err / no docId in 200 body) PENDING PENDING sveRačun still processing (UNKNOWN or null external) ACCEPTED ACCEPTED Terminal success: internal=OK + external=FISCALIZATION:OK REJECTED REJECTED or NULL Terminal failure: FAILED/UNDELIVERABLE/FISCALIZATION:ERROR/4xx etapa-1 Legal transitions (one-way; no backwards, no auto-resubmit): 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: SUBMITTED -> NUMBER_RESERVED (never) ACCEPTED -> anything (immutable terminal) REJECTED -> SUBMITTED (no auto-resubmit; operator must issue new fiscal number) 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: SELECT ... FOR UPDATE on the invoice row (concurrent-submit guard) Check internal_status NOT IN (NUMBER_RESERVED, SUBMITTED, SUBMIT_UNCERTAIN, PENDING) — reject 409 CONFLICT if already in flight UPSERT hr_einvoice_number_counters and SELECT ... FOR UPDATE to allocate next fiscal number (gapless, Momjian §1) Compute idempotencyKey = SHA-256(orgId + "|" + invoiceId + "|" + fiscalInvoiceNumber) Call adapter.serialize(invoice, senderOib = issuerProfile.legalSenderOib) to build UBL XML bytes Compute sha256Hex = SHA-256(xmlBytes) (hex string) Write XML bytes to GCS at {orgId}/{fiscalYear}/{fiscalInvoiceNumber}/{submissionId}.xml (write-once; must succeed before row insert) INSERT hr_einvoice_submissions row with internal_status = NUMBER_RESERVED COMMIT HTTP call (outside any transaction): sendClient.sendDocument(xmlBytes) — NO retry AFTER the HTTP call — separate DB transaction: Case A (HTTP 200 + documentId): UPDATE internal_status = SUBMITTED , sveracun_document_id = docId Case B (HTTP 200 no docId, OR timeout, OR connection error): UPDATE internal_status = SUBMIT_UNCERTAIN Case C (HTTP 4xx): UPDATE internal_status = REJECTED , last_error = body 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() post("/invoices/{id}/submit-to-sveracun") { val principal = call.principal()!! 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: CONSTRAINT uq_hr_einvoice_submissions_invoice UNIQUE (invoice_id) — THE load-bearing constraint; prevents double fiscalization at the DB layer CONSTRAINT uq_hr_einvoice_submissions_idempotency UNIQUE (idempotency_key) CONSTRAINT uq_hr_einvoice_submissions_fiscal_number_org UNIQUE (org_id, fiscal_invoice_number) CONSTRAINT chk_hr_einvoice_internal_status CHECK (internal_status IN ('NUMBER_RESERVED','SUBMITTED','SUBMIT_UNCERTAIN','PENDING','ACCEPTED','REJECTED')) 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. Capability Demo (build now) Prod (parked / future) IssuerProfile abstraction YES — one ALAI/DIRECT profile in DB Same table; N tenant rows; INTERMEDIARY mode Schema V75-V78 YES — full schema from day one Same migrations; no change OIB binding invariant YES — enforced at service layer Same code; more profiles UNIQUE(invoice_id) on submissions YES — in V77 before any submit code Same constraint Retry-fix on send path YES — sendClient (no retry) Same fix Persist-before/after protocol YES — full protocol Same protocol SUBMIT_UNCERTAIN state YES — must be representable Same state GCS write at NUMBER_RESERVED YES — write-once, SHA-256 Same; LOCKED retention policy added Gapless numbering (FOR UPDATE) YES — counter table V76 Same; per-tenant issuer_oib separates sequences HR einvoice archive row (V78) YES — written on ACCEPTED Same; 11-year LOCKED policy for prod sveRačun base URL TEST (test.sveracun.hr) PROD (hr.sveracun.hr) SVERACUN_HR_LIVE gate Explicit flip required (default false) PROD env flag; separate secret IssuerProfile.enabled gate Explicit DB update required Same; per-tenant enable flow Background poll worker Manual: POST /invoices/{id}/poll-sveracun-status Scheduled job (Cloud Run Job or scheduler) GCS retention policy 90 days (demo bucket; not locked) 4015 days LOCKED (WORM) RLS mode PERMISSIVE (current ADR-017 state) RESTRICTIVE (Phase 2C; Securion gate) PostLink posrednik contract (B2) Not required; DIRECT mode Required before multi-tenant; legal track ALAI Norwegian entity HR OIB (B1) Not required; using existing TEST creds Legal confirmation required Credit note (InvoiceTypeCode 381) Not built; domain model records the type Must be built for full B2B accounting Rate limiting (durable) In-memory sliding window; 10/min, 100/day per org Redis-backed (Cloud Memorystore) Items NOT Deferred (frequently deferred in prototype builds; not here) Flyway migrations V75-V78 — schema before any submit code The UNIQUE (invoice_id) constraint — non-negotiable from the first migration The retry fix on sendDocument() — before any live call, including TEST The OIB binding invariant — runtime enforcement, not just a comment The GCS write at NUMBER_RESERVED — even for demo; write-once pattern identical to prod The SUBMIT_UNCERTAIN state — sveRačun TEST is not perfectly reliable 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. Flyway migrations V75, V76, V77, V78 — all four tables with constraints, indexes, RLS, grants SveRacunHttpClient : split into sendClient (maxRetries=0) + pollClient (maxRetries=3). Existing 42 tests remain green; add test asserting no retry on sendDocument() for 5xx IssuerProfile data class + SubmissionMode enum IssuerProfileRepository interface + DbIssuerProfileRepository SveRacunHrEInvoiceAdapter.serialize(invoice, senderOib: String) — add senderOib param; remove httpClient.configuredSenderVat usage HrEInvoiceNumberService.reserveNextNumber(orgId, issuerOib, fiscalYear): String — UPSERT + SELECT FOR UPDATE + increment 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 HrEInvoiceService.submitInvoice() — full persist-before/after protocol, OIB binding invariant, status gate (409 if in flight), IssuerProfile lookup, GCS write, LoggedAction HrEInvoiceService.pollAndUpdateStatus() — only if SUBMITTED/PENDING/SUBMIT_UNCERTAIN; archive write on ACCEPTED 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 All four route handlers (thin layer over service, mirrors SefRoutes.kt) Rate limit middleware: 10 submit requests/org/minute, 100/org/day (in-memory ConcurrentHashMap sliding window) 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 Terraform: bilko-hr-einvoice-archive-demo GCS bucket — versioning, write-once IAM, 90-day lifecycle Verify bilko-sveracun-test-api-key exists and Cloud Run SA has secretmanager.versions.access Secret rotation runbook documented in BookStack 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 Delete StorecoveHrFiskEInvoiceAdapter.kt (652 lines, abandoned provider, confirmed CEO decision MC #8675) 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 Submit a real invoice through the route (SVERACUN_HR_LIVE=true, TEST env, IssuerProfile.enabled=true) Assert HTTP 200 + non-null documentId received and persisted in DB Assert GCS object exists and SHA-256(GCS bytes) == xml_sha256_hex from DB Trigger poll; assert status transitions (PENDING → ACCEPTED on TEST env) Verify status and XML download routes Security checks: wrong orgId → 404; already SUBMITTED → 409; invalid OIB → 422; unauthenticated → 401 Rate limit: 101st submit → 429 Audit: LoggedAction row present with correct event, no PII in values 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) This ADR page (published) 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 Q1 Posrednik / 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? Q2 companyVatNumber 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? Q3 PROD 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? Q4 Fiscalization 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? Q5 REJECTION_REPORT Payload: What structured data is in FISCALIZATION_REJECTION_REPORT? Rejection reason code and free text? Q6 Document Retrieval API: Does sveRačun provide a GET /documents/{id}/download endpoint? Critical for SUBMIT_UNCERTAIN reconciliation path. Q7 List 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. Q8 Norwegian Entity Eligibility (B1): Is ALAI Holding AS (Norwegian org.nr, holding HR OIB HR91276104352) eligible as a platform intermediary under PostLink's terms? Q9 Pricing: Per-document pricing for an intermediary platform account. Setup fee per registered sender OIB. 7. Risk Register Risk Probability Impact Mitigation Crash between HTTP 200 and AFTER tx (Kleppmann §5) Low (Cloud Run reliability) CRITICAL Clarify Q7 (list-by-reference API) with PostLink. Admin recovery endpoint in WP2 as fallback. Document the gap explicitly. sveRačun TEST API unreliable during demo Medium HIGH SUBMIT_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 REJECTED Low (by design) Low Service 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 call Low MEDIUM If 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 prod Certain (currently PERMISSIVE) CRITICAL for multi-tenant Securion prod gate checklist (WP7 BookStack). Block prod activation on this item. PostLink posrednik contract takes longer than expected High (legal/commercial) HIGH for multi-tenant; LOW for demo Demo runs DIRECT mode; no contract required. Architecture does not change. sveRačun PROD base URL differs in auth scheme Unknown MEDIUM Q3 to PostLink. The baseUrlOverride + apiKeyOverride parameters allow runtime configuration without code change. Double-fiscal number if FOR UPDATE not atomic in pgBouncer Medium without care CRITICAL Use 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 IssuerProfile Medium HIGH The 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) B1: Legal confirmation of ALAI Holding AS (Norwegian entity) as a valid HR OIB holder and eRačun issuer. Legal track; no code dependency. B2: PostLink intermediary (posrednik) contract, power-of-attorney template for each tenant, per-sender OIB registration. Commercial track; IssuerProfile abstraction already built (WP1). InvoiceTypeCode 381 (credit note): Zachariadis §4.2 is the authoritative spec. Separate MC. TaxExemptionReason BT-120: Required for EN 16931 business rules BR-E-10 and BR-Z-10 (0%/exempt VAT). Post-demo. FISCALIZATION_REJECTION_REPORT workflow: User-facing notification + credit note issuance path. Post-demo. NOT_DELIVERED_REPORT distinct state: Fiscalized-but-not-delivered accounting problem. Post-demo. Background poll worker: Cloud Run Job or Cloud Scheduler. Architecture designed for it (next_poll_at + partial index); not built in this sprint. Phase 2C RLS RESTRICTIVE mode: Securion gate before any multi-tenant prod activation. Currently PERMISSIVE (ADR-017). Fiscal identifier storage (Q4): If PostLink confirms ZKI/JIR equivalent on FISCALIZATION:OK, add fiscal_identifier column in V79 migration. Redis-backed rate limiting: In-memory acceptable for demo. Prod requires Cloud Memorystore (Redis) for durability across multiple Cloud Run instances. 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)