# Architecture

# High-Level Design (HLD)

# Bilko — High-Level Design (HLD)

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

---

## Table of Contents

1. [System Overview](#1-system-overview)
2. [Monorepo Structure](#2-monorepo-structure)
3. [Component Architecture](#3-component-architecture)
4. [Data Flow](#4-data-flow)
5. [Tech Stack Rationale](#5-tech-stack-rationale)
6. [Multi-Tenancy Model](#6-multi-tenancy-model)
7. [Authentication Architecture](#7-authentication-architecture)
8. [Multi-Currency Architecture](#8-multi-currency-architecture)
9. [Country Plugin System](#9-country-plugin-system)
10. [Infrastructure Overview](#10-infrastructure-overview)
11. [Security Model](#11-security-model)

---

## 1. System Overview

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

**Key design goals:**

- 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

```mermaid
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

```mermaid
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

```mermaid
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

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

```mermaid
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

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

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

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

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

**Token storage:**
- Access token: returned in response body, client stores in memory
- Refresh token: `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

```mermaid
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

1. [API Endpoint Specifications](#1-api-endpoint-specifications)
2. [Database Schema Documentation](#2-database-schema-documentation)
3. [Service Layer Design](#3-service-layer-design)
4. [Middleware Stack](#4-middleware-stack)
5. [Double-Entry Bookkeeping Implementation](#5-double-entry-bookkeeping-implementation)
6. [Tax Calculation Logic Per Country](#6-tax-calculation-logic-per-country)
7. [Invoice Lifecycle](#7-invoice-lifecycle)
8. [Bank Import Flow](#8-bank-import-flow)
9. [Core Engine Modules](#9-core-engine-modules)

---

## 1. API Endpoint Specifications

**Base URL:** `/api/v1`
**Auth:** All endpoints except `/auth/*` and `/health` require `Authorization: Bearer <accessToken>`
**Content-Type:** `application/json`
**Error format:**
```json
{
  "error": "Human-readable message",
  "code": "ERROR_CODE",
  "details": {}
}
```

---

### 1.1 Health

#### `GET /api/v1/health`
No auth required.

**Response 200:**
```json
{ "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:**
```json
{
  "organizationName": "Acme DOO",
  "country": "RS",
  "baseCurrency": "RSD",
  "language": "sr",
  "registrationNumber": "12345678",
  "vatNumber": "123456789",
  "email": "user@acme.rs",
  "password": "securepassword",
  "fullName": "Marko Marković"
}
```

**Response 201:**
```json
{
  "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:**
```json
{ "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:**
```json
{ "accessToken": "jwt..." }
```

**Errors:** `401 NO_TOKEN`, `401 TOKEN_EXPIRED`, `401 INVALID_TOKEN`

---

#### `POST /api/v1/auth/logout`
Clears `refreshToken` cookie.

**Response 204:** No content.

---

#### `GET /api/v1/auth/me`
Requires `Authorization: Bearer <accessToken>`.

**Response 200:**
```json
{
  "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:**
```json
{
  "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:**
```json
{
  "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:**
```json
{
  "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):**
```json
{
  "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:**
```json
{ "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:**
```json
{ "to": "customer@example.com", "subject": "Invoice ...", "message": "..." }
```

**Response 200:**
```json
{ "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:**
```json
{
  "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:**
```json
{
  "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:**
```json
{
  "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:**
```json
{
  "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:**
```json
{
  "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:**
```json
{
  "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:**
```json
{
  "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:**
```json
{
  "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:**
```json
{ "imported": 45, "duplicates": 3, "errors": 0 }
```

#### `POST /api/v1/bank-accounts/:id/reconcile`
**Request body:**
```json
{
  "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:**
```json
{
  "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:**
```json
{ "email": "newuser@acme.rs", "fullName": "Jana Jović", "role": "accountant" }
```

#### `PUT /api/v1/users/:id/role`
Requires `owner` role only.

**Request body:**
```json
{ "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

```mermaid
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:**
```json
{
  "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)

```typescript
// 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

```typescript
// 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 |

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

```typescript
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 |

```typescript
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)

```typescript
// 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.

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

### 7.3 Amount Calculation

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

All using `Decimal.js` — never JavaScript `number`.

---

## 8. Bank Import Flow

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

### 8.1 CSV Format

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

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

### 8.2 Import Process

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

### 8.3 Reconciliation

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

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

---

## 9. Core Engine Modules

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

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

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

# Validation Report

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

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

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

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

**Design System Consistency:**
- ✅ **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:**

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

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

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

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

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

6. **Risk Assessment:**
   - **LOW RISK:** BiH e-invoicing pending (can use PDF invoices initially)
   - **LOW RISK:** Serbia SEF integration requires digital cert (defer to post-MVP)
   - **MEDIUM RISK:** Competitive market (mitigated by Balkan localization)

7. **Go-to-Market Strategy:**
   - Launch: Serbia first (largest market, SEF integration differentiator)
   - Expand: Croatia (e-invoicing mandate = forced adoption)
   - Expand: BiH (when e-invoicing regulations finalized)

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

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

**Issues:** None

---

## Cross-Document Consistency Check

**CLAUDE.md files:**
- ✅ /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:

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

**READY FOR GATE 8 CEO APPROVAL.**

---

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

# Bilko — Project Handbook

# Bilko — Balkan Accounting SaaS

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

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

## Specs Location
All specs in `~/system/specs/bilko-*.md`:
- 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

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

## Current Status

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

## Gate Validation Summary (2026-02-20)

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

### Gate 4: Product Requirements — **PASS**
- ✅ 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:**
1. Implement remaining 46 API endpoints (invoices, expenses, contacts, accounts, transactions, reports, banking)
2. Create Zod validators for all endpoints
3. Add integration tests for auth flow
4. Connect frontend to real backend (replace mock data)
5. Beta testing with 5 SMBs + 3 accountants

## Status: DEVELOPMENT IN PROGRESS

**All 8 gates PASSED — Project approved and active**

## Decision Log

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

## Notes

- **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

<div class="callout info" id="bkmrk-mc-%23100025-%7C-publish" style="background: #d1ecf1; border-left: 4px solid #0c5460; padding: 1em; margin: 1em 0;"> **MC #100025** | Published 2026-05-08 | Status: Approved (Pattern 3 — Skybound)   
**Related:** [SPEC-022](https://docs.alai.no/books/bilko-balkan-accounting-saas/page/spec-022-document-archive-implementation) • [COMPLIANCE-022](https://docs.alai.no/books/bilko-balkan-accounting-saas/page/compliance-022-archive-review-hipaagdprcqc) </div># 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 &lt;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

<table id="bkmrk-criterionweightpatte"><tr><td>Criterion</td><td>Weight</td><td>Pattern 1 (Email)</td><td>Pattern 2 (Direct API)</td><td>Pattern 3 (Blob Queue)</td></tr><tr><td>--------------------------</td><td>------</td><td>-----------------</td><td>----------------------</td><td>----------------------</td></tr><tr><td>Multi-tenant scoping</td><td>HIGH</td><td>3/5</td><td>4/5</td><td>5/5</td></tr><tr><td>Bilko coupling</td><td>HIGH</td><td>5/5</td><td>2/5</td><td>5/5</td></tr><tr><td>Paperless coupling</td><td>HIGH</td><td>4/5</td><td>1/5</td><td>5/5</td></tr><tr><td>Retry/idempotency</td><td>HIGH</td><td>2/5</td><td>3/5</td><td>5/5</td></tr><tr><td>Auth model</td><td>MED</td><td>5/5</td><td>2/5</td><td>4/5</td></tr><tr><td>Dev velocity</td><td>MED</td><td>5/5</td><td>4/5</td><td>3/5</td></tr><tr><td>Ops surface</td><td>MED</td><td>4/5</td><td>5/5</td><td>3/5</td></tr><tr><td>Cross-cloud friendliness</td><td>MED</td><td>5/5</td><td>3/5</td><td>5/5</td></tr><tr><td>Dedup strategy</td><td>LOW</td><td>2/5</td><td>4/5</td><td>5/5</td></tr><tr><td>Scalability (&gt;1k docs/day)</td><td>LOW</td><td>2/5</td><td>5/5</td><td>5/5</td></tr><tr><td>**TOTAL (weighted sum)**</td><td>—</td><td>**3.6/5**</td><td>**3.2/5**</td><td>**4.6/5**</td></tr></table>

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

```json
{
  "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, &lt;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`:

```kotlin
suspend fun archiveDocument(
    organizationId: UUID,
    organizationName: String,
    documentType: String,  // "invoice" | "contract" | "care_plan"
    documentBuffer: ByteArray,
    metadata: Map<string string="">  // { "invoiceNumber": "2024-001", ... }
): String {
    val sha256 = documentBuffer.sha256()
    val objectKey = "archive-queue/${organizationId}/${documentType}/${sha256}.pdf"
<p>    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
    )</p>
<p>    return objectKey
}
</p></string>
```

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

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

```kotlin
// apps/archiver-worker/src/main/kotlin/no/alai/bilko/archiver/Main.kt
<p>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")
    )</p>
<p>    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)</p>
<p>                // 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
                }</p>
<p>                // 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"]!!
                    )
                )</p>
<p>                logger.info("Archived ${obj.key} → Paperless #${paperlessDoc.id}")
                s3Client.deleteObject(obj.key)</p>
<p>            } catch (e: Exception) {
                logger.error("Failed to archive ${obj.key}: ${e.message}", e)
                // Leave object in R2, retry on next run
            }
        }
    }
}
</p>
```

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

```yaml
<h1 id="bkmrk-infrastructure%2Fgcp%2Ft-1">infrastructure/gcp/terraform/modules/archiver-worker/main.tf</h1>
<p>resource "google_cloud_run_v2_job" "bilko_archiver_worker" {
  name     = "bilko-archiver-worker"
  location = var.region</p>
<p>  template {
    template {
      containers {
        image = "europe-north1-docker.pkg.dev/${var.project_id}/bilko/archiver-worker:latest"</p>
<p>        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"
            }
          }
        }
      }</p>
<p>      timeout = "600s"  # 10min max
    }
  }
}</p>
<p>resource "google_cloud_scheduler_job" "archiver_trigger" {
  name      = "bilko-archiver-cron"
  schedule  = "*/5 * * * *"  # Every 5 minutes
  time_zone = "Europe/Oslo"</p>
<p>  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"</p>
<p>    oauth_token {
      service_account_email = google_service_account.archiver_worker.email
    }
  }
}
</p>
```

3\. **Monitoring dashboard** (Cloud Monitoring): - Queue depth (R2 objects in `archive-queue/` prefix) — alert if &gt;500 - Worker success rate — alert if &lt;95% over 1h - Worker execution time — alert if &gt;300s - Paperless API error rate — alert if &gt;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 &lt;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

<div class="callout info" id="bkmrk-mc-%23100025-%7C-publish" style="background: #d1ecf1; border-left: 4px solid #0c5460; padding: 1em; margin: 1em 0;"> **MC #100025** | Published 2026-05-08 | Status: Approved (Pattern 3 — Skybound)   
**Related:** [ADR-022](https://docs.alai.no/books/bilko-balkan-accounting-saas/page/adr-022-document-archive-strategy) • [COMPLIANCE-022](https://docs.alai.no/books/bilko-balkan-accounting-saas/page/compliance-022-archive-review-hipaagdprcqc) </div># 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:<organizationid></organizationid>` 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

<table id="bkmrk-componentlocationtyp"><tr><td>Component</td><td>Location</td><td>Type</td><td>Purpose</td></tr><tr><td>------------------------------------</td><td>------------------------------------------------------------------------------------</td><td>-------------------------------------</td><td>----------------------------------------------------------------------------------------------------------------------------</td></tr><tr><td>`ArchiveService`</td><td>`apps/api/src/main/kotlin/no/alai/bilko/services/ArchiveService.kt`</td><td>New Kotlin service</td><td>Writes PDF + `.meta.json` sidecar to R2 `bilko-archive-queue` bucket; returns `ArchiveJobId`</td></tr><tr><td>R2 bucket `bilko-archive-queue`</td><td>Cloudflare R2 (separate from existing `AWS_S3_BUCKET`)</td><td>New bucket</td><td>Staging queue for pending Paperless uploads</td></tr><tr><td>R2 bucket `bilko-archive-dlq`</td><td>Cloudflare R2</td><td>New bucket</td><td>Dead-letter queue for objects that failed 3 upload attempts</td></tr><tr><td>`archiver-worker`</td><td>`apps/archiver-worker/`</td><td>New Cloud Run job (Node.js — see §10)</td><td>Polls R2 → uploads to Paperless → deletes R2 objects</td></tr><tr><td>Cloud Scheduler trigger</td><td>GCP Cloud Scheduler `bilko-archiver-cron`</td><td>New scheduler job</td><td>Fires `archiver-worker` Cloud Run job every 5 minutes (per CEO decision D1)</td></tr><tr><td>Flyway migration `V_archive_status`</td><td>`apps/api/src/main/resources/db/migration/`</td><td>New migration</td><td>Adds `archive_status`, `archive_job_id`, `paperless_doc_url`, `archived_at` columns to `invoices` and future document tables</td></tr><tr><td>`ArchiveAuditLog`</td><td>`apps/api/src/main/kotlin/no/alai/bilko/model/ArchiveAuditLog.kt` + Flyway migration</td><td>New DB table</td><td>Per-document archive status: `pending`, `archived`, `failed`</td></tr><tr><td>Bilko DB table `org_paperless_cache`</td><td>PostgreSQL, Flyway migration</td><td>New table</td><td>Caches `organizationId → paperless_correspondent_id` and `organizationId → paperless_org_tag_id` to avoid repeat API calls</td></tr></table>

\---

## 3. Interfaces

### 3.1 ArchiveService — Kotlin signature

```
// File: apps/api/src/main/kotlin/no/alai/bilko/services/ArchiveService.kt
// Package: no.alai.bilko.services
<p>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<string string="">  // e.g. { "invoiceNumber": "2024-001", "contractId": "abc" }
)</string></p>
<p>data class ArchiveOptions(
    val priority: ArchivePriority = ArchivePriority.NORMAL  // NORMAL | HIGH (for future urgency flag)
)</p>
<p>// Return type — opaque job ID (R2 object key)
typealias ArchiveJobId = String</p>
<p>// 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
</p>
```

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/<organizationid>/<documenttype>/<sha256>.pdf
org/<organizationid>/<documenttype>/<sha256>.meta.json
</sha256></documenttype></organizationid></sha256></documenttype></organizationid>
```

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

```json
{
  "schemaVersion": "1",
  "r2Uuid": "<sha256>",
  "organizationId": "550e8400-e29b-41d4-a716-446655440000",
  "organizationName": "Firma AS",
  "documentType": "invoice",
  "bilkoDocumentId": "<uuid bilko="" db="" in="" invoice="" of="" row="">",
  "invoiceNumber": "2024-001",
  "contractId": null,
  "timestamp": "2026-05-08T10:30:00Z",
  "sha256": "a1b2c3d4...ef",
  "retryCount": 0,
  "lastAttemptAt": null,
  "lastError": null
}
</uuid></sha256>
```

- `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:<sha256></sha256>` 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_id>
CF-Access-Client-Secret: <cf_access_client_secret>
Authorization: Token <paperless_api_token>
Content-Type: multipart/form-data
<p>Fields:
  document         — PDF binary (required)
  title            — "<documenttype> — <organizationname> <isodate>"  (e.g. "Invoice — Firma AS 2026-05-08")
  correspondent    — <paperless_correspondent_id>  (integer, pre-resolved by worker — see §5)
  document_type    — <paperless_document_type_id>  (integer, mapped from documentType enum)
  tags             — [<org_tag_id>, <doc_type_tag_id>, <bilko_source_tag_id>, <bilko_source_uuid_tag_id>]
  created          — <iso date="" document="" of="" original="" upload="">
  custom_fields    — [{"field": <sha256_field_id>, "value": "<sha256>"}, {"field": <org_id_field_id>, "value": "<organizationid>"}, {"field": <uploaded_at_field_id>, "value": "<timestamp>"}]
</timestamp></uploaded_at_field_id></organizationid></org_id_field_id></sha256></sha256_field_id></iso></bilko_source_uuid_tag_id></bilko_source_tag_id></doc_type_tag_id></org_tag_id></paperless_document_type_id></paperless_correspondent_id></isodate></organizationname></documenttype></p></paperless_api_token></cf_access_client_secret></cf_access_client_id>
```

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 = <url></url>`, `archived_at = NOW()`. 4. `UPDATE` Bilko DB source document table (e.g. `invoices`): `archive_status = 'archived'`, `paperless_doc_url = <url></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-<organizationid></organizationid>` (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-<organizationid>", "match": "", "matching_algorithm": 0, "is_insensitive": false }
</organizationid>
```

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

```sql
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-<uuid></uuid>` is canonical to prevent name collisions and to survive org renames in Bilko.

### 5.2 Tag strategy

Every archived document receives exactly these tags:

<table id="bkmrk-tagpurposecreated-by"><tr><td>Tag</td><td>Purpose</td><td>Created by</td></tr><tr><td>------------------------------------------------------------------------</td><td>--------------------------------------------------------------</td><td>------------------------------------------------------------------</td></tr><tr><td>`org:<organizationid></organizationid>`</td><td>Tenant isolation — one tag per Bilko org</td><td>Worker on first archive for org</td></tr><tr><td>`doc-type:invoice` (or contract, care-plan, incident-report, onboarding)</td><td>Document type filter</td><td>Worker — static set, pre-created in Paperless during initial setup</td></tr><tr><td>`bilko-source`</td><td>Identifies all documents archived from Bilko (across all orgs)</td><td>Pre-created in Paperless during initial setup</td></tr><tr><td>`bilko-source-uuid:<sha256></sha256>`</td><td>Idempotency dedup key — prevents duplicate Paperless documents</td><td>Worker — unique per document</td></tr></table>

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=<org_tag_id>&page=1&page_size=25
</org_tag_id>
```

For filtering by doc type within an org:

```
GET https://archive.alai.no/api/documents/?tags__id__in=<org_tag_id>,<doc_type_tag_id>
</doc_type_tag_id></org_tag_id>
```

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 &lt; 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:

<table id="bkmrk-document-typerecomme"><tr><td>Document Type</td><td>Recommended Retention</td><td>Legal Basis</td></tr><tr><td>--------------------</td><td>---------------------------------</td><td>------------------------------------------------------------------------</td></tr><tr><td>Invoices</td><td>7 years</td><td>Norway Bokføringsloven §13; Serbia Zakon o računovodstvu; BiH equivalent</td></tr><tr><td>Contracts</td><td>Indefinite until expiry + 5 years</td><td>Standard contract law (Norway, Serbia, BiH, Croatia)</td></tr><tr><td>Care plans</td><td>25 years</td><td>NHS/CQC standard (applicable if Bilko expands to UK healthcare)</td></tr><tr><td>Incident reports</td><td>7 years</td><td>General audit retention standard</td></tr><tr><td>Onboarding documents</td><td>5 years post-customer-offboarding</td><td>GDPR Art. 5(1)(e) storage limitation</td></tr></table>

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=<sha256></sha256>` — 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/<orgid>/<doctype>/<sha256>.pdf</sha256></doctype></orgid>`. 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:<sha256></sha256>` 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

<table id="bkmrk-erroraction---------"><tr><td>Error</td><td>Action</td></tr><tr><td>----------------</td><td>--------------------------------------------------------------------------------------------------------------------------------------------------------------------</td></tr><tr><td>401 Unauthorized</td><td>Token expired/rotated. Alert `dev@alai.no` immediately. Worker stops processing (do not retry — all subsequent calls will also 401). Manual token rotation required.</td></tr><tr><td>403 Forbidden</td><td>CF Access token issue. Same action as 401.</td></tr><tr><td>429 Rate Limited</td><td>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.</td></tr><tr><td>500/502/503</td><td>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.</td></tr><tr><td>Network timeout</td><td>Same as 5xx. Worker `fetch()` timeout = 30 seconds per request.</td></tr></table>

### 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:

```json
{
  "severity": "INFO|WARNING|ERROR",
  "timestamp": "<iso>",
  "r2Key": "<object key="">",
  "organizationId": "<uuid>",
  "documentType": "<type>",
  "action": "UPLOAD_SUCCESS|UPLOAD_FAILED|DEDUP_SKIP|DLQ_MOVE",
  "paperlessDocId": <integer null="" or="">,
  "retryCount": <integer>,
  "durationMs": <integer>,
  "error": "<message null="" or="">"
}
</message></integer></integer></integer></type></uuid></object></iso>
```

\---

## 9. Observability

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

<table id="bkmrk-metrictypedescriptio"><tr><td>Metric</td><td>Type</td><td>Description</td></tr><tr><td>------------------------------</td><td>---------</td><td>-----------------------------------------------------------------------------------</td></tr><tr><td>`archive_jobs_processed_total`</td><td>Counter</td><td>Total R2 objects successfully uploaded to Paperless</td></tr><tr><td>`archive_jobs_failed_total`</td><td>Counter</td><td>Total R2 objects that failed upload (all retry attempts)</td></tr><tr><td>`archive_queue_depth`</td><td>Gauge</td><td>Count of objects currently in `bilko-archive-queue` (R2 ListObjectsV2 at job start)</td></tr><tr><td>`archive_e2e_latency_seconds`</td><td>Histogram</td><td>Time from R2 object `timestamp` in `.meta.json` to confirmed Paperless upload</td></tr><tr><td>`archive_dlq_depth`</td><td>Gauge</td><td>Count of objects in `bilko-archive-dlq` (alert if &gt; 0)</td></tr></table>

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

<table id="bkmrk-conditionseveritycha"><tr><td>Condition</td><td>Severity</td><td>Channel</td></tr><tr><td>------------------------------------------</td><td>--------</td><td>-----------------------------------------------------</td></tr><tr><td>`archive_dlq_depth > 0`</td><td>P1</td><td>`dev@alai.no` (existing Cloud Monitoring alert email)</td></tr><tr><td>`archive_queue_depth > 500` for 15 minutes</td><td>P2</td><td>`dev@alai.no` — worker may have stopped</td></tr><tr><td>Worker job not invoked in &gt;10 minutes</td><td>P2</td><td>Cloud Scheduler missed execution alert</td></tr><tr><td>`archive_jobs_failed_total` &gt; 5 in 1 hour</td><td>P2</td><td>`dev@alai.no`</td></tr><tr><td>Paperless 401 in worker logs</td><td>P1</td><td>`dev@alai.no` — token rotation required</td></tr></table>

### 9.3 Bilko DB audit log

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

```sql
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 &gt;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)

<div class="callout info" id="bkmrk-mc-%23100025-%7C-publish" style="background: #d1ecf1; border-left: 4px solid #0c5460; padding: 1em; margin: 1em 0;"> **MC #100025** | Published 2026-05-08 | Status: Approved (Pattern 3 — Skybound) / Compliance gate pending (Dr. Sarah Chen M3+M5 blockers)   
**Related:** [ADR-022](https://docs.alai.no/books/bilko-balkan-accounting-saas/page/adr-022-document-archive-strategy) • [SPEC-022](https://docs.alai.no/books/bilko-balkan-accounting-saas/page/spec-022-document-archive-implementation) </div><div class="callout warning" id="bkmrk-%E2%9A%A0%EF%B8%8F-pre-emptive-block" style="background: #fff3cd; border-left: 4px solid #ffc107; padding: 1em; margin: 1em 0;"> **⚠️ 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. </div># COMPLIANCE-022: Healthcare &amp; 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

<table id="bkmrk-regulationtriggerapp"><tr><td>Regulation</td><td>Trigger</td><td>Applies?</td></tr><tr><td>--------------------------------------------------</td><td>---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------</td><td>------------------------------------------------------------------------------</td></tr><tr><td>GDPR / EU GDPR (Regulation 2016/679)</td><td>EU residency, Balkan clients in EU data space, special category Art. 9 data possible in care plans</td><td>YES — primary</td></tr><tr><td>HITECH Act (US)</td><td>Only if Bilko serves US-based covered entities or their BAs. No US presence confirmed in BUILD-BLUEPRINT.</td><td>NOT YET — but architecture must not preclude compliance if US expansion occurs</td></tr><tr><td>HIPAA Privacy + Security Rules</td><td>Same trigger as HITECH.</td><td>NOT YET — apply when US expansion scoped</td></tr><tr><td>CQC / Health and Social Care Act 2008</td><td>Only if Bilko serves UK-registered domiciliary care agencies. Not confirmed.</td><td>NOT YET — same comment</td></tr><tr><td>NIS2 Directive (EU 2022/2555)</td><td>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.</td><td>MONITOR — review at 50+ orgs</td></tr><tr><td>Norway Bokføringsloven §13</td><td>Invoices, financial records, 7-year retention</td><td>YES — invoices</td></tr><tr><td>Serbia Zakon o računovodstvu / Croatia equivalents</td><td>Same financial retention</td><td>YES — domain packages</td></tr><tr><td>GDPR Art. 17 (Right to Erasure)</td><td>Active for all EU data subjects</td><td>YES — open gap in SPEC-022 §10.4</td></tr><tr><td>GDPR Art. 28 (Sub-processor chain)</td><td>ALAI Azure VM Paperless is a sub-processor of Bilko</td><td>YES — gap in both documents</td></tr></table>

### 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

<table id="bkmrk-document-typegdpr-cl"><tr><td>Document Type</td><td>GDPR Classification</td><td>Special Category (Art. 9)?</td><td>Financial Record?</td><td>Recommended Paperless Tag</td></tr><tr><td>-------------------</td><td>-------------------------------------------------</td><td>----------------------------------------------</td><td>--------------------------------</td><td>--------------------------------------</td></tr><tr><td>Invoice</td><td>Personal data (contact name, address, VAT ID)</td><td>No</td><td>Yes (Bokføringsloven, 7y)</td><td>`data-class:financial`</td></tr><tr><td>Contract</td><td>Personal data (signatories, company data)</td><td>No</td><td>Quasi-financial (5y post-expiry)</td><td>`data-class:legal`</td></tr><tr><td>Care plan</td><td>**Special category health data**</td><td>YES — diagnosis, medication, functional status</td><td>No</td><td>`data-class:health` `sensitivity:high`</td></tr><tr><td>Incident report</td><td>**Special category health/social data**</td><td>YES — if describes injury, clinical event</td><td>Potentially</td><td>`data-class:health` `sensitivity:high`</td></tr><tr><td>Onboarding document</td><td>Personal data (identity verification, scanned ID)</td><td>No (unless medical screen)</td><td>No</td><td>`data-class:identity`</td></tr></table>

### Tag strategy amendment

SPEC-022 §5.2 defines four tag types: `org:<uuid></uuid>`, `doc-type:*`, `bilko-source`, `bilko-source-uuid:<sha256></sha256>`.

**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:<uuid></uuid>` 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:<uuid></uuid>` 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 <vm-name> --resource-group <rg></rg></vm-name>` 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:<uuid></uuid>` 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/<uuid>/</uuid>` and delete all. 5. Query Paperless: `GET /api/documents/?tags__id__in=<org_tag_id></org_tag_id>`, delete all results. 6. Delete Paperless correspondent: `DELETE /api/correspondents/<id></id>`. 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

<table id="bkmrk-scenarioseveritygdpr"><tr><td>Scenario</td><td>Severity</td><td>GDPR notification</td><td>Responsible party</td></tr><tr><td>---------------------------------------------------------</td><td>-----------------------------------------------------------------</td><td>---------------------------------------------------------------------------------------------------</td><td>------------------------------------------------------------</td></tr><tr><td>Bilko Cloud Run API compromise (R2 staging queue exposed)</td><td>HIGH if health data in queue</td><td>72h to supervisory authority (Datatilsynet, Norway; or relevant Balkan DPA)</td><td>ALAI (as Bilko operator)</td></tr><tr><td>Azure VM compromise (Paperless data exposed)</td><td>HIGH</td><td>72h — triggers sub-processor notification chain: ALAI Azure → ALAI Bilko team → tenant notification</td><td>ALAI (as sub-processor); tenant notifies their data subjects</td></tr><tr><td>Worker credential leak (CF Access + Paperless API token)</td><td>MEDIUM-HIGH (allows read of all archived docs across all tenants)</td><td>72h if PHI/health data accessible</td><td>ALAI</td></tr><tr><td>Cross-tenant Paperless UI access (human error)</td><td>MEDIUM</td><td>72h if health data accessed</td><td>ALAI</td></tr></table>

### 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)

<table id="bkmrk-iddocumentsectionreq"><tr><td>ID</td><td>Document</td><td>Section</td><td>Required change</td></tr><tr><td>---</td><td>------------------</td><td>-------------</td><td>-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------</td></tr><tr><td>M1</td><td>SPEC-022</td><td>§5.2</td><td>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.</td></tr><tr><td>M2</td><td>SPEC-022</td><td>§9.3</td><td>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.</td></tr><tr><td>M3</td><td>ADR-022 + SPEC-022</td><td>§4 / §Context</td><td>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.</td></tr><tr><td>M4</td><td>SPEC-022</td><td>§10.4</td><td>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.</td></tr><tr><td>M5</td><td>ADR-022</td><td>§Consequences</td><td>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.</td></tr><tr><td>M6</td><td>SPEC-022</td><td>§4 / §9</td><td>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.</td></tr></table>

### SHOULD — best practice (not immediate ship blockers)

<table id="bkmrk-iddocumentsectionrec"><tr><td>ID</td><td>Document</td><td>Section</td><td>Recommended change</td></tr><tr><td>---</td><td>--------</td><td>--------</td><td>--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------</td></tr><tr><td>S1</td><td>SPEC-022</td><td>§5.3</td><td>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.</td></tr><tr><td>S2</td><td>SPEC-022</td><td>§4.3</td><td>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.</td></tr><tr><td>S3</td><td>ADR-022</td><td>§Phase 4</td><td>Create child MC for automated erasure worker before enabling care plan archival. Manual erasure is not appropriate for health data under GDPR Art. 17.</td></tr><tr><td>S4</td><td>SPEC-022</td><td>§6.2</td><td>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).</td></tr><tr><td>S5</td><td>SPEC-022</td><td>§10</td><td>Add Breach Notification Runbook to RUNBOOK.md (§8.2 of this review) as child MC. Required before any production data flows through the pipeline.</td></tr><tr><td>S6</td><td>ADR-022</td><td>§Context</td><td>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.</td></tr></table>

\---

## 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

<div id="bkmrk-status%3A-design-accep" style="background:#fff3cd;border:1px solid #ffc107;border-left:5px solid #e6a817;padding:16px 20px;margin-bottom:24px;border-radius:4px;">**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. </div>**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](https://boards.alai.no) (architecture documentation) | [\#103464](https://boards.alai.no) (build execution)  
**Cross-link:** [Bilko HR eRačun — sveRačun (PostLink) Integration &amp; Status Model](https://docs.alai.no/books/bilko-balkan-accounting-saas/page/bilko-hr-eracun-sveracun-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.

<table id="bkmrk-stateinternal_status"><thead><tr><th>State</th><th>`internal_status`</th><th>`sveracun_document_id`</th><th>Meaning</th></tr></thead><tbody><tr><td>NOT\_SUBMITTED</td><td>NULL (no row)</td><td>NULL</td><td>Invoice exists; no submission row</td></tr><tr><td>NUMBER\_RESERVED</td><td>`NUMBER_RESERVED`</td><td>NULL</td><td>Fiscal number locked; XML serialized; GCS written; HTTP not yet called</td></tr><tr><td>SUBMITTED</td><td>`SUBMITTED`</td><td>`<docId>`</td><td>HTTP 200 + documentId received and persisted</td></tr><tr><td>SUBMIT\_UNCERTAIN</td><td>`SUBMIT_UNCERTAIN`</td><td>NULL</td><td>Sent (maybe); no documentId received (timeout / conn err / no docId in 200 body)</td></tr><tr><td>PENDING</td><td>`PENDING`</td><td>`<docId>`</td><td>sveRačun still processing (UNKNOWN or null external)</td></tr><tr><td>ACCEPTED</td><td>`ACCEPTED`</td><td>`<docId>`</td><td>Terminal success: internal=OK + external=FISCALIZATION:OK</td></tr><tr><td>REJECTED</td><td>`REJECTED`</td><td>`<docId>` or NULL</td><td>Terminal failure: FAILED/UNDELIVERABLE/FISCALIZATION:ERROR/4xx etapa-1</td></tr></tbody></table>

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

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

**HTTP call (outside any transaction):**

- `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<HrEInvoiceService>()

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

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

### 3.4 Persistence Schema — Flyway V75–V78

#### V75 — hr\_einvoice\_issuer\_config

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

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

#### V76 — hr\_einvoice\_number\_counters

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

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

#### V77 — hr\_einvoice\_submissions

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

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

**Critical constraints:**

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

<table id="bkmrk-capabilitydemo-%28buil"><thead><tr><th>Capability</th><th>Demo (build now)</th><th>Prod (parked / future)</th></tr></thead><tbody><tr><td>IssuerProfile abstraction</td><td>YES — one ALAI/DIRECT profile in DB</td><td>Same table; N tenant rows; INTERMEDIARY mode</td></tr><tr><td>Schema V75-V78</td><td>YES — full schema from day one</td><td>Same migrations; no change</td></tr><tr><td>OIB binding invariant</td><td>YES — enforced at service layer</td><td>Same code; more profiles</td></tr><tr><td>UNIQUE(invoice\_id) on submissions</td><td>YES — in V77 before any submit code</td><td>Same constraint</td></tr><tr><td>Retry-fix on send path</td><td>YES — sendClient (no retry)</td><td>Same fix</td></tr><tr><td>Persist-before/after protocol</td><td>YES — full protocol</td><td>Same protocol</td></tr><tr><td>SUBMIT\_UNCERTAIN state</td><td>YES — must be representable</td><td>Same state</td></tr><tr><td>GCS write at NUMBER\_RESERVED</td><td>YES — write-once, SHA-256</td><td>Same; LOCKED retention policy added</td></tr><tr><td>Gapless numbering (FOR UPDATE)</td><td>YES — counter table V76</td><td>Same; per-tenant issuer\_oib separates sequences</td></tr><tr><td>HR einvoice archive row (V78)</td><td>YES — written on ACCEPTED</td><td>Same; 11-year LOCKED policy for prod</td></tr><tr><td>sveRačun base URL</td><td>TEST (test.sveracun.hr)</td><td>PROD (hr.sveracun.hr)</td></tr><tr><td>SVERACUN\_HR\_LIVE gate</td><td>Explicit flip required (default false)</td><td>PROD env flag; separate secret</td></tr><tr><td>IssuerProfile.enabled gate</td><td>Explicit DB update required</td><td>Same; per-tenant enable flow</td></tr><tr><td>Background poll worker</td><td>Manual: POST /invoices/{id}/poll-sveracun-status</td><td>Scheduled job (Cloud Run Job or scheduler)</td></tr><tr><td>GCS retention policy</td><td>90 days (demo bucket; not locked)</td><td>4015 days LOCKED (WORM)</td></tr><tr><td>RLS mode</td><td>PERMISSIVE (current ADR-017 state)</td><td>RESTRICTIVE (Phase 2C; Securion gate)</td></tr><tr><td>PostLink posrednik contract (B2)</td><td>Not required; DIRECT mode</td><td>Required before multi-tenant; legal track</td></tr><tr><td>ALAI Norwegian entity HR OIB (B1)</td><td>Not required; using existing TEST creds</td><td>Legal confirmation required</td></tr><tr><td>Credit note (InvoiceTypeCode 381)</td><td>Not built; domain model records the type</td><td>Must be built for full B2B accounting</td></tr><tr><td>Rate limiting (durable)</td><td>In-memory sliding window; 10/min, 100/day per org</td><td>Redis-backed (Cloud Memorystore)</td></tr></tbody></table>

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

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

---

## 5. Phased Build Plan (7 Work Packages)

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

**Owner:** CodeCraft (backend) | **Depends on:** None

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

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

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

### WP2 — Service + Persist Protocol: HrEInvoiceService

**Owner:** CodeCraft (backend) | **Depends on:** WP1

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

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

### WP3 — Route: SveRacunRoutes

**Owner:** CodeCraft (backend) | **Depends on:** WP2

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

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

### WP4 — Infra: GCS Bucket + Secret Wiring

**Owner:** FlowForge (infra) | **Depends on:** WP1

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

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

### WP5 — Dead Code Removal

**Owner:** CodeCraft (backend) | **Depends on:** WP3

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

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

### WP6 — Proveo E2E Validation

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

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

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

### WP7 — BookStack Documentation

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

1. This ADR page (published)
2. BookStack page: "HR eRačun — Prod Prerequisites Checklist" (Bilko book, Legal &amp; 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.

<table id="bkmrk-%23question-q1posredni"><thead><tr><th>\#</th><th>Question</th></tr></thead><tbody><tr><td>Q1</td><td>**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?</td></tr><tr><td>Q2</td><td>**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?</td></tr><tr><td>Q3</td><td>**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?</td></tr><tr><td>Q4</td><td>**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?</td></tr><tr><td>Q5</td><td>**REJECTION\_REPORT Payload:** What structured data is in FISCALIZATION\_REJECTION\_REPORT? Rejection reason code and free text?</td></tr><tr><td>Q6</td><td>**Document Retrieval API:** Does sveRačun provide a GET /documents/{id}/download endpoint? Critical for SUBMIT\_UNCERTAIN reconciliation path.</td></tr><tr><td>Q7</td><td>**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.</td></tr><tr><td>Q8</td><td>**Norwegian Entity Eligibility (B1):** Is ALAI Holding AS (Norwegian org.nr, holding HR OIB HR91276104352) eligible as a platform intermediary under PostLink's terms?</td></tr><tr><td>Q9</td><td>**Pricing:** Per-document pricing for an intermediary platform account. Setup fee per registered sender OIB.</td></tr></tbody></table>

---

## 7. Risk Register

<table id="bkmrk-riskprobabilityimpac"><thead><tr><th>Risk</th><th>Probability</th><th>Impact</th><th>Mitigation</th></tr></thead><tbody><tr><td>Crash between HTTP 200 and AFTER tx (Kleppmann §5)</td><td>Low (Cloud Run reliability)</td><td>CRITICAL</td><td>Clarify Q7 (list-by-reference API) with PostLink. Admin recovery endpoint in WP2 as fallback. Document the gap explicitly.</td></tr><tr><td>sveRačun TEST API unreliable during demo</td><td>Medium</td><td>HIGH</td><td>SUBMIT\_UNCERTAIN state is representable; demo recovery endpoint allows operator to manually enter docId. Brief the demo presenter.</td></tr><tr><td>UNIQUE(invoice\_id) constraint blocks a legitimate re-send after REJECTED</td><td>Low (by design)</td><td>Low</td><td>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.</td></tr><tr><td>GCS write fails between number allocation and HTTP call</td><td>Low</td><td>MEDIUM</td><td>If GCS write fails, rollback DB insert. Number is consumed (non-returnable per B5) but absence of submission row signals no send occurred.</td></tr><tr><td>Phase 2C RLS not activated before multi-tenant prod</td><td>Certain (currently PERMISSIVE)</td><td>CRITICAL for multi-tenant</td><td>Securion prod gate checklist (WP7 BookStack). Block prod activation on this item.</td></tr><tr><td>PostLink posrednik contract takes longer than expected</td><td>High (legal/commercial)</td><td>HIGH for multi-tenant; LOW for demo</td><td>Demo runs DIRECT mode; no contract required. Architecture does not change.</td></tr><tr><td>sveRačun PROD base URL differs in auth scheme</td><td>Unknown</td><td>MEDIUM</td><td>Q3 to PostLink. The baseUrlOverride + apiKeyOverride parameters allow runtime configuration without code change.</td></tr><tr><td>Double-fiscal number if FOR UPDATE not atomic in pgBouncer</td><td>Medium without care</td><td>CRITICAL</td><td>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).</td></tr><tr><td>Developer accidentally wires env-var path instead of IssuerProfile</td><td>Medium</td><td>HIGH</td><td>The serialize() signature change (WP1) removes httpClient.configuredSenderVat call; senderOib parameter is required (non-nullable). Caught at compile time.</td></tr></tbody></table>

---

## 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)*