# Backend

# Backend — Target Architecture

# Bilko API — Express Backend

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

## Status: NOT BUILT YET
**This directory is EMPTY.** The CLAUDE.md describes the target architecture.
When building, follow `docs/backend/API-REFERENCE.md` as the implementation contract.

## Target Tech Stack
- **Framework:** Express + TypeScript
- **Database:** PostgreSQL 15 via Prisma
- **Auth:** JWT (15min access + 7d refresh) + Passport.js
- **Validation:** Zod
- **Middleware:** helmet, cors, rate-limit, auth-guard, zod-validation, error-handler

## Route Structure
All routes under `/api/v1/{resource}`:
- `/api/v1/auth` — login, register, refresh, logout
- `/api/v1/organizations` — org CRUD
- `/api/v1/users` — user management
- `/api/v1/accounts` — chart of accounts
- `/api/v1/invoices` — invoice CRUD
- `/api/v1/expenses` — expense CRUD
- `/api/v1/transactions` — transaction ledger
- `/api/v1/contacts` — customer/vendor contacts
- `/api/v1/banking` — bank account integration
- `/api/v1/reports` — financial reports

## Middleware Stack (Order Matters)
1. **helmet** — Security headers
2. **cors** — CORS with whitelist
3. **express.json()** — Body parser
4. **rate-limit** — 100 req/15min per IP
5. **auth-guard** — JWT validation (protected routes)
6. **zod-validation** — Request validation
7. **route-handler** — Business logic
8. **error-handler** — Centralized error responses

## Error Response Format
```json
{
  "error": "Error message",
  "code": "ERROR_CODE",
  "details": {} // optional
}
```

**HTTP Status Codes:**
- 400 — Validation error
- 401 — Unauthorized (missing/invalid token)
- 403 — Forbidden (insufficient permissions)
- 404 — Not found
- 500 — Internal server error

## Database Access
- **ORM:** Prisma Client from `@bilko/database` package
- **Connection:** Read `DATABASE_URL` from env
- **Transactions:** Use Prisma transactions for multi-step operations
- **NEVER:** Raw SQL for business logic (use for migrations only)

## Authentication
- **Strategy:** JWT (access + refresh tokens)
- **Access token:** 15min expiry, httpOnly cookie
- **Refresh token:** 7d expiry, httpOnly cookie, stored in DB
- **Password:** bcrypt hash with salt rounds = 12
- **2FA:** Optional TOTP (stored in User.twoFactorSecret)

## Validation Rules
All requests validated with Zod schemas:
- **Money:** Must be string or number, converted to Decimal
- **Currency:** 3-letter ISO code (EUR, RSD, BAM, HRK)
- **Dates:** ISO 8601 format (YYYY-MM-DD)
- **UUIDs:** Valid v4 UUIDs for all IDs
- **Emails:** RFC 5322 compliant

## Double-Entry Rules (CRITICAL)
Every financial transaction MUST:
1. Have both debit and credit accounts
2. Equal amounts (debit = credit)
3. Reference the source (invoice ID, expense ID)
4. Lock exchange rate at transaction date
5. Create audit log entry (LoggedAction)

## Development Rules
1. **NEVER hold money** — This is an accounting tool, not a payment processor
2. **Immutable transactions** — Once locked, NEVER modify
3. **Audit everything** — All mutations logged to LoggedAction
4. **Multi-currency always** — Even single-currency orgs need exchange rate support
5. **Test with real accounting scenarios** — Invoice → payment → reconciliation

## API Reference
Full endpoint documentation in `docs/backend/API-REFERENCE.md` (to be created).
This file will be the contract for implementation.

# Database — Schema & Models

# Bilko Database — Prisma + PostgreSQL

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

## Schema Location
`prisma/schema.prisma` — 15 models, fully defined

## Database Models (15)
**Core:**
- Organization — Multi-tenant root (baseCurrency, country, language)
- User — RBAC (owner, admin, accountant, viewer)

**Chart of Accounts:**
- AccountType — Asset, Liability, Equity, Revenue, Expense
- Account — Hierarchical CoA with parent-child relations

**Contacts:**
- Contact — Customers, vendors, or both (type enum)

**Invoicing:**
- Invoice — Sales invoices with multi-currency support
- InvoiceItem — Line items with tax rates

**Expenses:**
- Expense — Purchase tracking with approval workflow

**Transactions:**
- Transaction — Double-entry ledger (debit + credit accounts)

**Banking:**
- BankAccount — Bank account metadata
- BankTransaction — Bank statement imports for reconciliation

**Multi-Currency:**
- Currency — Currency definitions (EUR, RSD, BAM, HRK, etc.)
- ExchangeRate — Historical exchange rates by date

**Audit:**
- LoggedAction — Immutable audit trail (APPEND-ONLY)
- SchemaVersion — Migration tracking

## Key Design Decisions

### 1. NUMERIC(19,4) for Money
**NEVER use float or JavaScript number for currency.**
- Prisma type: `Decimal` (maps to PostgreSQL NUMERIC)
- Precision: 19 digits total, 4 decimal places
- Range: -999,999,999,999,999.9999 to +999,999,999,999,999.9999

### 2. Double-Entry Bookkeeping
Every financial event creates a `Transaction` with:
- `debitAccountId` — Account to debit
- `creditAccountId` — Account to credit
- `amount` — MUST be equal for both sides
- Balance = sum(debits) - sum(credits) per account

### 3. Multi-Currency with Rate Locking
- `Invoice.exchangeRate` — Locked at invoice date
- `Transaction.exchangeRate` — Locked at transaction date
- `baseAmount` — Amount converted to org's baseCurrency
- **NEVER recalculate** historical transactions with current rates

### 4. Immutable Audit Trail
`LoggedAction` table:
- **APPEND-ONLY** — NEVER delete or update
- Captures: table name, user ID, action (INSERT/UPDATE/DELETE), old/new values
- Used for: compliance, debugging, rollback simulation

### 5. Transaction Locking
- `Transaction.locked` — Once true, record is immutable
- Locked transactions cannot be edited or deleted
- Used for: end-of-period close, tax reporting

### 6. Organization-Scoped Multi-Tenancy
- Every record has `organizationId` foreign key
- Queries MUST filter by org (enforced in API middleware)
- No cross-org data access

### 7. UUID Primary Keys
- All IDs: `uuid_generate_v4()` (PostgreSQL function)
- NEVER use auto-increment for business data
- Portable across systems, no collisions

## Migration Rules
1. **Never edit existing migrations** — Always create new ones
2. **Test migrations on copy** — Never run on production first
3. **Backward compatible** — Additive changes only
4. **Data migrations separate** — Use Prisma seed or custom scripts
5. **Rollback plan** — Document how to undo breaking changes

## Naming Conventions
- **DB columns:** snake_case (via `@map`)
- **Prisma fields:** camelCase
- **Indexes:** `idx_{table}_{column(s)}`
- **Foreign keys:** Auto-generated by Prisma

## Indexes
Defined for:
- All foreign keys (automatic)
- Common query patterns (org + date, org + status)
- Unique constraints (org + code, org + invoice number)

## Enums
- UserRole: owner, admin, accountant, viewer
- NormalBalance: debit, credit
- ContactType: customer, vendor, both
- InvoiceStatus: draft, sent, viewed, paid, overdue, cancelled
- ExpenseStatus: pending, approved, paid, rejected
- AuditAction: INSERT, UPDATE, DELETE

## Development Commands
```bash
# Generate Prisma Client
npx prisma generate

# Create migration
npx prisma migrate dev --name migration_name

# Apply migrations (production)
npx prisma migrate deploy

# Reset database (dev only)
npx prisma migrate reset

# Open Prisma Studio
npx prisma studio
```

## Critical Rules
1. **NUMERIC for money** — NEVER float
2. **Double-entry enforced** — Every transaction has debit + credit
3. **Exchange rates locked** — At transaction date, NEVER recalculate
4. **Audit is append-only** — NEVER delete LoggedAction records
5. **UUID everywhere** — NEVER expose auto-increment IDs

# API Reference

# Bilko API Reference

> **Status:** SPECIFICATION (backend not implemented)
> **Base URL:** `http://localhost:4000/api/v1` (development)
> **Production URL:** `https://api.bilko.io/api/v1`
> **Last updated:** 2026-02-20

---

## Purpose

This document is the implementation contract for Bilko's backend. All ~35 endpoints are specified with:
- HTTP method + path
- Authentication requirements
- Request/response TypeScript interfaces
- Query parameters
- Error responses
- Example requests/responses

**CRITICAL:** Backend is NOT BUILT. This is the spec that apps/api/ MUST implement.

---

## Table of Contents

1. [Authentication](#authentication) (5 endpoints)
2. [Organization](#organization) (2 endpoints)
3. [Users](#users) (4 endpoints)
4. [Contacts](#contacts) (5 endpoints)
5. [Invoices](#invoices) (8 endpoints)
6. [Expenses](#expenses) (6 endpoints)
7. [Bank Accounts](#bank-accounts) (4 endpoints)
8. [Reports](#reports) (7 endpoints)
9. [Chart of Accounts](#chart-of-accounts) (3 endpoints)
10. [Transactions](#transactions) (2 endpoints)
11. [Settings](#settings) (2 endpoints)
12. [Currencies](#currencies) (2 endpoints)

**Total:** 50 endpoints

---

## API Architecture Overview

```mermaid
graph LR
    subgraph CLIENT [Client]
        FE[Next.js Frontend\nbilko.io:3000]
    end

    subgraph API [Express API — api.bilko.io:4000]
        AUTH_R[/auth/*\nPublic]
        ORG_R[/organization\nAll roles]
        USR_R[/users/*\nowner, admin]
        CON_R[/contacts/*\nAll roles]
        INV_R[/invoices/*\nAll roles]
        EXP_R[/expenses/*\nAll roles]
        BANK_R[/bank-accounts/*\nAll roles]
        RPT_R[/reports/*\nAll roles]
        ACC_R[/accounts/*\nAll roles]
        TXN_R[/transactions/*\nAll roles]
        SET_R[/settings/*\nowner, admin]
        CUR_R[/currencies\nAll roles]
    end

    FE -->|Bearer token\nin Authorization header| API
    FE -->|refreshToken\nhttpOnly cookie| AUTH_R

    style AUTH_R fill:#e2e8f0,color:#000
    style FE fill:#00E5A0,color:#000
```

---

## Global Response Patterns

### Pagination

All list endpoints support pagination:

```typescript
interface PaginatedResponse<T> {
  data: T[]
  meta: {
    total: number        // Total records
    page: number         // Current page (1-indexed)
    perPage: number      // Records per page
    totalPages: number   // Total pages
  }
}
```

**Query parameters:**
- `page` (default: 1)
- `perPage` (default: 20, max: 100)
- `sort` (field name, default varies by endpoint)
- `order` (`asc` or `desc`, default: `desc`)

### Error Responses

```typescript
interface ApiError {
  error: string                         // Human-readable error message
  code: string                          // Machine-readable error code
  details?: Record<string, string[]>    // Field-level validation errors
}
```

**HTTP Status Codes:**
- `400 Bad Request` — Invalid request body/params
- `401 Unauthorized` — Missing or invalid auth token
- `403 Forbidden` — User lacks required role
- `404 Not Found` — Resource does not exist
- `422 Unprocessable Entity` — Validation failed
- `500 Internal Server Error` — Server error

---

## 1. Authentication

### POST /api/v1/auth/register

Create new organization and owner user.

**Auth:** None
**Rate limit:** 5 req/min

**Request:**
```typescript
interface RegisterRequest {
  // Organization
  organizationName: string
  country: 'RS' | 'BA' | 'HR'        // Serbia, BiH, Croatia
  baseCurrency: 'EUR' | 'RSD' | 'BAM' | 'HRK'
  language: 'sr' | 'bs' | 'hr'
  registrationNumber?: string         // Company tax ID
  vatNumber?: string

  // User
  email: string                       // Must be unique
  password: string                    // Min 8 chars, 1 upper, 1 lower, 1 number
  fullName: string
}
```

**Response (201):**
```typescript
interface RegisterResponse {
  user: {
    id: string
    email: string
    fullName: string
    role: 'owner'
  }
  organization: {
    id: string
    name: string
    country: string
    baseCurrency: string
  }
  tokens: {
    accessToken: string      // JWT, expires in 15 min
    refreshToken: string     // Expires in 7 days
  }
}
```

**Errors:**
- `400` — Email already exists
- `422` — Validation failed (weak password, invalid country, etc.)

---

### POST /api/v1/auth/login

Authenticate with email + password.

**Auth:** None
**Rate limit:** 5 req/min

**Request:**
```typescript
interface LoginRequest {
  email: string
  password: string
  rememberMe?: boolean     // If true, refreshToken expires in 30 days
}
```

**Response (200):**
```typescript
interface LoginResponse {
  user: {
    id: string
    email: string
    fullName: string
    role: 'owner' | 'admin' | 'accountant' | 'viewer'
    organizationId: string
    organizationName: string
  }
  tokens: {
    accessToken: string      // JWT, expires in 15 min
    refreshToken: string     // httpOnly cookie
  }
}
```

**Errors:**
- `401` — Invalid credentials
- `403` — Account disabled or requires 2FA

---

### POST /api/v1/auth/refresh

Get new access token using refresh token.

**Auth:** Refresh token (httpOnly cookie)
**Rate limit:** 100 req/min

**Request:** None (uses cookie)

**Response (200):**
```typescript
interface RefreshResponse {
  accessToken: string
}
```

**Errors:**
- `401` — Invalid or expired refresh token

---

### POST /api/v1/auth/logout

Invalidate refresh token.

**Auth:** Bearer token
**Rate limit:** 100 req/min

**Request:** None

**Response (204):** No content

---

### GET /api/v1/auth/me

Get current user info.

**Auth:** Bearer token
**Rate limit:** 100 req/min

**Response (200):**
```typescript
interface CurrentUser {
  id: string
  email: string
  fullName: string
  role: 'owner' | 'admin' | 'accountant' | 'viewer'
  twoFactorEnabled: boolean
  lastLoginAt: string | null
  organization: {
    id: string
    name: string
    country: string
    baseCurrency: string
    language: string
  }
}
```

---

## 2. Organization

### GET /api/v1/organization

Get organization details.

**Auth:** Bearer token
**Roles:** All
**Rate limit:** 100 req/min

**Response (200):**
```typescript
interface Organization {
  id: string
  name: string
  registrationNumber: string | null
  vatNumber: string | null
  baseCurrency: string
  country: string
  language: string
  fiscalYearStart: string    // ISO date, e.g., "2026-01-01"
  createdAt: string
  updatedAt: string
}
```

---

### PUT /api/v1/organization

Update organization details.

**Auth:** Bearer token
**Roles:** owner, admin
**Rate limit:** 10 req/min

**Request:**
```typescript
interface UpdateOrganizationRequest {
  name?: string
  registrationNumber?: string
  vatNumber?: string
  baseCurrency?: 'EUR' | 'RSD' | 'BAM' | 'HRK'
  language?: 'sr' | 'bs' | 'hr'
  fiscalYearStart?: string    // ISO date
}
```

**Response (200):** Organization object (same as GET)

**Errors:**
- `422` — Validation failed (invalid currency code, etc.)

---

## 3. Users

### GET /api/v1/users

List all users in organization.

**Auth:** Bearer token
**Roles:** owner, admin
**Rate limit:** 100 req/min

**Query:**
- `role` (filter by role)

**Response (200):**
```typescript
interface UserListResponse {
  data: Array<{
    id: string
    email: string
    fullName: string
    role: 'owner' | 'admin' | 'accountant' | 'viewer'
    twoFactorEnabled: boolean
    lastLoginAt: string | null
    createdAt: string
  }>
}
```

---

### POST /api/v1/users/invite

Invite new user to organization.

**Auth:** Bearer token
**Roles:** owner, admin
**Rate limit:** 10 req/min

**Request:**
```typescript
interface InviteUserRequest {
  email: string
  fullName: string
  role: 'admin' | 'accountant' | 'viewer'    // Cannot create 'owner'
}
```

**Response (201):**
```typescript
interface InviteUserResponse {
  user: {
    id: string
    email: string
    fullName: string
    role: string
  }
  inviteLink: string    // One-time setup link, expires in 7 days
}
```

**Errors:**
- `400` — Email already exists in organization
- `422` — Invalid role

---

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

Change user role.

**Auth:** Bearer token
**Roles:** owner
**Rate limit:** 10 req/min

**Request:**
```typescript
interface ChangeRoleRequest {
  role: 'admin' | 'accountant' | 'viewer'
}
```

**Response (200):** User object

**Errors:**
- `403` — Cannot change owner role or demote yourself
- `404` — User not found

---

### DELETE /api/v1/users/:id

Remove user from organization.

**Auth:** Bearer token
**Roles:** owner
**Rate limit:** 10 req/min

**Response (204):** No content

**Errors:**
- `403` — Cannot delete owner or yourself
- `404` — User not found

---

## 4. Contacts

### GET /api/v1/contacts

List contacts (customers/vendors).

**Auth:** Bearer token
**Roles:** All
**Rate limit:** 100 req/min

**Query:**
- `type` (`customer`, `vendor`, `both`)
- `page`, `perPage`, `sort`, `order`

**Response (200):**
```typescript
type ContactListResponse = PaginatedResponse<Contact>

interface Contact {
  id: string
  type: 'customer' | 'vendor' | 'both'
  name: string
  email: string | null
  phone: string | null
  registrationNumber: string | null
  vatNumber: string | null
  addressLine1: string | null
  addressLine2: string | null
  city: string | null
  postalCode: string | null
  country: string | null
  currencyCode: string
  paymentTerms: number           // Days
  isActive: boolean
  createdAt: string
  updatedAt: string
}
```

---

### POST /api/v1/contacts

Create new contact.

**Auth:** Bearer token
**Roles:** owner, admin, accountant
**Rate limit:** 50 req/min

**Request:**
```typescript
interface CreateContactRequest {
  type: 'customer' | 'vendor' | 'both'
  name: string
  email?: string
  phone?: string
  registrationNumber?: string
  vatNumber?: string
  addressLine1?: string
  addressLine2?: string
  city?: string
  postalCode?: string
  country?: string               // ISO 3166-1 alpha-2 (e.g., 'RS')
  currencyCode?: string          // ISO 4217 (default: org baseCurrency)
  paymentTerms?: number          // Default: 30 days
  notes?: string
}
```

**Response (201):** Contact object

**Errors:**
- `422` — Validation failed (invalid country code, currency code, etc.)

---

### GET /api/v1/contacts/:id

Get contact details.

**Auth:** Bearer token
**Roles:** All
**Rate limit:** 100 req/min

**Response (200):** Contact object + notes field

---

### PUT /api/v1/contacts/:id

Update contact.

**Auth:** Bearer token
**Roles:** owner, admin, accountant
**Rate limit:** 50 req/min

**Request:** Same as CreateContactRequest (all fields optional)

**Response (200):** Contact object

---

### DELETE /api/v1/contacts/:id

Soft-delete contact (sets isActive = false).

**Auth:** Bearer token
**Roles:** owner, admin
**Rate limit:** 10 req/min

**Response (204):** No content

**Errors:**
- `400` — Contact has active invoices or expenses

---

## 5. Invoices

### GET /api/v1/invoices

List invoices.

**Auth:** Bearer token
**Roles:** All
**Rate limit:** 100 req/min

**Query:**
- `status` (`draft`, `sent`, `viewed`, `paid`, `overdue`, `cancelled`)
- `customerId` (UUID)
- `fromDate`, `toDate` (ISO dates)
- `page`, `perPage`, `sort`, `order`

**Response (200):**
```typescript
type InvoiceListResponse = PaginatedResponse<InvoiceSummary>

interface InvoiceSummary {
  id: string
  invoiceNumber: string
  customerId: string
  customerName: string
  invoiceDate: string
  dueDate: string
  currencyCode: string
  totalAmount: string         // Decimal as string, e.g., "125000.0000"
  status: 'draft' | 'sent' | 'viewed' | 'paid' | 'overdue' | 'cancelled'
  createdAt: string
}
```

---

### Invoice Creation — Full Sequence

```mermaid
sequenceDiagram
    participant FE as Frontend
    participant MW as Middleware Stack\n(auth, roleGuard, validate)
    participant H as Invoice Handler
    participant DB as PostgreSQL\n(Prisma)
    participant EX as Exchange Rate\nService

    FE->>MW: POST /api/v1/invoices\nAuthorization: Bearer {accessToken}
    MW->>MW: authGuard: verify JWT\nAttach req.user {id, role, orgId}
    MW->>MW: roleGuard: check owner/admin/accountant
    MW->>MW: validate(createInvoiceSchema)\ncustomerId UUID, dates, items[]

    MW->>H: Validated request
    H->>DB: Find Contact by customerId\nwhere orgId matches
    DB-->>H: Contact { email, currencyCode }

    H->>EX: getExchangeRate(invoiceCurrency, orgBaseCurrency, invoiceDate)
    EX-->>H: rate (locked at invoiceDate — NEVER changes)

    H->>H: Calculate:\nlineTotal = qty × unitPrice\ntaxAmount = SUM(lineTotal × taxRate/100)\ntotalAmount = subtotal + taxAmount - discount\nbaseAmount = totalAmount × exchangeRate

    H->>DB: BEGIN TRANSACTION\nGenerate invoiceNumber INV-YYYY-NNN\nINSERT Invoice { status: draft }\nINSERT InvoiceItems[]

    DB-->>H: Invoice created
    H->>DB: INSERT LoggedAction\n{ action: INSERT, tableName: Invoice }
    DB-->>H: Logged

    H->>FE: 201 Created\n{ id, invoiceNumber, status: draft, items, totals }
```

### POST /api/v1/invoices

Create invoice.

**Auth:** Bearer token
**Roles:** owner, admin, accountant
**Rate limit:** 50 req/min

**Request:**
```typescript
interface CreateInvoiceRequest {
  customerId: string
  invoiceDate: string           // ISO date
  dueDate: string               // ISO date
  currencyCode?: string         // Default: customer's currency
  items: Array<{
    description: string
    quantity: number            // Decimal as number
    unitPrice: number           // Decimal as number
    taxRate: number             // Percentage, e.g., 20 for 20%
    accountId?: string          // Revenue account
  }>
  notes?: string
  terms?: string
}
```

**Response (201):**
```typescript
interface Invoice {
  id: string
  invoiceNumber: string         // Auto-generated
  customerId: string
  customerName: string
  invoiceDate: string
  dueDate: string
  currencyCode: string
  exchangeRate: string          // Decimal as string
  subtotal: string
  taxAmount: string
  discountAmount: string
  totalAmount: string
  baseAmount: string            // Converted to org baseCurrency
  status: 'draft'
  items: Array<{
    id: string
    lineNumber: number
    description: string
    quantity: string
    unitPrice: string
    taxRate: string
    lineTotal: string
    accountId: string | null
  }>
  notes: string | null
  terms: string | null
  pdfUrl: string | null
  createdBy: string
  createdAt: string
  updatedAt: string
}
```

**Errors:**
- `404` — Customer not found
- `422` — Validation failed (invalid date, negative amount, etc.)

---

### GET /api/v1/invoices/:id

Get invoice details.

**Auth:** Bearer token
**Roles:** All
**Rate limit:** 100 req/min

**Response (200):** Invoice object (same as POST response)

---

### PUT /api/v1/invoices/:id

Update invoice (draft only).

**Auth:** Bearer token
**Roles:** owner, admin, accountant
**Rate limit:** 50 req/min

**Request:** Same as CreateInvoiceRequest

**Response (200):** Invoice object

**Errors:**
- `400` — Invoice is not in draft status

---

### Invoice Status Transition — Send Flow

```mermaid
sequenceDiagram
    participant FE as Frontend
    participant API as Bilko API
    participant PDF as Puppeteer\nPDF Service
    participant R2 as Cloudflare R2
    participant SG as SendGrid
    participant DB as PostgreSQL

    FE->>API: PATCH /invoices/:id/status\n{ action: "send" }
    API->>DB: Fetch Invoice with items, customer, org
    DB-->>API: Invoice (must be status=draft)
    API->>PDF: generateInvoicePDF(invoice data)
    PDF-->>API: PDF Buffer

    API->>R2: PUT invoices/{orgId}/INV-2026-001.pdf
    R2-->>API: pdfUrl stored

    API->>DB: BEGIN TRANSACTION
    API->>DB: INSERT Transaction {\n  DR: Accounts Receivable (1200)\n  CR: Revenue (4000)\n  amount: invoice.totalAmount\n  referenceType: 'invoice'\n}
    API->>DB: UPDATE Invoice SET\n  status='sent', sentAt=now()\n  pdfUrl=url

    API->>SG: sendEmail({\n  to: customer.email,\n  subject: "Invoice INV-2026-001 from Org",\n  html: template,\n  attachment: pdf\n})
    SG-->>API: { messageId }

    DB-->>API: COMMIT

    API->>FE: 200 { status: sent, sentAt, pdfUrl }

    Note over API: If SendGrid fails:\nKeep invoice as draft\nAdd note: "Email delivery failed"\nAlert admin via Slack
```

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

Change invoice status.

**Auth:** Bearer token
**Roles:** owner, admin, accountant
**Rate limit:** 50 req/min

**Request:**
```typescript
interface ChangeInvoiceStatusRequest {
  action: 'send' | 'mark-paid' | 'cancel'
  paidAt?: string               // Required if action = 'mark-paid'
}
```

**Response (200):** Invoice object

**Business logic:**
- `send`: draft → sent (generates PDF, sends email via SendGrid)
- `mark-paid`: sent/viewed → paid (creates Transaction: debit BankAccount, credit AccountsReceivable)
- `cancel`: any → cancelled (reverses Transaction if paid)

**Errors:**
- `400` — Invalid status transition

---

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

Get invoice PDF.

**Auth:** Bearer token
**Roles:** All
**Rate limit:** 100 req/min

**Response (200):**
- Content-Type: application/pdf
- Content-Disposition: attachment; filename="INV-2026-001.pdf"

**Errors:**
- `404` — Invoice or PDF not found

---

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

Send invoice email to customer.

**Auth:** Bearer token
**Roles:** owner, admin, accountant
**Rate limit:** 10 req/min

**Request:**
```typescript
interface SendInvoiceRequest {
  to?: string                   // Override customer email
  cc?: string[]
  subject?: string              // Override default subject
  message?: string              // Custom message
}
```

**Response (200):**
```typescript
interface SendInvoiceResponse {
  sentAt: string
  sentTo: string
  emailId: string               // SendGrid message ID
}
```

**Errors:**
- `400` — Customer has no email
- `500` — SendGrid error

---

## 6. Expenses

### GET /api/v1/expenses

List expenses.

**Auth:** Bearer token
**Roles:** All
**Rate limit:** 100 req/min

**Query:**
- `status` (`pending`, `approved`, `paid`, `rejected`)
- `category`
- `vendorId`
- `fromDate`, `toDate`
- `page`, `perPage`, `sort`, `order`

**Response (200):**
```typescript
type ExpenseListResponse = PaginatedResponse<ExpenseSummary>

interface ExpenseSummary {
  id: string
  expenseNumber: string
  vendorId: string | null
  vendorName: string | null
  expenseDate: string
  category: string
  amount: string
  currencyCode: string
  status: 'pending' | 'approved' | 'paid' | 'rejected'
  receiptUrl: string | null
  createdAt: string
}
```

---

### POST /api/v1/expenses

Create expense.

**Auth:** Bearer token
**Roles:** owner, admin, accountant
**Rate limit:** 50 req/min

**Request:**
```typescript
interface CreateExpenseRequest {
  vendorId?: string
  expenseDate: string
  category: string              // Free text or predefined categories
  amount: number
  currencyCode?: string         // Default: org baseCurrency
  taxAmount?: number
  paymentMethod?: string        // 'cash', 'card', 'bank_transfer', etc.
  accountId?: string            // Expense account
  description?: string
  receiptFile?: File            // Multipart form upload (max 10MB)
}
```

**Response (201):**
```typescript
interface Expense {
  id: string
  expenseNumber: string         // Auto-generated
  vendorId: string | null
  vendorName: string | null
  expenseDate: string
  category: string
  currencyCode: string
  exchangeRate: string
  amount: string
  baseAmount: string
  taxAmount: string
  paymentMethod: string | null
  accountId: string | null
  description: string | null
  receiptUrl: string | null     // Cloudflare R2 URL
  status: 'pending'
  createdBy: string
  createdAt: string
  updatedAt: string
}
```

**Errors:**
- `422` — Validation failed (negative amount, invalid date, etc.)
- `413` — File too large

---

### GET /api/v1/expenses/:id

Get expense details.

**Auth:** Bearer token
**Roles:** All
**Rate limit:** 100 req/min

**Response (200):** Expense object

---

### PUT /api/v1/expenses/:id

Update expense (pending only).

**Auth:** Bearer token
**Roles:** owner, admin, accountant
**Rate limit:** 50 req/min

**Request:** Same as CreateExpenseRequest

**Response (200):** Expense object

**Errors:**
- `400` — Expense is not pending

---

### Expense Approval — Full Sequence

```mermaid
sequenceDiagram
    participant FE as Frontend\n(admin/owner)
    participant MW as Middleware Stack
    participant H as Expense Handler
    participant DB as PostgreSQL

    FE->>MW: PATCH /api/v1/expenses/:id/approve\nAuthorization: Bearer {accessToken}
    MW->>MW: authGuard: verify JWT
    MW->>MW: roleGuard: owner or admin ONLY\n(accountant CANNOT approve)

    MW->>H: Request passes
    H->>DB: Find Expense by id\nwhere organizationId = req.orgId
    DB-->>H: Expense { status: pending, amount, accountId }

    H->>H: Validate: status must be 'pending'\nIf not → 400 Bad Request

    H->>DB: BEGIN TRANSACTION

    H->>DB: Find ExpenseAccount\n(expense.accountId or default 5xxx)
    H->>DB: Find AccountsPayable account\n(2110 or configured account)

    H->>DB: INSERT Transaction {\n  debitAccountId: expenseAccountId,\n  creditAccountId: accountsPayableId,\n  amount: expense.amount,\n  referenceType: 'expense',\n  referenceId: expense.id\n}

    H->>DB: UPDATE Expense SET\n  status='approved',\n  approvedBy=req.user.id,\n  approvedAt=now()

    H->>DB: INSERT LoggedAction

    DB-->>H: COMMIT

    H->>FE: 200 OK\n{ id, status: approved, approvedBy, approvedAt }
```

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

Approve expense.

**Auth:** Bearer token
**Roles:** owner, admin
**Rate limit:** 50 req/min

**Response (200):** Expense object (status = approved)

**Business logic:**
- Creates Transaction: debit ExpenseAccount, credit AccountsPayable

**Errors:**
- `400` — Expense already approved/paid/rejected

---

### DELETE /api/v1/expenses/:id

Delete expense (pending only).

**Auth:** Bearer token
**Roles:** owner, admin
**Rate limit:** 10 req/min

**Response (204):** No content

**Errors:**
- `400` — Expense is not pending

---

## 7. Bank Accounts

### GET /api/v1/bank-accounts

List bank accounts.

**Auth:** Bearer token
**Roles:** All
**Rate limit:** 100 req/min

**Response (200):**
```typescript
interface BankAccountListResponse {
  data: Array<{
    id: string
    accountId: string           // GL account ID
    accountCode: string         // GL account code
    bankName: string
    accountNumber: string | null
    iban: string | null
    currencyCode: string
    currentBalance: string
    isActive: boolean
    createdAt: string
    updatedAt: string
  }>
}
```

---

### POST /api/v1/bank-accounts

Create bank account.

**Auth:** Bearer token
**Roles:** owner, admin
**Rate limit:** 10 req/min

**Request:**
```typescript
interface CreateBankAccountRequest {
  accountId: string             // Must be Asset account
  bankName: string
  accountNumber?: string
  iban?: string
  currencyCode: string
  currentBalance?: number       // Default: 0
}
```

**Response (201):** BankAccount object

**Errors:**
- `404` — Account not found
- `422` — Account is not Asset type

---

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

Get bank transactions.

**Auth:** Bearer token
**Roles:** All
**Rate limit:** 100 req/min

**Query:**
- `fromDate`, `toDate`
- `reconciled` (true/false)
- `page`, `perPage`, `sort`, `order`

**Response (200):**
```typescript
type BankTransactionListResponse = PaginatedResponse<BankTransaction>

interface BankTransaction {
  id: string
  transactionDate: string
  amount: string                // Positive = credit, negative = debit
  description: string | null
  reference: string | null
  reconciled: boolean
  matchedTransactionId: string | null
  createdAt: string
}
```

---

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

Import bank statement (CSV).

**Auth:** Bearer token
**Roles:** owner, admin, accountant
**Rate limit:** 10 req/min

**Request:**
- Multipart form: `file` (CSV, max 5MB)

**CSV format:**
```csv
Date,Description,Amount,Reference
2026-02-19,"Payment from customer",3500.00,INV-2026-002
2026-02-18,"AWS Invoice",-850.00,
```

**Response (200):**
```typescript
interface ImportStatementResponse {
  imported: number
  duplicates: number
  errors: Array<{
    line: number
    error: string
  }>
}
```

**Errors:**
- `422` — Invalid CSV format
- `413` — File too large

---

### Bank Reconciliation — Full Sequence

```mermaid
sequenceDiagram
    participant FE as Frontend
    participant API as Bilko API
    participant DB as PostgreSQL

    Note over FE,DB: Step 1 — Import Bank Statement
    FE->>API: POST /bank-accounts/:id/import\n[multipart: CSV file, max 5MB]
    API->>API: Parse CSV\nDate, Description, Amount, Reference
    API->>DB: INSERT BankTransaction[] records\n{ bankAccountId, transactionDate, amount, reference }
    DB-->>API: Imported count
    API->>FE: 200 { imported: 45, duplicates: 2, errors: [] }

    Note over FE,DB: Step 2 — Auto-Match Suggestions
    FE->>API: GET /bank-accounts/:id/transactions?reconciled=false
    API->>DB: Fetch unreconciled BankTransactions
    DB-->>API: BankTransaction[]
    API->>DB: Fetch unreconciled GL Transactions\nfor same date range
    DB-->>API: Transaction[]
    API->>API: calculateMatchScore() for each pair\nAmount match +50\nDate match +30/+20/+10\nReference match +20
    API->>FE: 200 { bankTransactions, suggestions[{ bankTxId, glTxId, score }] }

    Note over FE,DB: Step 3 — Confirm Reconciliation
    FE->>API: POST /bank-accounts/:id/reconcile\n{ bankTransactionId, transactionId }
    API->>DB: Find both records, verify same org
    API->>DB: UPDATE BankTransaction SET\n  reconciled=true\n  matchedTransactionId=glTxId
    API->>DB: UPDATE Transaction SET\n  reconciled=true\n  reconciledAt=now()
    DB-->>API: Both updated
    API->>FE: 200 { bankTransaction, transaction, confidence: 95 }
```

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

Reconcile bank transactions with GL transactions.

**Auth:** Bearer token
**Roles:** owner, admin, accountant
**Rate limit:** 10 req/min

**Request:**
```typescript
interface ReconcileRequest {
  bankTransactionId: string
  transactionId: string         // GL transaction ID
}
```

**Response (200):**
```typescript
interface ReconcileResponse {
  bankTransaction: BankTransaction
  transaction: Transaction
  confidence: number            // 0-100 match score
}
```

**Errors:**
- `404` — Bank transaction or GL transaction not found
- `400` — Already reconciled

---

## 8. Reports

### GET /api/v1/reports/dashboard

Get dashboard metrics.

**Auth:** Bearer token
**Roles:** All
**Rate limit:** 100 req/min

**Response (200):**
```typescript
interface DashboardMetrics {
  cashBalance: string           // Total across all bank accounts (in baseCurrency)
  revenueMTD: string            // Month-to-date revenue
  unpaidInvoices: string        // Total unpaid invoices
  expensesMTD: string           // Month-to-date expenses
  profitMTD: string             // revenueMTD - expensesMTD
  cashFlowChange: number        // Percentage change from last month

  // Chart data
  monthlyPL: Array<{
    month: string
    revenue: string
    expenses: string
    profit: string
  }>

  receivablesAging: {
    current: string             // 0-30 days
    days30: string              // 31-60 days
    days60: string              // 61-90 days
    days90plus: string          // 90+ days
  }

  expensesByCategory: Array<{
    category: string
    amount: string
    currencyCode: string
  }>
}
```

---

### GET /api/v1/reports/profit-loss

Profit & Loss statement.

**Auth:** Bearer token
**Roles:** All
**Rate limit:** 50 req/min

**Query:**
- `from` (ISO date, required)
- `to` (ISO date, required)

**Response (200):**
```typescript
interface ProfitLossReport {
  period: {
    from: string
    to: string
  }
  baseCurrency: string

  revenue: {
    total: string
    accounts: Array<{
      accountCode: string
      accountName: string
      amount: string
    }>
  }

  expenses: {
    total: string
    accounts: Array<{
      accountCode: string
      accountName: string
      amount: string
    }>
  }

  netProfit: string             // revenue.total - expenses.total
}
```

---

### GET /api/v1/reports/balance-sheet

Balance Sheet.

**Auth:** Bearer token
**Roles:** All
**Rate limit:** 50 req/min

**Query:**
- `date` (ISO date, default: today)

**Response (200):**
```typescript
interface BalanceSheetReport {
  asOfDate: string
  baseCurrency: string

  assets: {
    total: string
    current: {
      total: string
      accounts: Array<AccountBalance>
    }
    fixed: {
      total: string
      accounts: Array<AccountBalance>
    }
  }

  liabilities: {
    total: string
    current: {
      total: string
      accounts: Array<AccountBalance>
    }
    longTerm: {
      total: string
      accounts: Array<AccountBalance>
    }
  }

  equity: {
    total: string
    accounts: Array<AccountBalance>
  }
}

interface AccountBalance {
  accountCode: string
  accountName: string
  balance: string
}
```

---

### GET /api/v1/reports/cash-flow

Cash Flow statement.

**Auth:** Bearer token
**Roles:** All
**Rate limit:** 50 req/min

**Query:**
- `from`, `to` (ISO dates, required)

**Response (200):**
```typescript
interface CashFlowReport {
  period: {
    from: string
    to: string
  }
  baseCurrency: string

  operating: {
    total: string
    items: Array<{
      description: string
      amount: string
    }>
  }

  investing: {
    total: string
    items: Array<{
      description: string
      amount: string
    }>
  }

  financing: {
    total: string
    items: Array<{
      description: string
      amount: string
    }>
  }

  netCashFlow: string
  openingBalance: string
  closingBalance: string
}
```

---

### GET /api/v1/reports/vat

VAT/PDV report.

**Auth:** Bearer token
**Roles:** All
**Rate limit:** 50 req/min

**Query:**
- `from`, `to` (ISO dates, required)

**Response (200):**
```typescript
interface VATReport {
  period: {
    from: string
    to: string
  }
  country: string               // Organization country

  outputVAT: {
    total: string
    invoices: Array<{
      invoiceNumber: string
      customerName: string
      invoiceDate: string
      baseAmount: string
      vatAmount: string
      vatRate: string
    }>
  }

  inputVAT: {
    total: string
    expenses: Array<{
      expenseNumber: string
      vendorName: string
      expenseDate: string
      baseAmount: string
      vatAmount: string
      vatRate: string
    }>
  }

  netVAT: string                // outputVAT.total - inputVAT.total

  reconciliationStatus: {
    allInvoicesPaid: boolean
    allExpensesApproved: boolean
    unmatchedTransactions: number
  }
}
```

---

### GET /api/v1/reports/trial-balance

Trial Balance.

**Auth:** Bearer token
**Roles:** All
**Rate limit:** 50 req/min

**Query:**
- `date` (ISO date, default: today)

**Response (200):**
```typescript
interface TrialBalanceReport {
  asOfDate: string
  baseCurrency: string

  accounts: Array<{
    accountCode: string
    accountName: string
    accountType: string
    debitTotal: string
    creditTotal: string
    balance: string
  }>

  totals: {
    debit: string
    credit: string
  }

  balanced: boolean             // totals.debit === totals.credit
}
```

---

## 9. Chart of Accounts

### GET /api/v1/accounts

List chart of accounts.

**Auth:** Bearer token
**Roles:** All
**Rate limit:** 100 req/min

**Query:**
- `accountTypeId` (filter by type)
- `isActive` (true/false)

**Response (200):**
```typescript
interface AccountListResponse {
  data: Array<{
    id: string
    code: string                // e.g., "1000", "4000"
    name: string                // e.g., "Bank Account EUR", "Revenue"
    accountTypeId: number
    accountTypeName: string     // Asset, Liability, Equity, Revenue, Expense
    normalBalance: 'debit' | 'credit'
    currencyCode: string
    parentAccountId: string | null
    parentAccountCode: string | null
    isActive: boolean
    currentBalance: string      // Calculated from transactions
    createdAt: string
    updatedAt: string
  }>
}
```

---

### POST /api/v1/accounts

Create account.

**Auth:** Bearer token
**Roles:** owner, admin
**Rate limit:** 10 req/min

**Request:**
```typescript
interface CreateAccountRequest {
  code: string                  // Must be unique within organization
  name: string
  accountTypeId: number         // 1-5 (Asset, Liability, Equity, Revenue, Expense)
  currencyCode?: string         // Default: org baseCurrency
  parentAccountId?: string      // For sub-accounts
}
```

**Response (201):** Account object

**Errors:**
- `400` — Code already exists
- `404` — Parent account not found
- `422` — Invalid account type

---

### PUT /api/v1/accounts/:id

Update account.

**Auth:** Bearer token
**Roles:** owner, admin
**Rate limit:** 10 req/min

**Request:**
```typescript
interface UpdateAccountRequest {
  name?: string
  isActive?: boolean
}
```

**Response (200):** Account object

**Errors:**
- `400` — Cannot deactivate account with transactions

---

## 10. Transactions

### GET /api/v1/transactions

List general ledger transactions.

**Auth:** Bearer token
**Roles:** All
**Rate limit:** 100 req/min

**Query:**
- `fromDate`, `toDate`
- `accountId` (show transactions for specific account)
- `referenceType` (`invoice`, `expense`, `payment`, `manual`)
- `page`, `perPage`, `sort`, `order`

**Response (200):**
```typescript
type TransactionListResponse = PaginatedResponse<Transaction>

interface Transaction {
  id: string
  transactionDate: string
  description: string
  debitAccountId: string
  debitAccountCode: string
  debitAccountName: string
  creditAccountId: string
  creditAccountCode: string
  creditAccountName: string
  amount: string
  currencyCode: string
  exchangeRate: string
  baseAmount: string
  referenceType: string | null
  referenceId: string | null
  locked: boolean
  reconciled: boolean
  createdBy: string
  createdAt: string
}
```

---

### POST /api/v1/transactions

Create manual journal entry.

**Auth:** Bearer token
**Roles:** owner, admin, accountant
**Rate limit:** 20 req/min

**Request:**
```typescript
interface CreateTransactionRequest {
  transactionDate: string
  description: string
  debitAccountId: string
  creditAccountId: string
  amount: number
  currencyCode?: string         // Default: org baseCurrency
  notes?: string
}
```

**Response (201):** Transaction object

**Errors:**
- `404` — Account not found
- `422` — Validation failed (debit = credit account, negative amount, etc.)

---

## 11. Settings

### GET /api/v1/settings/tax-rates

Get tax rate configuration.

**Auth:** Bearer token
**Roles:** All
**Rate limit:** 100 req/min

**Response (200):**
```typescript
interface TaxRatesResponse {
  country: string
  defaultVATRate: number        // e.g., 20 for Serbia, 17 for BiH
  rates: Array<{
    name: string                // "Standard", "Reduced", "Zero"
    rate: number
    description: string
  }>
}
```

---

### PUT /api/v1/settings/tax-rates

Update tax rate configuration.

**Auth:** Bearer token
**Roles:** owner, admin
**Rate limit:** 10 req/min

**Request:**
```typescript
interface UpdateTaxRatesRequest {
  defaultVATRate: number
  rates: Array<{
    name: string
    rate: number
    description: string
  }>
}
```

**Response (200):** TaxRatesResponse

---

## 12. Currencies

### GET /api/v1/currencies

List supported currencies.

**Auth:** Bearer token
**Roles:** All
**Rate limit:** 100 req/min

**Response (200):**
```typescript
interface CurrencyListResponse {
  data: Array<{
    code: string                // ISO 4217
    name: string
    symbol: string | null
    decimalPlaces: number
    isActive: boolean
  }>
}
```

---

### GET /api/v1/exchange-rates

Get exchange rates.

**Auth:** Bearer token
**Roles:** All
**Rate limit:** 100 req/min

**Query:**
- `base` (currency code, required)
- `target` (currency code, required)
- `date` (ISO date, default: today)

**Response (200):**
```typescript
interface ExchangeRateResponse {
  baseCurrency: string
  targetCurrency: string
  rate: string                  // Decimal as string
  effectiveDate: string
  source: string                // "ECB", "fixer.io", "manual"
  lastUpdated: string
}
```

**Errors:**
- `404` — No rate found for date (return nearest available)

---

## Endpoint Summary Map

```mermaid
graph TD
    subgraph AUTH [Authentication — No auth required]
        A1[POST /auth/register]
        A2[POST /auth/login]
        A3[POST /auth/refresh]
        A4[POST /auth/logout]
        A5[GET /auth/me]
    end

    subgraph ORG [Organization]
        O1[GET /organization]
        O2[PUT /organization]
    end

    subgraph USR [Users]
        U1[GET /users]
        U2[POST /users/invite]
        U3[PUT /users/:id/role]
        U4[DELETE /users/:id]
    end

    subgraph CON [Contacts]
        C1[GET /contacts]
        C2[POST /contacts]
        C3[GET /contacts/:id]
        C4[PUT /contacts/:id]
        C5[DELETE /contacts/:id]
    end

    subgraph INV [Invoices]
        I1[GET /invoices]
        I2[POST /invoices]
        I3[GET /invoices/:id]
        I4[PUT /invoices/:id]
        I5[PATCH /invoices/:id/status]
        I6[GET /invoices/:id/pdf]
        I7[POST /invoices/:id/send]
    end

    subgraph EXP [Expenses]
        E1[GET /expenses]
        E2[POST /expenses]
        E3[GET /expenses/:id]
        E4[PUT /expenses/:id]
        E5[PATCH /expenses/:id/approve]
        E6[DELETE /expenses/:id]
    end

    subgraph BANK [Bank Accounts]
        B1[GET /bank-accounts]
        B2[POST /bank-accounts]
        B3[GET /bank-accounts/:id/transactions]
        B4[POST /bank-accounts/:id/import]
        B5[POST /bank-accounts/:id/reconcile]
    end

    subgraph RPT [Reports]
        R1[GET /reports/dashboard]
        R2[GET /reports/profit-loss]
        R3[GET /reports/balance-sheet]
        R4[GET /reports/cash-flow]
        R5[GET /reports/vat]
        R6[GET /reports/trial-balance]
    end

    subgraph MISC [Other]
        M1[GET /accounts]
        M2[POST /accounts]
        M3[PUT /accounts/:id]
        M4[GET /transactions]
        M5[POST /transactions]
        M6[GET /settings/tax-rates]
        M7[PUT /settings/tax-rates]
        M8[GET /currencies]
        M9[GET /exchange-rates]
    end
```

## Implementation Notes

### Request Validation
All requests validated with Zod schemas. Invalid requests return `422` with field-level errors.

### Database Transactions
All write operations wrapped in database transactions. Rollback on error.

### Audit Logging
All INSERT/UPDATE/DELETE captured in `LoggedAction` table via Prisma middleware.

### Rate Limiting
- General: 100 req/min per user
- Auth: 5 req/min per IP
- Write ops: 10-50 req/min per user

### File Uploads
- Max size: 10MB (receipts), 5MB (CSV)
- Allowed: PDF, PNG, JPG, CSV
- Storage: Cloudflare R2
- Virus scanning: ClamAV

### CORS
- Allowed origins: `https://bilko.io`, `http://localhost:3000`
- Credentials: true (cookies)

### Error Logging
- Sentry for production errors
- Winston for structured logs

---

## Example Requests

### Create Invoice
```bash
curl -X POST http://localhost:4000/api/v1/invoices \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "customerId": "550e8400-e29b-41d4-a716-446655440000",
    "invoiceDate": "2026-02-20",
    "dueDate": "2026-03-20",
    "items": [
      {
        "description": "Web Development",
        "quantity": 40,
        "unitPrice": 100,
        "taxRate": 20
      }
    ]
  }'
```

### Get Dashboard Metrics
```bash
curl http://localhost:4000/api/v1/reports/dashboard \
  -H "Authorization: Bearer $TOKEN"
```

---

**End of API Reference**

# Database Schema

# Bilko Database Schema

> **Status:** IMPLEMENTED (Prisma schema exists)
> **Location:** `/Users/makinja/ALAI/products/Bilko/packages/database/prisma/schema.prisma`
> **Database:** PostgreSQL 14+
> **ORM:** Prisma 5.x
> **Last updated:** 2026-02-20

---

## Purpose

This document describes the complete database schema for Bilko. The schema is IMPLEMENTED in Prisma and ready for migration. This doc explains the relationships, constraints, and design decisions.

---

## Entity Relationship Overview

```
Organization (1) ──┬── (N) User
                   ├── (N) Account
                   ├── (N) Contact
                   ├── (N) Invoice
                   ├── (N) Expense
                   ├── (N) Transaction
                   └── (N) BankAccount

Contact (1) ────┬── (N) Invoice
                └── (N) Expense

Invoice (1) ──── (N) InvoiceItem

Account (1) ───┬── (N) InvoiceItem
               ├── (N) Expense
               ├── (N) BankAccount
               ├── (N) Transaction (debit)
               ├── (N) Transaction (credit)
               └── (N) Account (parent-child hierarchy)

BankAccount (1) ── (N) BankTransaction

Currency (1) ───┬── (N) ExchangeRate (base)
                └── (N) ExchangeRate (target)

User (1) ───┬── (N) Invoice (creator)
            ├── (N) Expense (creator)
            ├── (N) Expense (approver)
            ├── (N) Transaction (creator)
            └── (N) LoggedAction
```

### Full ER Diagram

```mermaid
erDiagram
    Organization {
        UUID id PK
        VARCHAR name
        VARCHAR registrationNumber
        VARCHAR vatNumber
        CHAR baseCurrency
        CHAR country
        CHAR language
        DATE fiscalYearStart
        TIMESTAMP createdAt
        TIMESTAMP updatedAt
    }

    User {
        UUID id PK
        UUID organizationId FK
        VARCHAR email
        VARCHAR passwordHash
        VARCHAR fullName
        ENUM role
        BOOLEAN twoFactorEnabled
        VARCHAR twoFactorSecret
        TIMESTAMP lastLoginAt
        TIMESTAMP createdAt
        TIMESTAMP updatedAt
    }

    AccountType {
        INT id PK
        VARCHAR name
        ENUM normalBalance
        TIMESTAMP createdAt
    }

    Account {
        UUID id PK
        UUID organizationId FK
        VARCHAR code
        VARCHAR name
        INT accountTypeId FK
        CHAR currencyCode
        UUID parentAccountId FK
        BOOLEAN isActive
        TIMESTAMP createdAt
        TIMESTAMP updatedAt
    }

    Contact {
        UUID id PK
        UUID organizationId FK
        ENUM type
        VARCHAR name
        VARCHAR email
        VARCHAR phone
        VARCHAR vatNumber
        VARCHAR addressLine1
        VARCHAR city
        CHAR country
        CHAR currencyCode
        INT paymentTerms
        BOOLEAN isActive
        TIMESTAMP createdAt
        TIMESTAMP updatedAt
    }

    Invoice {
        UUID id PK
        UUID organizationId FK
        UUID customerId FK
        VARCHAR invoiceNumber
        DATE invoiceDate
        DATE dueDate
        CHAR currencyCode
        DECIMAL exchangeRate
        DECIMAL subtotal
        DECIMAL taxAmount
        DECIMAL discountAmount
        DECIMAL totalAmount
        DECIMAL baseAmount
        ENUM status
        TIMESTAMP sentAt
        TIMESTAMP paidAt
        VARCHAR pdfUrl
        UUID createdBy FK
        TIMESTAMP createdAt
        TIMESTAMP updatedAt
    }

    InvoiceItem {
        UUID id PK
        UUID invoiceId FK
        INT lineNumber
        VARCHAR description
        DECIMAL quantity
        DECIMAL unitPrice
        DECIMAL taxRate
        DECIMAL lineTotal
        UUID accountId FK
        TIMESTAMP createdAt
    }

    Expense {
        UUID id PK
        UUID organizationId FK
        UUID vendorId FK
        VARCHAR expenseNumber
        DATE expenseDate
        CHAR currencyCode
        DECIMAL exchangeRate
        DECIMAL amount
        DECIMAL baseAmount
        DECIMAL taxAmount
        VARCHAR category
        VARCHAR paymentMethod
        UUID accountId FK
        ENUM status
        UUID approvedBy FK
        TIMESTAMP approvedAt
        UUID createdBy FK
        TIMESTAMP createdAt
        TIMESTAMP updatedAt
    }

    Transaction {
        UUID id PK
        UUID organizationId FK
        DATE transactionDate
        VARCHAR description
        UUID debitAccountId FK
        UUID creditAccountId FK
        DECIMAL amount
        CHAR currencyCode
        DECIMAL exchangeRate
        DECIMAL baseAmount
        VARCHAR referenceType
        UUID referenceId
        BOOLEAN locked
        BOOLEAN reconciled
        UUID createdBy FK
        TIMESTAMP createdAt
    }

    BankAccount {
        UUID id PK
        UUID organizationId FK
        UUID accountId FK
        VARCHAR bankName
        VARCHAR accountNumber
        VARCHAR iban
        CHAR currencyCode
        DECIMAL currentBalance
        BOOLEAN isActive
        TIMESTAMP createdAt
        TIMESTAMP updatedAt
    }

    BankTransaction {
        UUID id PK
        UUID bankAccountId FK
        DATE transactionDate
        DECIMAL amount
        VARCHAR description
        VARCHAR reference
        BOOLEAN reconciled
        UUID matchedTransactionId
        TIMESTAMP createdAt
    }

    Currency {
        CHAR code PK
        VARCHAR name
        VARCHAR symbol
        SMALLINT decimalPlaces
        BOOLEAN isActive
        TIMESTAMP createdAt
    }

    ExchangeRate {
        UUID id PK
        CHAR baseCurrency FK
        CHAR targetCurrency FK
        DECIMAL rate
        DATE effectiveDate
        VARCHAR source
        TIMESTAMP lastUpdated
    }

    LoggedAction {
        BIGINT eventId PK
        TEXT schemaName
        TEXT tableName
        UUID userId FK
        TIMESTAMP actionTimestamp
        ENUM action
        JSONB rowData
        JSONB changedFields
        INET clientIp
    }

    SchemaVersion {
        VARCHAR version PK
        TIMESTAMP appliedAt
        TEXT description
    }

    Organization ||--o{ User : "has"
    Organization ||--o{ Account : "owns"
    Organization ||--o{ Contact : "manages"
    Organization ||--o{ Invoice : "issues"
    Organization ||--o{ Expense : "tracks"
    Organization ||--o{ Transaction : "records"
    Organization ||--o{ BankAccount : "holds"

    User ||--o{ Invoice : "createdBy"
    User ||--o{ Expense : "createdBy"
    User ||--o{ Expense : "approvedBy"
    User ||--o{ Transaction : "createdBy"
    User ||--o{ LoggedAction : "performed"

    AccountType ||--o{ Account : "categorizes"
    Account ||--o{ Account : "parentOf"
    Account ||--o{ InvoiceItem : "revenueAccount"
    Account ||--o{ Expense : "expenseAccount"
    Account ||--o| BankAccount : "glAccount"
    Account ||--o{ Transaction : "debitAccount"
    Account ||--o{ Transaction : "creditAccount"

    Contact ||--o{ Invoice : "customer"
    Contact ||--o{ Expense : "vendor"

    Invoice ||--o{ InvoiceItem : "contains"

    BankAccount ||--o{ BankTransaction : "has"

    Currency ||--o{ ExchangeRate : "base"
    Currency ||--o{ ExchangeRate : "target"
```

---

### Multi-Tenant Scoping

```mermaid
graph LR
    ORG[Organization\nMulti-tenant Root]

    ORG --> U[Users\nowner/admin/accountant/viewer]
    ORG --> COA[Chart of Accounts\nHierarchical GL]
    ORG --> C[Contacts\nCustomers & Vendors]
    ORG --> INV[Invoices\nOutgoing]
    ORG --> EXP[Expenses\nIncoming]
    ORG --> TXN[Transactions\nDouble-Entry Ledger]
    ORG --> BANK[BankAccounts\nReconciliation]

    INV --> ITEM[InvoiceItems\nLine Items]
    BANK --> BTXN[BankTransactions\nStatement Import]
    TXN --> LOG[LoggedAction\nAudit Trail]

    style ORG fill:#00E5A0,color:#000
    style TXN fill:#ffd700,color:#000
```

---

## Core Tables

### 1. Organization

**Purpose:** Multi-tenant root. Every business is one organization.

| Column | Type | Constraints | Description |
|--------|------|-------------|-------------|
| id | UUID | PK, default uuid_generate_v4() | Primary key |
| name | VARCHAR(255) | NOT NULL | Business name |
| registrationNumber | VARCHAR(50) | NULL | Company tax ID |
| vatNumber | VARCHAR(50) | NULL | VAT registration number |
| baseCurrency | CHAR(3) | NOT NULL, default 'EUR' | ISO 4217 currency code |
| country | CHAR(2) | NOT NULL | ISO 3166-1 alpha-2 country code |
| language | CHAR(2) | NOT NULL, default 'sr' | ISO 639-1 language code |
| fiscalYearStart | DATE | NOT NULL, default '2026-01-01' | Fiscal year start date |
| createdAt | TIMESTAMP | NOT NULL, default now() | Record creation timestamp |
| updatedAt | TIMESTAMP | NOT NULL, default now() | Last update timestamp |

**Indexes:**
- Primary key: `id`

**Business rules:**
- baseCurrency determines default currency for all transactions
- country determines tax rules (Serbia 20%, BiH 17%, Croatia 25%)
- fiscalYearStart used for annual reports

---

### 2. User

**Purpose:** Users within an organization. Role-based access control.

| Column | Type | Constraints | Description |
|--------|------|-------------|-------------|
| id | UUID | PK | Primary key |
| organizationId | UUID | FK → Organization, NOT NULL, CASCADE | Organization membership |
| email | VARCHAR(255) | UNIQUE, NOT NULL | Login email |
| passwordHash | VARCHAR(255) | NOT NULL | bcrypt hash (12 rounds) |
| fullName | VARCHAR(255) | NOT NULL | Display name |
| role | ENUM | NOT NULL | owner, admin, accountant, viewer |
| twoFactorEnabled | BOOLEAN | NOT NULL, default false | 2FA status |
| twoFactorSecret | VARCHAR(255) | NULL | TOTP secret |
| lastLoginAt | TIMESTAMP | NULL | Last login timestamp |
| createdAt | TIMESTAMP | NOT NULL | Account creation |
| updatedAt | TIMESTAMP | NOT NULL | Last update |

**Indexes:**
- Primary key: `id`
- Unique: `email`
- Foreign key: `organizationId` → Organization(id)
- Index: `idx_users_organization` on organizationId
- Index: `idx_users_email` on email

**Enums:**
```prisma
enum UserRole {
  owner       // Full access, can delete org
  admin       // Can manage users and settings
  accountant  // Can create invoices/expenses
  viewer      // Read-only access
}
```

**Business rules:**
- One owner per organization (enforced in API)
- Cannot delete owner
- Password must be bcrypt hashed, NEVER plain text

---

## Chart of Accounts

### Account Hierarchy & Normal Balances

```mermaid
graph TD
    COA[Chart of Accounts]

    COA --> A[1xxx Assets\nnormalBalance: debit]
    COA --> L[2xxx Liabilities\nnormalBalance: credit]
    COA --> E[3xxx Equity\nnormalBalance: credit]
    COA --> R[4xxx Revenue\nnormalBalance: credit]
    COA --> EX[5xxx Expenses\nnormalBalance: debit]

    A --> CA[1100 Current Assets]
    A --> FA[1500 Fixed Assets]
    CA --> Cash[1110 Cash]
    CA --> Bank[1120 Bank Accounts]
    CA --> AR[1200 Accounts Receivable]
    Bank --> B1[1121 Intesa RSD]
    Bank --> B2[1122 Raiffeisen EUR]

    L --> CL[2100 Current Liabilities]
    L --> LL[2500 Long-term]
    CL --> AP[2110 Accounts Payable]
    CL --> VAT[2120 VAT Payable]

    E --> SC[3100 Share Capital]
    E --> RE[3900 Retained Earnings]

    R --> SR[4100 Service Revenue]
    R --> PR[4200 Product Sales]

    EX --> OE[5100 Operating Expenses]
    OE --> SAL[5110 Salaries]
    OE --> RENT[5120 Rent]

    style A fill:#4ade80,color:#000
    style L fill:#f87171,color:#000
    style E fill:#60a5fa,color:#000
    style R fill:#a78bfa,color:#000
    style EX fill:#fb923c,color:#000
```

### 3. AccountType

**Purpose:** Defines account categories for double-entry bookkeeping.

| Column | Type | Constraints | Description |
|--------|------|-------------|-------------|
| id | INT | PK, AUTOINCREMENT | Primary key |
| name | VARCHAR(50) | UNIQUE, NOT NULL | Asset, Liability, Equity, Revenue, Expense |
| normalBalance | ENUM | NOT NULL | debit or credit |
| createdAt | TIMESTAMP | NOT NULL | Record creation |

**Enums:**
```prisma
enum NormalBalance {
  debit   // Asset, Expense accounts increase with debits
  credit  // Liability, Equity, Revenue accounts increase with credits
}
```

**Seed data:**
```
1 | Asset      | debit
2 | Liability  | credit
3 | Equity     | credit
4 | Revenue    | credit
5 | Expense    | debit
```

---

### 4. Account

**Purpose:** Chart of Accounts. Hierarchical GL accounts.

| Column | Type | Constraints | Description |
|--------|------|-------------|-------------|
| id | UUID | PK | Primary key |
| organizationId | UUID | FK → Organization, NOT NULL | Organization scope |
| code | VARCHAR(10) | NOT NULL | Account code (e.g., "1000", "4000") |
| name | VARCHAR(255) | NOT NULL | Account name (e.g., "Cash", "Revenue") |
| accountTypeId | INT | FK → AccountType, NOT NULL | Account category |
| currencyCode | CHAR(3) | NOT NULL, default 'EUR' | Account currency |
| parentAccountId | UUID | FK → Account, NULL | Parent account (for sub-accounts) |
| isActive | BOOLEAN | NOT NULL, default true | Active status |
| createdAt | TIMESTAMP | NOT NULL | Record creation |
| updatedAt | TIMESTAMP | NOT NULL | Last update |

**Indexes:**
- Primary key: `id`
- Unique: `(organizationId, code)`
- Index: `idx_accounts_organization` on organizationId
- Index: `idx_accounts_type` on accountTypeId

**Business rules:**
- Code MUST be unique within organization
- Cannot delete account with transactions
- Parent-child hierarchy for sub-accounts (e.g., 1000 → 1001, 1002)

---

## Contacts (Customers & Vendors)

### 5. Contact

**Purpose:** Customers (invoice recipients) and vendors (expense payees).

| Column | Type | Constraints | Description |
|--------|------|-------------|-------------|
| id | UUID | PK | Primary key |
| organizationId | UUID | FK → Organization, NOT NULL | Organization scope |
| type | ENUM | NOT NULL | customer, vendor, both |
| name | VARCHAR(255) | NOT NULL | Contact name |
| email | VARCHAR(255) | NULL | Email address |
| phone | VARCHAR(50) | NULL | Phone number |
| registrationNumber | VARCHAR(50) | NULL | Company registration number |
| vatNumber | VARCHAR(50) | NULL | VAT number |
| addressLine1 | VARCHAR(255) | NULL | Street address |
| addressLine2 | VARCHAR(255) | NULL | Apt/suite |
| city | VARCHAR(100) | NULL | City |
| postalCode | VARCHAR(20) | NULL | Postal/ZIP code |
| country | CHAR(2) | NULL | ISO 3166-1 alpha-2 |
| currencyCode | CHAR(3) | NOT NULL, default 'EUR' | Preferred currency |
| paymentTerms | INT | NOT NULL, default 30 | Payment terms in days |
| notes | TEXT | NULL | Free-text notes |
| isActive | BOOLEAN | NOT NULL, default true | Active status |
| createdAt | TIMESTAMP | NOT NULL | Record creation |
| updatedAt | TIMESTAMP | NOT NULL | Last update |

**Indexes:**
- Primary key: `id`
- Index: `idx_contacts_organization` on organizationId
- Index: `idx_contacts_type` on type

**Enums:**
```prisma
enum ContactType {
  customer  // Invoice recipient
  vendor    // Expense payee
  both      // Can be both customer and vendor
}
```

**Business rules:**
- Soft delete (isActive = false) if has invoices/expenses
- currencyCode determines default invoice/expense currency

---

## Invoicing

### 6. Invoice

**Purpose:** Sales invoices (outgoing). Revenue recognition.

| Column | Type | Constraints | Description |
|--------|------|-------------|-------------|
| id | UUID | PK | Primary key |
| organizationId | UUID | FK → Organization, NOT NULL | Organization scope |
| customerId | UUID | FK → Contact, NOT NULL | Invoice recipient |
| invoiceNumber | VARCHAR(50) | NOT NULL | Auto-generated (e.g., INV-2026-001) |
| invoiceDate | DATE | NOT NULL | Invoice issue date |
| dueDate | DATE | NOT NULL | Payment due date |
| currencyCode | CHAR(3) | NOT NULL | Invoice currency |
| exchangeRate | DECIMAL(12,6) | NOT NULL, default 1.0 | Exchange rate at invoiceDate |
| subtotal | DECIMAL(19,4) | NOT NULL | Sum of line totals (before tax) |
| taxAmount | DECIMAL(19,4) | NOT NULL, default 0 | Total VAT/tax |
| discountAmount | DECIMAL(19,4) | NOT NULL, default 0 | Total discount |
| totalAmount | DECIMAL(19,4) | NOT NULL | subtotal + taxAmount - discountAmount |
| baseAmount | DECIMAL(19,4) | NOT NULL | Converted to org baseCurrency |
| status | ENUM | NOT NULL, default 'draft' | Invoice status |
| sentAt | TIMESTAMP | NULL | When invoice was sent |
| viewedAt | TIMESTAMP | NULL | When customer viewed (email tracking) |
| paidAt | TIMESTAMP | NULL | When marked as paid |
| notes | TEXT | NULL | Internal notes |
| terms | TEXT | NULL | Payment terms text |
| pdfUrl | VARCHAR(500) | NULL | Cloudflare R2 URL |
| createdBy | UUID | FK → User, NULL | Creator user |
| createdAt | TIMESTAMP | NOT NULL | Record creation |
| updatedAt | TIMESTAMP | NOT NULL | Last update |

**Indexes:**
- Primary key: `id`
- Unique: `(organizationId, invoiceNumber)`
- Index: `idx_invoices_organization` on organizationId
- Index: `idx_invoices_customer` on customerId
- Index: `idx_invoices_status` on status
- Index: `idx_invoices_due_date` on dueDate
- Composite: `idx_invoices_org_status_date` on (organizationId, status, invoiceDate)

**Enums:**
```prisma
enum InvoiceStatus {
  draft      // Being edited
  sent       // Sent to customer
  viewed     // Customer viewed email
  paid       // Payment received
  overdue    // Past dueDate and unpaid
  cancelled  // Voided
}
```

**Invoice Status Transitions:**

```mermaid
stateDiagram-v2
    [*] --> draft : Created
    draft --> sent : Send email\n(creates GL Transaction)
    sent --> viewed : Tracking pixel loaded
    viewed --> paid : Mark as paid\n(creates GL Transaction)
    sent --> paid : Mark as paid\n(creates GL Transaction)
    draft --> cancelled : Cancel
    sent --> cancelled : Cancel\n(reverses Transaction)
    viewed --> cancelled : Cancel\n(reverses Transaction)
    paid --> [*]
    cancelled --> [*]

    note right of draft
        Can edit line items,\ndates, amounts
    end note
    note right of sent
        Locked — no edits\nPDF generated & stored in R2
    end note
    note right of overdue
        Automated check: dueDate < today\nAND status != paid
    end note
```

**Business rules:**
- invoiceNumber auto-generated on first save
- exchangeRate locked at invoiceDate (NEVER recalculate)
- baseAmount = totalAmount * exchangeRate
- Cannot edit invoice unless status = draft
- When status changes to 'paid', create Transaction (debit BankAccount, credit AccountsReceivable)

---

### 7. InvoiceItem

**Purpose:** Line items on invoices.

| Column | Type | Constraints | Description |
|--------|------|-------------|-------------|
| id | UUID | PK | Primary key |
| invoiceId | UUID | FK → Invoice, CASCADE, NOT NULL | Parent invoice |
| lineNumber | INT | NOT NULL | Line order (1, 2, 3...) |
| description | VARCHAR(500) | NOT NULL | Item description |
| quantity | DECIMAL(10,2) | NOT NULL | Quantity sold |
| unitPrice | DECIMAL(19,4) | NOT NULL | Price per unit |
| taxRate | DECIMAL(5,2) | NOT NULL, default 0 | VAT rate (20 = 20%) |
| lineTotal | DECIMAL(19,4) | NOT NULL | quantity * unitPrice |
| accountId | UUID | FK → Account, NULL | Revenue account |
| createdAt | TIMESTAMP | NOT NULL | Record creation |

**Indexes:**
- Primary key: `id`
- Index: `idx_invoice_items_invoice` on invoiceId

**Business rules:**
- lineTotal = quantity * unitPrice (calculated before save)
- Tax amount = lineTotal * (taxRate / 100)
- accountId determines which revenue account is credited

---

## Expenses

### 8. Expense

**Purpose:** Purchase tracking (incoming). Expense recognition.

| Column | Type | Constraints | Description |
|--------|------|-------------|-------------|
| id | UUID | PK | Primary key |
| organizationId | UUID | FK → Organization, NOT NULL | Organization scope |
| vendorId | UUID | FK → Contact, NULL | Vendor (optional) |
| expenseNumber | VARCHAR(50) | NOT NULL | Auto-generated (e.g., EXP-2026-001) |
| expenseDate | DATE | NOT NULL | Expense date |
| currencyCode | CHAR(3) | NOT NULL | Expense currency |
| exchangeRate | DECIMAL(12,6) | NOT NULL, default 1.0 | Exchange rate at expenseDate |
| amount | DECIMAL(19,4) | NOT NULL | Total expense amount |
| baseAmount | DECIMAL(19,4) | NOT NULL | Converted to org baseCurrency |
| taxAmount | DECIMAL(19,4) | NOT NULL, default 0 | VAT amount |
| category | VARCHAR(100) | NOT NULL | Expense category |
| paymentMethod | VARCHAR(50) | NULL | cash, card, bank_transfer, etc. |
| accountId | UUID | FK → Account, NULL | Expense account |
| description | TEXT | NULL | Expense description |
| receiptUrl | VARCHAR(500) | NULL | Cloudflare R2 URL |
| status | ENUM | NOT NULL, default 'pending' | Approval status |
| approvedBy | UUID | FK → User, NULL | Approver user |
| approvedAt | TIMESTAMP | NULL | Approval timestamp |
| paidAt | TIMESTAMP | NULL | Payment timestamp |
| createdBy | UUID | FK → User, NULL | Creator user |
| createdAt | TIMESTAMP | NOT NULL | Record creation |
| updatedAt | TIMESTAMP | NOT NULL | Last update |

**Indexes:**
- Primary key: `id`
- Unique: `(organizationId, expenseNumber)`
- Index: `idx_expenses_organization` on organizationId
- Index: `idx_expenses_vendor` on vendorId
- Index: `idx_expenses_category` on category
- Index: `idx_expenses_date` on expenseDate
- Composite: `idx_expenses_org_date_category` on (organizationId, expenseDate, category)

**Enums:**
```prisma
enum ExpenseStatus {
  pending   // Awaiting approval
  approved  // Approved, ready to pay
  paid      // Payment made
  rejected  // Approval denied
}
```

**Business rules:**
- expenseNumber auto-generated
- exchangeRate locked at expenseDate
- baseAmount = amount * exchangeRate
- When status → approved, create Transaction (debit ExpenseAccount, credit AccountsPayable)
- When status → paid, create Transaction (debit AccountsPayable, credit BankAccount)

---

## Transactions (Double-Entry Ledger)

### 9. Transaction

**Purpose:** General ledger transactions. Every financial event creates a transaction.

| Column | Type | Constraints | Description |
|--------|------|-------------|-------------|
| id | UUID | PK | Primary key |
| organizationId | UUID | FK → Organization, NOT NULL | Organization scope |
| transactionDate | DATE | NOT NULL | Transaction date |
| description | VARCHAR(255) | NOT NULL | Transaction description |
| debitAccountId | UUID | FK → Account, NOT NULL | Account to debit |
| creditAccountId | UUID | FK → Account, NOT NULL | Account to credit |
| amount | DECIMAL(19,4) | NOT NULL | Transaction amount |
| currencyCode | CHAR(3) | NOT NULL | Transaction currency |
| exchangeRate | DECIMAL(12,6) | NOT NULL, default 1.0 | Exchange rate at transactionDate |
| baseAmount | DECIMAL(19,4) | NOT NULL | Converted to org baseCurrency |
| referenceType | VARCHAR(50) | NULL | invoice, expense, payment, manual |
| referenceId | UUID | NULL | Invoice/Expense ID |
| locked | BOOLEAN | NOT NULL, default false | Immutable if true |
| lockedAt | TIMESTAMP | NULL | When locked |
| reconciled | BOOLEAN | NOT NULL, default false | Matched to bank transaction |
| reconciledAt | TIMESTAMP | NULL | When reconciled |
| notes | TEXT | NULL | Free-text notes |
| createdBy | UUID | FK → User, NULL | Creator user |
| createdAt | TIMESTAMP | NOT NULL | Record creation |

**Indexes:**
- Primary key: `id`
- Index: `idx_transactions_organization` on organizationId
- Index: `idx_transactions_date` on transactionDate
- Index: `idx_transactions_debit` on debitAccountId
- Index: `idx_transactions_credit` on creditAccountId
- Index: `idx_transactions_reference` on (referenceType, referenceId)
- Composite: `idx_transactions_org_date` on (organizationId, transactionDate)

**Business rules:**
- **DEBITS = CREDITS** — Every transaction has exactly one debit and one credit
- debitAccountId ≠ creditAccountId (enforced in API)
- Cannot edit if locked = true
- Cannot delete if reconciled = true
- exchangeRate locked at transactionDate
- baseAmount = amount * exchangeRate

**Common transaction patterns:**

1. **Invoice created (draft → sent):**
   - Debit: Accounts Receivable (Asset)
   - Credit: Revenue (Revenue)

2. **Invoice paid:**
   - Debit: Bank Account (Asset)
   - Credit: Accounts Receivable (Asset)

3. **Expense approved:**
   - Debit: Expense Account (Expense)
   - Credit: Accounts Payable (Liability)

4. **Expense paid:**
   - Debit: Accounts Payable (Liability)
   - Credit: Bank Account (Asset)

---

## Banking & Reconciliation

### 10. BankAccount

**Purpose:** Bank account metadata.

| Column | Type | Constraints | Description |
|--------|------|-------------|-------------|
| id | UUID | PK | Primary key |
| organizationId | UUID | FK → Organization, NOT NULL | Organization scope |
| accountId | UUID | FK → Account, NOT NULL | GL account (must be Asset) |
| bankName | VARCHAR(255) | NOT NULL | Bank name |
| accountNumber | VARCHAR(50) | NULL | Account number |
| iban | VARCHAR(50) | NULL | IBAN |
| currencyCode | CHAR(3) | NOT NULL, default 'EUR' | Account currency |
| currentBalance | DECIMAL(19,4) | NOT NULL, default 0 | Current balance |
| isActive | BOOLEAN | NOT NULL, default true | Active status |
| createdAt | TIMESTAMP | NOT NULL | Record creation |
| updatedAt | TIMESTAMP | NOT NULL | Last update |

**Indexes:**
- Primary key: `id`
- Index: `idx_bank_accounts_organization` on organizationId

**Business rules:**
- accountId MUST be Asset type account
- currentBalance updated when transactions created
- Soft delete (isActive = false)

---

### 11. BankTransaction

**Purpose:** Bank statement imports. For reconciliation.

| Column | Type | Constraints | Description |
|--------|------|-------------|-------------|
| id | UUID | PK | Primary key |
| bankAccountId | UUID | FK → BankAccount, CASCADE, NOT NULL | Parent bank account |
| transactionDate | DATE | NOT NULL | Transaction date |
| amount | DECIMAL(19,4) | NOT NULL | Positive = credit, negative = debit |
| description | VARCHAR(500) | NULL | Bank description |
| reference | VARCHAR(255) | NULL | Reference number |
| reconciled | BOOLEAN | NOT NULL, default false | Matched to GL transaction |
| matchedTransactionId | UUID | NULL | GL transaction ID |
| createdAt | TIMESTAMP | NOT NULL | Record creation |

**Indexes:**
- Primary key: `id`
- Index: `idx_bank_transactions_account` on bankAccountId
- Index: `idx_bank_transactions_date` on transactionDate

**Business rules:**
- Imported from CSV bank statements
- Matched to GL transactions via reconciliation workflow
- reconciled = true when matched

---

## Multi-Currency

### 12. Currency

**Purpose:** Supported currencies.

| Column | Type | Constraints | Description |
|--------|------|-------------|-------------|
| code | CHAR(3) | PK | ISO 4217 currency code |
| name | VARCHAR(100) | NOT NULL | Currency name |
| symbol | VARCHAR(10) | NULL | Currency symbol |
| decimalPlaces | SMALLINT | NOT NULL, default 2 | Decimal precision |
| isActive | BOOLEAN | NOT NULL, default true | Active status |
| createdAt | TIMESTAMP | NOT NULL | Record creation |

**Seed data:**
```
EUR | Euro           | €    | 2
RSD | Serbian Dinar  | din. | 2
BAM | Bosnian Mark   | KM   | 2
HRK | Croatian Kuna  | kn   | 2
USD | US Dollar      | $    | 2
```

---

### 13. ExchangeRate

**Purpose:** Historical exchange rates.

| Column | Type | Constraints | Description |
|--------|------|-------------|-------------|
| id | UUID | PK | Primary key |
| baseCurrency | CHAR(3) | FK → Currency, NOT NULL | From currency |
| targetCurrency | CHAR(3) | FK → Currency, NOT NULL | To currency |
| rate | DECIMAL(12,6) | NOT NULL | Exchange rate |
| effectiveDate | DATE | NOT NULL | Rate effective date |
| source | VARCHAR(50) | NULL | ECB, fixer.io, manual |
| lastUpdated | TIMESTAMP | NOT NULL | Last update timestamp |

**Indexes:**
- Primary key: `id`
- Unique: `(baseCurrency, targetCurrency, effectiveDate)`
- Index: `idx_exchange_rates_date` on effectiveDate
- Index: `idx_exchange_rates_pair` on (baseCurrency, targetCurrency)

**Business rules:**
- Rates fetched daily from ECB or fixer.io API
- When creating transaction, rate is locked at transaction date
- If no rate for exact date, use nearest available (warn in logs)

---

## Audit Trail

### 14. LoggedAction

**Purpose:** Immutable audit log. Captures all INSERT/UPDATE/DELETE.

| Column | Type | Constraints | Description |
|--------|------|-------------|-------------|
| eventId | BIGINT | PK, AUTOINCREMENT | Event ID |
| schemaName | TEXT | NOT NULL | Database schema (default: public) |
| tableName | TEXT | NOT NULL | Table name |
| userId | UUID | FK → User, NULL | User who performed action |
| actionTimestamp | TIMESTAMP | NOT NULL, default now() | When action occurred |
| action | ENUM | NOT NULL | INSERT, UPDATE, DELETE |
| rowData | JSONB | NULL | Full row data before change |
| changedFields | JSONB | NULL | Changed fields (UPDATE only) |
| queryText | TEXT | NULL | SQL query (if available) |
| clientIp | INET | NULL | Client IP address |
| applicationName | TEXT | NOT NULL, default 'fiken-clone-api' | Application identifier |

**Indexes:**
- Primary key: `eventId`
- Index: `idx_logged_actions_timestamp` on actionTimestamp
- Index: `idx_logged_actions_table` on tableName
- Index: `idx_logged_actions_user` on userId

**Enums:**
```prisma
enum AuditAction {
  INSERT
  UPDATE
  DELETE
}
```

**Business rules:**
- **APPEND-ONLY** — NEVER delete or update records
- Triggered via Prisma middleware (automatic)
- Used for: compliance, debugging, rollback simulation

---

## Schema Version

### 15. SchemaVersion

**Purpose:** Migration tracking.

| Column | Type | Constraints | Description |
|--------|------|-------------|-------------|
| version | VARCHAR(20) | PK | Version string (e.g., "1.0.0") |
| appliedAt | TIMESTAMP | NOT NULL, default now() | Migration timestamp |
| description | TEXT | NULL | Migration description |

**Business rules:**
- Updated by Prisma migrations
- Used to verify schema version matches application version

---

## Data Types & Precision

### NUMERIC(19,4) for ALL Money

**CRITICAL:** NEVER use `float`, `double`, or JavaScript `number` for currency.

- Precision: 19 digits total, 4 decimal places
- Range: -999,999,999,999,999.9999 to +999,999,999,999,999.9999
- Prisma type: `Decimal`
- PostgreSQL type: `NUMERIC(19,4)`

**Why:**
- JavaScript `number` has 53-bit precision (safe up to 2^53 - 1 = 9,007,199,254,740,991)
- Financial calculations require exact decimal precision
- Example: 0.1 + 0.2 = 0.30000000000000004 (float error)

**Usage in code:**
```typescript
import { Decimal } from '@prisma/client/runtime'

const amount = new Decimal('125000.0000')
const taxRate = new Decimal('0.20')
const taxAmount = amount.times(taxRate)  // 25000.0000
```

---

## Constraints Summary

### Primary Keys
All tables use UUID primary keys (except AccountType uses INT auto-increment).

### Foreign Keys
All foreign keys have `onDelete: Cascade` (deleting organization deletes all data).

### Unique Constraints
- User.email
- Account.(organizationId, code)
- Invoice.(organizationId, invoiceNumber)
- Expense.(organizationId, expenseNumber)
- ExchangeRate.(baseCurrency, targetCurrency, effectiveDate)

### Check Constraints
(Enforced in API layer, not database):
- amount > 0 for all financial amounts
- dueDate >= invoiceDate for invoices
- debitAccountId ≠ creditAccountId for transactions

---

## Indexes Strategy

### Query Patterns Optimized

1. **List by organization + filter:**
   - `(organizationId, status, date)` composite index on invoices
   - `(organizationId, category, date)` composite index on expenses

2. **Foreign key lookups:**
   - All foreign keys have indexes

3. **Date range queries:**
   - Dedicated indexes on `transactionDate`, `invoiceDate`, `expenseDate`, `dueDate`

4. **Reconciliation:**
   - Index on `(referenceType, referenceId)` for transaction lookups

---

## Migration Commands

```bash
# Generate Prisma Client
npx prisma generate

# Create migration
npx prisma migrate dev --name migration_name

# Apply migrations (production)
npx prisma migrate deploy

# Reset database (dev only)
npx prisma migrate reset

# Seed initial data
npx prisma db seed
```

---

## Seed Data

### AccountType
```sql
INSERT INTO account_types (id, name, normal_balance) VALUES
(1, 'Asset', 'debit'),
(2, 'Liability', 'credit'),
(3, 'Equity', 'credit'),
(4, 'Revenue', 'credit'),
(5, 'Expense', 'debit');
```

### Currency
```sql
INSERT INTO currencies (code, name, symbol, decimal_places) VALUES
('EUR', 'Euro', '€', 2),
('RSD', 'Serbian Dinar', 'din.', 2),
('BAM', 'Bosnian Mark', 'KM', 2),
('HRK', 'Croatian Kuna', 'kn', 2),
('USD', 'US Dollar', '$', 2);
```

---

**End of Database Schema**

# Authentication & Authorization

# Bilko Authentication & Authorization

> **Status:** SPECIFICATION (backend not implemented)
> **Last updated:** 2026-02-20

---

## Purpose

This document specifies the authentication and authorization system for Bilko's backend. Covers JWT tokens, password hashing, 2FA, role-based access control (RBAC), and session management.

---

## Authentication Flow

### System Overview

```mermaid
graph LR
    CLIENT[Frontend\nbilko.io]

    subgraph AUTH [Auth Layer]
        LOGIN[POST /auth/login]
        REGISTER[POST /auth/register]
        REFRESH[POST /auth/refresh]
        LOGOUT[POST /auth/logout]
    end

    subgraph TOKENS [Token Storage]
        AT[Access Token\nBearer header\n15 min TTL]
        RT[Refresh Token\nhttpOnly Cookie\n7-30 days TTL]
        BL[Blacklist\nRevoked JTIs]
    end

    subgraph GUARDS [Middleware Guards]
        AG[authGuard\nVerify JWT]
        RG[roleGuard\nCheck role]
        RL[rateLimiter\n5 req/min auth]
    end

    CLIENT --> LOGIN
    CLIENT --> REGISTER
    CLIENT --> REFRESH
    CLIENT --> LOGOUT

    LOGIN --> AT
    LOGIN --> RT
    REGISTER --> AT
    REGISTER --> RT
    REFRESH --> AT
    LOGOUT --> BL

    AT --> AG
    AG --> RG
    RG --> HANDLER[Route Handler]

    style AT fill:#00E5A0,color:#000
    style RT fill:#ffd700,color:#000
    style BL fill:#f87171,color:#fff
```

### 1. Registration

**Endpoint:** `POST /api/v1/auth/register`

```mermaid
sequenceDiagram
    participant C as Client
    participant API as Express API
    participant DB as PostgreSQL
    participant JWT as JWT Service

    C->>API: POST /auth/register\n{email, password, orgName, country}
    API->>API: Validate Zod schema\nCheck password strength
    API->>DB: Check email uniqueness
    DB-->>API: Email available
    API->>API: bcrypt.hash(password, 12)
    API->>DB: BEGIN TRANSACTION\nCreate Organization\nCreate User (role=owner)\nCreate default Chart of Accounts
    DB-->>API: Organization + User created
    API->>JWT: Generate access token (15min)\nGenerate refresh token (7days)
    JWT-->>API: { accessToken, refreshToken }
    API->>C: 201 Created\n{ user, organization, tokens }\nSet-Cookie: refreshToken (httpOnly)
```

**Steps:**
1. Validate request body (email uniqueness, password strength, country/currency codes)
2. Hash password with bcrypt (12 rounds)
3. Create database transaction:
   - Create Organization
   - Create User (role = 'owner')
   - Create default Chart of Accounts (seed accounts based on country)
4. Generate JWT access token (15 min expiry)
5. Generate refresh token (7 days expiry)
6. Set refresh token in httpOnly cookie
7. Return user + organization + tokens

**Password Requirements:**
- Minimum 8 characters
- At least 1 uppercase letter
- At least 1 lowercase letter
- At least 1 number
- Optional: 1 special character

**Password Hashing:**
```typescript
import bcrypt from 'bcrypt'

const SALT_ROUNDS = 12

async function hashPassword(password: string): Promise<string> {
  return bcrypt.hash(password, SALT_ROUNDS)
}

async function verifyPassword(password: string, hash: string): Promise<boolean> {
  return bcrypt.compare(password, hash)
}
```

**Errors:**
- `400 Bad Request` — Email already exists
- `422 Unprocessable Entity` — Weak password, invalid country code

---

### 2. Login

**Endpoint:** `POST /api/v1/auth/login`

```mermaid
sequenceDiagram
    participant C as Client
    participant RL as Rate Limiter\n5 req/min/IP
    participant API as Express API
    participant DB as PostgreSQL
    participant JWT as JWT Service

    C->>RL: POST /auth/login\n{email, password}
    RL-->>C: 429 Too Many Requests (if exceeded)
    RL->>API: Pass through
    API->>DB: Find user by email
    DB-->>API: User record
    API->>API: bcrypt.compare(password, hash)

    alt Invalid credentials
        API-->>C: 401 Unauthorized
    else 2FA required
        API-->>C: 403 { requiresTwoFactor: true }
        C->>API: POST /auth/verify-2fa { code }
        API->>API: speakeasy.totp.verify()
    end

    API->>DB: UPDATE users SET lastLoginAt = now()
    API->>JWT: Generate access token (15min)\nGenerate refresh token\n(7d or 30d if rememberMe)
    JWT-->>API: Tokens
    API->>C: 200 OK { user, tokens }\nSet-Cookie: refreshToken (httpOnly, Secure, SameSite=Strict)
```

**Steps:**
1. Find user by email
2. Verify password with bcrypt.compare()
3. If 2FA enabled, send TOTP challenge (not covered in MVP)
4. Update user.lastLoginAt
5. Generate JWT access token (15 min expiry)
6. Generate refresh token (7 days or 30 days if rememberMe = true)
7. Set refresh token in httpOnly cookie
8. Return user + tokens

**Rate Limiting:**
- Max 5 login attempts per 1 minute per IP address
- After 5 failed attempts, return `429 Too Many Requests`
- Lockout duration: 15 minutes

**Errors:**
- `401 Unauthorized` — Invalid email or password
- `403 Forbidden` — Account disabled or requires 2FA
- `429 Too Many Requests` — Rate limit exceeded

---

### 3. Token Refresh

**Endpoint:** `POST /api/v1/auth/refresh`

```mermaid
sequenceDiagram
    participant C as Client
    participant API as Express API
    participant BL as Blacklist\n(Redis/PostgreSQL)
    participant JWT as JWT Service

    C->>API: POST /auth/refresh\n[Cookie: refreshToken]
    API->>API: Extract JWT from httpOnly cookie
    API->>JWT: Verify signature & expiry
    JWT-->>API: RefreshTokenPayload { sub, jti, exp }
    API->>BL: Check if jti is blacklisted
    BL-->>API: Not blacklisted
    API->>JWT: Generate new access token (15min)
    JWT-->>API: New accessToken
    API->>C: 200 OK { accessToken }

    note over C,API: Access token expires every 15min\nClient must silently refresh via cookie
```

**Steps:**
1. Extract refresh token from httpOnly cookie
2. Verify refresh token signature
3. Check if token is blacklisted (revoked)
4. Check expiry
5. Generate new access token (15 min expiry)
6. Return new access token

**Refresh Token Storage:**
- Stored in httpOnly cookie (prevents XSS attacks)
- Secure flag = true (HTTPS only)
- SameSite = Strict (prevents CSRF attacks)
- Path = /api/v1/auth/refresh

**Token Revocation:**
- On logout, add refresh token to blacklist (Redis or PostgreSQL)
- Blacklist stores token JTI (JWT ID) + expiry
- Expired blacklist entries auto-deleted after 30 days

**Errors:**
- `401 Unauthorized` — Invalid or expired refresh token

---

### 4. Logout

**Endpoint:** `POST /api/v1/auth/logout`

**Steps:**
1. Extract refresh token from cookie
2. Add token JTI to blacklist
3. Clear httpOnly cookie
4. Return 204 No Content

---

## JWT Tokens

### Access Token

**Purpose:** Short-lived token for API authentication.

**Claims:**
```typescript
interface AccessTokenPayload {
  sub: string           // User ID (UUID)
  email: string         // User email
  role: UserRole        // owner, admin, accountant, viewer
  orgId: string         // Organization ID (UUID)
  iat: number           // Issued at (Unix timestamp)
  exp: number           // Expires at (Unix timestamp, iat + 15 min)
}
```

**Expiry:** 15 minutes

**Header:**
```json
{
  "alg": "HS256",
  "typ": "JWT"
}
```

**Usage:**
- Sent in `Authorization: Bearer <token>` header
- Verified on every API request via `authGuard` middleware
- If expired, client requests new token via `/auth/refresh`

---

### Refresh Token

**Purpose:** Long-lived token for obtaining new access tokens.

**Claims:**
```typescript
interface RefreshTokenPayload {
  sub: string           // User ID (UUID)
  jti: string           // JWT ID (for revocation)
  iat: number           // Issued at (Unix timestamp)
  exp: number           // Expires at (Unix timestamp, iat + 7 days or 30 days)
}
```

**Expiry:** 7 days (default) or 30 days (if rememberMe = true)

**Storage:**
- httpOnly cookie (client cannot access via JavaScript)
- Secure = true (HTTPS only in production)
- SameSite = Strict (prevents CSRF)

**Revocation:**
- On logout, JTI added to blacklist
- On password change, all refresh tokens revoked
- On user deletion, all refresh tokens revoked

---

## JWT Secret Management

**CRITICAL:** JWT secret MUST be stored securely.

**Environment Variables:**
```bash
# .env
JWT_SECRET=<256-bit random string, minimum 32 chars>
JWT_REFRESH_SECRET=<different 256-bit random string>
```

**Generation:**
```bash
# Generate secure random secret
openssl rand -base64 32
```

**Best Practices:**
- Use different secrets for access and refresh tokens
- Rotate secrets every 90 days (requires re-login for all users)
- Store in environment variables, NEVER in code
- Use Vaultwarden or similar secret manager in production

---

## Two-Factor Authentication (2FA)

**Status:** OPTIONAL in MVP, implement in v2

```mermaid
sequenceDiagram
    participant U as User
    participant APP as Frontend
    participant API as Backend
    participant DB as PostgreSQL

    Note over U,DB: 2FA Setup Flow
    U->>APP: Enable 2FA in settings
    APP->>API: POST /settings/2fa/enable
    API->>API: Generate 32-char base32 TOTP secret
    API->>APP: { secret, qrCodeUrl }
    APP->>U: Display QR code
    U->>U: Scan with Google Authenticator / Authy
    U->>APP: Enter 6-digit code to verify
    APP->>API: POST /settings/2fa/verify { code }
    API->>API: speakeasy.totp.verify(secret, code)
    API->>DB: UPDATE users SET twoFactorEnabled=true\ntwoFactorSecret=encrypted
    API->>APP: 2FA activated

    Note over U,DB: Login with 2FA
    U->>APP: Enter email + password
    APP->>API: POST /auth/login
    API->>DB: Find user, verify password
    API->>APP: 403 { requiresTwoFactor: true }
    APP->>U: Prompt for 6-digit code
    U->>APP: Enter TOTP code
    APP->>API: POST /auth/verify-2fa { code }
    API->>API: Verify TOTP (30-second window ±1 step)
    API->>APP: 200 OK { user, tokens }
```

**Flow:**
1. User enables 2FA in settings
2. Generate TOTP secret (32-char base32 string)
3. Display QR code (Google Authenticator, Authy compatible)
4. User scans QR code
5. User enters 6-digit code to verify
6. Store `twoFactorSecret` (encrypted) in `users` table
7. Set `twoFactorEnabled = true`

**Login with 2FA:**
1. User enters email + password
2. If `twoFactorEnabled = true`, return `403` with `requiresTwoFactor: true`
3. Frontend prompts for 6-digit code
4. User submits code via `POST /api/v1/auth/verify-2fa`
5. Verify TOTP code (30-second window)
6. If valid, issue tokens

**TOTP Verification:**
```typescript
import speakeasy from 'speakeasy'

function verifyTOTP(secret: string, token: string): boolean {
  return speakeasy.totp.verify({
    secret,
    encoding: 'base32',
    token,
    window: 1  // Allow 1 time step before/after (30s window)
  })
}
```

---

## Role-Based Access Control (RBAC)

### RBAC Model

```mermaid
graph TD
    subgraph ROLES [User Roles — Hierarchy]
        OW[owner\nFull control]
        AD[admin\nManage users + all financials]
        AC[accountant\nCreate financials only]
        VW[viewer\nRead-only]
    end

    subgraph ACTIONS [Protected Actions]
        direction LR
        INV_C[Create Invoice]
        INV_S[Send Invoice]
        INV_P[Mark Invoice Paid]
        EXP_C[Create Expense]
        EXP_AP[Approve Expense]
        TXN[Create Transaction]
        USR_I[Invite User]
        USR_R[Change User Role]
        USR_D[Delete User]
        ORG_S[Org Settings]
        ORG_D[Delete Organization]
        RPT[View Reports]
    end

    OW --> INV_C & INV_S & INV_P
    OW --> EXP_C & EXP_AP
    OW --> TXN
    OW --> USR_I & USR_R & USR_D
    OW --> ORG_S & ORG_D
    OW --> RPT

    AD --> INV_C & INV_S & INV_P
    AD --> EXP_C & EXP_AP
    AD --> TXN
    AD --> USR_I
    AD --> ORG_S
    AD --> RPT

    AC --> INV_C & INV_S & INV_P
    AC --> EXP_C
    AC --> TXN
    AC --> RPT

    VW --> RPT

    style OW fill:#00E5A0,color:#000
    style AD fill:#60a5fa,color:#000
    style AC fill:#fbbf24,color:#000
    style VW fill:#94a3b8,color:#000
```

### Roles

| Role | Permissions |
|------|-------------|
| **owner** | Full access: manage users, delete organization, change settings, all financial operations |
| **admin** | Manage users (except owner), change settings, all financial operations |
| **accountant** | Create/edit invoices, expenses, transactions. View reports. Cannot manage users or settings. |
| **viewer** | Read-only access to all financial data. Cannot create or edit. |

### Permission Matrix

| Action | owner | admin | accountant | viewer |
|--------|-------|-------|------------|--------|
| Create invoice | ✅ | ✅ | ✅ | ❌ |
| Edit invoice (draft) | ✅ | ✅ | ✅ | ❌ |
| Send invoice | ✅ | ✅ | ✅ | ❌ |
| Mark invoice paid | ✅ | ✅ | ✅ | ❌ |
| Create expense | ✅ | ✅ | ✅ | ❌ |
| Approve expense | ✅ | ✅ | ❌ | ❌ |
| Create manual transaction | ✅ | ✅ | ✅ | ❌ |
| View reports | ✅ | ✅ | ✅ | ✅ |
| Invite user | ✅ | ✅ | ❌ | ❌ |
| Change user role | ✅ | ❌ | ❌ | ❌ |
| Delete user | ✅ | ❌ | ❌ | ❌ |
| Update org settings | ✅ | ✅ | ❌ | ❌ |
| Delete organization | ✅ | ❌ | ❌ | ❌ |

### Middleware Implementation

**Role Guard:**
```typescript
import { Request, Response, NextFunction } from 'express'

type UserRole = 'owner' | 'admin' | 'accountant' | 'viewer'

function roleGuard(allowedRoles: UserRole[]) {
  return (req: Request, res: Response, next: NextFunction) => {
    const user = req.user  // Attached by authGuard middleware

    if (!user) {
      return res.status(401).json({ error: 'Unauthorized', code: 'NO_AUTH' })
    }

    if (!allowedRoles.includes(user.role)) {
      return res.status(403).json({
        error: 'Forbidden',
        code: 'INSUFFICIENT_PERMISSIONS',
        details: { required: allowedRoles, current: user.role }
      })
    }

    next()
  }
}

// Usage in routes
app.post('/api/v1/invoices',
  authGuard,
  roleGuard(['owner', 'admin', 'accountant']),
  createInvoice
)
```

---

## Session Management

### Session Storage

**Option 1: JWT-only (stateless, recommended for MVP):**
- No server-side session storage
- All state in JWT claims
- Fast, scales horizontally
- Cannot revoke access tokens (must wait for expiry)

**Option 2: Redis sessions (for v2):**
- Store session data in Redis
- JWT contains only session ID
- Can revoke immediately
- Requires Redis infrastructure

**Recommended for MVP:** JWT-only (stateless)

---

### Session Invalidation

**On password change:**
1. Hash new password
2. Update `users.passwordHash`
3. Delete all refresh tokens from blacklist older than 1 hour (force re-login)
4. Return success

**On account deletion:**
1. Soft-delete user (set `isActive = false`)
2. Add all user's refresh tokens to blacklist
3. Revoke access immediately

---

## Security Best Practices

### 1. Password Storage
- **NEVER** store plain text passwords
- Use bcrypt with 12 rounds (2^12 iterations)
- Bcrypt auto-salts (no need to store salt separately)

### 2. Token Security
- Access tokens in `Authorization` header (NOT cookies to avoid CSRF)
- Refresh tokens in httpOnly cookies (prevent XSS)
- Use Secure flag (HTTPS only)
- Use SameSite=Strict (prevent CSRF)

### 3. Rate Limiting
- Login: 5 attempts per minute per IP
- Register: 5 attempts per minute per IP
- Refresh: 100 attempts per minute per user
- All other endpoints: 100 requests per minute per user

### 4. HTTPS Only
- All traffic over HTTPS in production
- Redirect HTTP → HTTPS
- HSTS header: `Strict-Transport-Security: max-age=31536000; includeSubDomains`

### 5. CORS Configuration
```typescript
const corsOptions = {
  origin: ['https://bilko.io', 'http://localhost:3000'],
  credentials: true,  // Allow cookies
  methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'],
  allowedHeaders: ['Content-Type', 'Authorization']
}

app.use(cors(corsOptions))
```

### 6. Input Validation
- Validate all inputs with Zod schemas
- Sanitize SQL inputs (Prisma prevents SQL injection)
- Escape HTML in user-generated content

---

## Example Implementation

### Auth Middleware

```typescript
import jwt from 'jsonwebtoken'
import { Request, Response, NextFunction } from 'express'

interface AuthRequest extends Request {
  user?: {
    id: string
    email: string
    role: UserRole
    organizationId: string
  }
}

async function authGuard(req: AuthRequest, res: Response, next: NextFunction) {
  const authHeader = req.headers.authorization

  if (!authHeader || !authHeader.startsWith('Bearer ')) {
    return res.status(401).json({ error: 'Unauthorized', code: 'NO_TOKEN' })
  }

  const token = authHeader.substring(7)  // Remove 'Bearer '

  try {
    const payload = jwt.verify(token, process.env.JWT_SECRET!) as AccessTokenPayload

    // Attach user to request
    req.user = {
      id: payload.sub,
      email: payload.email,
      role: payload.role,
      organizationId: payload.orgId
    }

    next()
  } catch (error) {
    if (error.name === 'TokenExpiredError') {
      return res.status(401).json({ error: 'Token expired', code: 'TOKEN_EXPIRED' })
    }

    return res.status(401).json({ error: 'Invalid token', code: 'INVALID_TOKEN' })
  }
}

export { authGuard, roleGuard }
```

---

## Environment Variables

```bash
# JWT
JWT_SECRET=<256-bit secret for access tokens>
JWT_REFRESH_SECRET=<256-bit secret for refresh tokens>
JWT_ACCESS_EXPIRY=15m
JWT_REFRESH_EXPIRY=7d

# Rate Limiting
RATE_LIMIT_AUTH=5           # Max login attempts per minute
RATE_LIMIT_GENERAL=100      # Max requests per minute

# Session
SESSION_COOKIE_SECURE=true  # HTTPS only (production)
SESSION_COOKIE_SAMESITE=strict
```

---

**End of Authentication Documentation**

# Business Logic

# Bilko Business Logic

> **Status:** SPECIFICATION (backend not implemented)
> **Last updated:** 2026-02-20

---

## Purpose

This document defines the accounting domain rules that Bilko's backend MUST enforce. These are non-negotiable business requirements for financial accuracy and compliance.

---

## Table of Contents

1. [Double-Entry Bookkeeping](#double-entry-bookkeeping)
2. [Invoice Workflow](#invoice-workflow)
3. [Expense Workflow](#expense-workflow)
4. [VAT Calculation](#vat-calculation)
5. [Multi-Currency](#multi-currency)
6. [Bank Reconciliation](#bank-reconciliation)
7. [Chart of Accounts](#chart-of-accounts)
8. [Fiscal Year](#fiscal-year)
9. [Audit Trail](#audit-trail)

---

## 1. Double-Entry Bookkeeping

### Core Principle

**EVERY financial event creates a `Transaction` with exactly one debit and one credit.**

**The fundamental equation:**
```
DEBITS = CREDITS
```

### Double-Entry Flow

```mermaid
flowchart TD
    EVENT[Financial Event\ne.g. Invoice sent, Expense approved, Payment received]
    EVENT --> TXN[Create Transaction\ndebitAccountId + creditAccountId + amount]

    TXN --> CHK{Validate:\ndebit ≠ credit\namount > 0}
    CHK -->|FAIL| ERR[422 Validation Error]
    CHK -->|PASS| DEBIT[Debit Account\nIncrease if Asset/Expense\nDecrease if Liability/Equity/Revenue]
    DEBIT --> CREDIT[Credit Account\nIncrease if Liability/Equity/Revenue\nDecrease if Asset/Expense]
    CREDIT --> BAL{Trial Balance\nSum Debits = Sum Credits?}
    BAL -->|Balanced| LOCK[Lock Transaction\nappend to GL]
    BAL -->|Unbalanced| ALERT[System Alert\nCritical Error]

    style EVENT fill:#00E5A0,color:#000
    style ERR fill:#f87171,color:#fff
    style ALERT fill:#f87171,color:#fff
    style LOCK fill:#60a5fa,color:#000
```

### Common Transaction Patterns

```mermaid
flowchart LR
    subgraph INV_SENT [Invoice Sent]
        IS_D[Debit: 1200 Accounts Receivable\nAsset ↑]
        IS_C[Credit: 4000 Revenue\nRevenue ↑]
        IS_D -. "amount" .- IS_C
    end

    subgraph INV_PAID [Invoice Paid]
        IP_D[Debit: 1000 Bank Account\nAsset ↑]
        IP_C[Credit: 1200 Accounts Receivable\nAsset ↓]
        IP_D -. "amount" .- IP_C
    end

    subgraph EXP_APR [Expense Approved]
        EA_D[Debit: 5100 Expense Account\nExpense ↑]
        EA_C[Credit: 2000 Accounts Payable\nLiability ↑]
        EA_D -. "amount" .- EA_C
    end

    subgraph EXP_PAID [Expense Paid]
        EP_D[Debit: 2000 Accounts Payable\nLiability ↓]
        EP_C[Credit: 1000 Bank Account\nAsset ↓]
        EP_D -. "amount" .- EP_C
    end

    style IS_D fill:#4ade80,color:#000
    style IP_D fill:#4ade80,color:#000
    style EA_D fill:#fb923c,color:#000
    style EP_D fill:#4ade80,color:#000
```

**Account types and normal balances:**

| Account Type | Normal Balance | Increases with | Decreases with |
|--------------|----------------|----------------|----------------|
| Asset | Debit | Debit | Credit |
| Liability | Credit | Credit | Debit |
| Equity | Credit | Credit | Debit |
| Revenue | Credit | Credit | Debit |
| Expense | Debit | Debit | Credit |

### Transaction Rules

1. **Debit Account ≠ Credit Account**
   - A transaction cannot debit and credit the same account
   - Enforced at API validation layer

2. **Amount > 0**
   - Transaction amount must be positive
   - Sign is determined by debit/credit, not amount

3. **Balanced Entries**
   - Debit amount = Credit amount
   - No split transactions in MVP (one debit, one credit only)

4. **Locked Transactions**
   - Once `transaction.locked = true`, cannot be edited or deleted
   - Locked at end-of-period close or when reconciled

### Common Transaction Patterns

#### 1. Invoice Created (draft → sent)
```
Debit:  1200 - Accounts Receivable (Asset)    +125,000 RSD
Credit: 4000 - Revenue (Revenue)              +125,000 RSD
```
**Effect:** Increases asset (money owed to us), increases revenue.

#### 2. Invoice Paid
```
Debit:  1000 - Bank Account (Asset)           +125,000 RSD
Credit: 1200 - Accounts Receivable (Asset)    -125,000 RSD
```
**Effect:** Increases cash, decreases receivables (converted to cash).

#### 3. Expense Approved
```
Debit:  5100 - Infrastructure Expense (Expense)  +850 EUR
Credit: 2000 - Accounts Payable (Liability)      +850 EUR
```
**Effect:** Increases expense, increases liability (we owe money).

#### 4. Expense Paid
```
Debit:  2000 - Accounts Payable (Liability)   -850 EUR
Credit: 1000 - Bank Account (Asset)           -850 EUR
```
**Effect:** Decreases liability, decreases cash.

### Balance Calculation

**Account balance** = Sum(debits) - Sum(credits) for **debit-normal accounts** (Asset, Expense)

**Account balance** = Sum(credits) - Sum(debits) for **credit-normal accounts** (Liability, Equity, Revenue)

**Trial Balance:**
- Sum of all debit balances = Sum of all credit balances
- If unbalanced, there is an error in the ledger

---

## 2. Invoice Workflow

### Status Transitions

```
draft → sent → viewed → paid
  ↓       ↓       ↓
  └─────→ cancelled
```

```mermaid
stateDiagram-v2
    [*] --> draft : POST /invoices\n(auto-number: INV-YYYY-NNN)

    draft --> sent : PATCH /invoices/:id/status\naction=send\n[generates PDF → R2]\n[sends email via SendGrid]\n[creates Transaction:\nDR Receivable / CR Revenue]

    sent --> viewed : Email tracking pixel loaded\n[updates invoice.viewedAt]

    viewed --> paid : PATCH status action=mark-paid\n[creates Transaction:\nDR Bank / CR Receivable]

    sent --> paid : PATCH status action=mark-paid\n[creates Transaction:\nDR Bank / CR Receivable]

    draft --> cancelled : PATCH status action=cancel
    sent --> cancelled : PATCH status action=cancel\n[reverses Transaction]
    viewed --> cancelled : PATCH status action=cancel\n[reverses Transaction]

    paid --> [*]
    cancelled --> [*]

    note right of draft
        Editable: items, dates, amounts
        Invoice number locked on first save
    end note

    note right of sent
        LOCKED — cannot edit amounts
        PDF stored in Cloudflare R2
        exchangeRate locked at invoiceDate
    end note

    note right of paid
        2 GL Transactions created total:
        1. draft→sent: DR Receivable / CR Revenue
        2. paid: DR Bank / CR Receivable
    end note
```

### Invoice Calculation Flow

```mermaid
flowchart TD
    ITEMS[Invoice Items\nquantity × unitPrice = lineTotal]
    ITEMS --> SUB[subtotal = SUM all lineTotals]
    SUB --> TAX[taxAmount = SUM lineTotal × taxRate/100]
    TAX --> DISC[Apply discountAmount]
    DISC --> TOTAL[totalAmount = subtotal + taxAmount - discountAmount]
    TOTAL --> BASE[baseAmount = totalAmount × exchangeRate\nexchangeRate locked at invoiceDate]
    BASE --> LOCK[Store — NEVER recalculate\nfrom future exchange rates]

    style LOCK fill:#f87171,color:#fff
    style BASE fill:#ffd700,color:#000
```

**Status rules:**

| From | To | Action | Transaction Created? |
|------|------|--------|---------------------|
| draft | sent | Send email | Yes (Debit Receivable, Credit Revenue) |
| sent | viewed | Email opened | No |
| viewed | paid | Mark paid | Yes (Debit Bank, Credit Receivable) |
| sent | paid | Mark paid | Yes (Debit Bank, Credit Receivable) |
| any | cancelled | Cancel | Reverses original transaction |

### Business Rules

#### Rule 1: Invoice Number Auto-Generation
- Format: `INV-YYYY-NNN` (e.g., `INV-2026-001`)
- Generated on first save (when status changes from null → draft)
- Sequential within organization per year
- NEVER reuse cancelled invoice numbers

#### Rule 2: Draft-Only Editing
- Can only edit invoice if `status = 'draft'`
- Once sent, cannot change line items or amounts
- Can still update notes/terms

#### Rule 3: Overdue Detection
- Invoice becomes `overdue` if `dueDate < today AND status != 'paid'`
- Checked automatically via scheduled job (daily at 00:00 UTC)

#### Rule 4: Subtotal Calculation
```
subtotal = SUM(lineTotal) for all invoice items
lineTotal = quantity * unitPrice
```

#### Rule 5: Tax Calculation
```
taxAmount = SUM(lineTotal * (taxRate / 100)) for all items
```

#### Rule 6: Total Calculation
```
totalAmount = subtotal + taxAmount - discountAmount
```

#### Rule 7: Base Amount Conversion
```
baseAmount = totalAmount * exchangeRate
```
- `exchangeRate` locked at `invoiceDate`
- NEVER recalculated

#### Rule 8: PDF Generation
- PDF generated when status → sent
- Stored in Cloudflare R2
- URL saved to `invoice.pdfUrl`
- PDF includes: org branding, line items, tax breakdown, payment terms

#### Rule 9: Email Delivery
- Sent to `contact.email`
- Subject: "Invoice [invoiceNumber] from [organizationName]"
- Attachment: PDF
- Tracking pixel for `viewedAt` timestamp

---

## 3. Expense Workflow

### Status Transitions

```
pending → approved → paid
   ↓
rejected
```

```mermaid
stateDiagram-v2
    [*] --> pending : POST /expenses\n(auto-number: EXP-YYYY-NNN)\ncreatedBy: accountant/admin/owner

    pending --> approved : PATCH /expenses/:id/approve\nRoles: owner, admin ONLY\n[creates Transaction:\nDR Expense / CR Accounts Payable]

    pending --> rejected : PATCH /expenses/:id/reject\nRoles: owner, admin ONLY\n[no Transaction created]

    approved --> paid : PATCH /expenses/:id/pay\n[creates Transaction:\nDR Accounts Payable / CR Bank]

    paid --> [*]
    rejected --> [*]

    note right of pending
        Can be edited before approval
        Receipt upload optional (max 10MB)
        PDF/PNG/JPG formats
    end note

    note right of approved
        Cannot edit after approval
        Stored in Cloudflare R2 receipts/
        exchangeRate locked at expenseDate
    end note
```

**Status rules:**

| From | To | Action | Transaction Created? |
|------|------|--------|---------------------|
| pending | approved | Approve | Yes (Debit Expense, Credit Payable) |
| pending | rejected | Reject | No |
| approved | paid | Mark paid | Yes (Debit Payable, Credit Bank) |

### Business Rules

#### Rule 1: Expense Number Auto-Generation
- Format: `EXP-YYYY-NNN` (e.g., `EXP-2026-001`)
- Generated on creation
- Sequential within organization per year

#### Rule 2: Approval Required
- Expenses created with `status = 'pending'`
- Only `owner` or `admin` can approve
- `accountant` can create but cannot approve
- Once approved, cannot be edited

#### Rule 3: Receipt Upload
- Optional but recommended
- Max file size: 10MB
- Allowed formats: PDF, PNG, JPG
- Stored in Cloudflare R2
- URL saved to `expense.receiptUrl`

#### Rule 4: Category Tracking
- Free-text category field
- Common categories suggested: Infrastructure, Software, Office, Travel, Marketing, Utilities
- Used for expense reports by category

#### Rule 5: Tax Amount
- Optional `taxAmount` field
- If provided, represents input VAT (can be deducted from output VAT)
- Used in VAT report

#### Rule 6: Base Amount Conversion
```
baseAmount = amount * exchangeRate
```
- `exchangeRate` locked at `expenseDate`

---

## 4. VAT Calculation

### VAT Calculation Flow

```mermaid
flowchart TD
    subgraph OUTPUT [Output VAT — Sales]
        INV[Invoice sent to customer]
        INV --> OLINE[For each line item:\nlineTotal = qty × unitPrice\nlineTaxAmount = lineTotal × taxRate/100]
        OLINE --> OTOT[Invoice taxAmount = SUM all lineTaxAmounts]
        OTOT --> OREC[Recorded as Output VAT\nin VAT Report]
    end

    subgraph INPUT [Input VAT — Purchases]
        EXP[Expense from vendor]
        EXP --> ETAX[expense.taxAmount field\nUser-entered or calculated]
        ETAX --> IREC[Recorded as Input VAT\nin VAT Report]
    end

    subgraph NET [Net VAT Calculation]
        OREC --> CALC[netVAT = outputVAT - inputVAT]
        IREC --> CALC
        CALC --> POS{netVAT > 0?}
        POS -->|Yes| OWE[Owe to tax authority\nFile PDV/VAT return]
        POS -->|No| REF[Tax authority owes refund\nRare for SMBs]
    end

    style OWE fill:#f87171,color:#fff
    style REF fill:#4ade80,color:#000
```

### VAT Rates by Country

| Country | Standard VAT | Reduced VAT | Zero VAT |
|---------|-------------|------------|----------|
| Serbia (RS) | 20% | 10% | 0% |
| BiH (BA) | 17% | - | 0% |
| Croatia (HR) | 25% | 13% | 0% |

### Business Rules

#### Rule 1: Tax Rate Application
- Invoice items have `taxRate` field (percentage)
- Default to organization's country standard rate
- User can override per line item

#### Rule 2: Tax Amount Calculation
```
For each invoice item:
  lineTotal = quantity * unitPrice
  lineTaxAmount = lineTotal * (taxRate / 100)

For invoice:
  subtotal = SUM(lineTotal)
  taxAmount = SUM(lineTaxAmount)
  totalAmount = subtotal + taxAmount - discountAmount
```

#### Rule 3: Output VAT (Sales)
- VAT collected on invoices sent to customers
- Recorded when invoice status → sent
- Included in VAT report as "Output VAT"

#### Rule 4: Input VAT (Purchases)
- VAT paid on expenses from vendors
- Recorded from `expense.taxAmount` field
- Included in VAT report as "Input VAT"

#### Rule 5: Net VAT Calculation
```
netVAT = outputVAT - inputVAT
```
- If positive: owe VAT to tax authority
- If negative: tax authority owes refund (rare for small businesses)

### VAT Report Structure

```typescript
interface VATReport {
  period: { from: string, to: string }

  outputVAT: {
    total: Decimal                    // Total VAT collected
    invoices: Array<{
      invoiceNumber: string
      customerName: string
      invoiceDate: string
      baseAmount: Decimal             // Subtotal
      vatAmount: Decimal              // Tax amount
      vatRate: Decimal                // Tax rate %
    }>
  }

  inputVAT: {
    total: Decimal                    // Total VAT paid
    expenses: Array<{
      expenseNumber: string
      vendorName: string
      expenseDate: string
      baseAmount: Decimal
      vatAmount: Decimal
      vatRate: Decimal
    }>
  }

  netVAT: Decimal                     // outputVAT - inputVAT

  reconciliationStatus: {
    allInvoicesPaid: boolean          // All invoices in period are paid
    allExpensesApproved: boolean      // All expenses in period are approved
    unmatchedTransactions: number     // Unreconciled bank transactions
  }
}
```

---

## 5. Multi-Currency

### Supported Currencies

**MVP:**
- EUR (Euro) — default
- RSD (Serbian Dinar)
- BAM (Bosnian Mark)
- HRK (Croatian Kuna)
- USD (US Dollar)

### Exchange Rate Locking

**CRITICAL RULE:** Exchange rates are locked at transaction date.

**Why:**
- Financial reports must be consistent over time
- Cannot recalculate historical transactions with current rates
- Accounting standards require rate at transaction date

**How it works:**

1. **Invoice created on 2026-02-20:**
   - `currencyCode = 'RSD'`
   - `exchangeRate = 117.50` (EUR to RSD rate on 2026-02-20)
   - `totalAmount = 125,000 RSD`
   - `baseAmount = 125,000 / 117.50 = 1,063.83 EUR` (locked)

2. **Today (2026-03-15), rate is now 120.00:**
   - Invoice `baseAmount` stays 1,063.83 EUR
   - NEVER recalculated to `125,000 / 120.00 = 1,041.67 EUR`

### Exchange Rate Sources

**Primary:** European Central Bank (ECB) API
- Free
- Daily updates
- Reliable

**Fallback:** fixer.io API
- Freemium (1000 requests/month free)
- Real-time rates

**Manual Entry:**
- If API unavailable, user can enter rate manually
- Stored with `source = 'manual'`

### Base Currency Conversion

All reports displayed in organization's `baseCurrency`.

**Example:**
- Organization baseCurrency = EUR
- Invoice 1: 125,000 RSD → 1,063.83 EUR (rate 117.50)
- Invoice 2: 3,500 EUR → 3,500 EUR (rate 1.0)
- Expense 1: 850 USD → 794.39 EUR (rate 1.07)

**Total Revenue:** 1,063.83 + 3,500 = 4,563.83 EUR

---

## 6. Bank Reconciliation

### Purpose

Match bank transactions (from statements) to general ledger transactions (from invoices/expenses).

```mermaid
flowchart TD
    CSV[Bank Statement CSV\nDate, Description, Amount, Reference]
    CSV --> PARSE[Parse & validate CSV\nCreate BankTransaction records]
    PARSE --> LINK[Link to BankAccount]

    LINK --> MATCH[Auto-Match Algorithm\nScore 0-100]

    subgraph SCORE [Match Score Calculation]
        S1[+50 pts: Exact amount match]
        S2[+30 pts: Same date\n+20 pts: ±1 day\n+10 pts: ±3 days]
        S3[+20 pts: Reference contains\ninvoice/expense number]
    end

    MATCH --> SCORE
    SCORE --> THRESH{Score?}

    THRESH -->|≥ 90| AUTO[Auto-match\nreconciled = true]
    THRESH -->|70-89| SUGGEST[Suggest to user\nUser confirms]
    THRESH -->|< 70| MANUAL[Manual review\nUser links manually]

    SUGGEST --> CONFIRM{User\nconfirms?}
    CONFIRM -->|Yes| RECONCILE[Set reconciled = true\nmatchedTransactionId = glTxId]
    CONFIRM -->|No| MANUAL

    MANUAL --> RECONCILE

    AUTO --> RECONCILE
    RECONCILE --> REPORT[Reconciliation Report\nbalanceDiscrepancy should = 0]

    style AUTO fill:#4ade80,color:#000
    style MANUAL fill:#fb923c,color:#000
    style REPORT fill:#60a5fa,color:#000
```

### Process

1. **Import bank statement (CSV):**
   - Parse CSV file
   - Create `BankTransaction` records
   - Link to `BankAccount`

2. **Auto-match transactions:**
   - Match by amount + date (within ±3 days)
   - Match by reference (invoice number in description)
   - Calculate confidence score (0-100)

3. **Manual reconciliation:**
   - User links `BankTransaction` to `Transaction`
   - Set `bankTransaction.reconciled = true`
   - Set `bankTransaction.matchedTransactionId = transaction.id`

4. **Unmatched transactions:**
   - Flag in reconciliation report
   - User must create manual journal entry or mark as miscellaneous

### Matching Algorithm

**Score calculation:**

```typescript
function calculateMatchScore(
  bankTx: BankTransaction,
  glTx: Transaction
): number {
  let score = 0

  // Exact amount match
  if (Math.abs(bankTx.amount) === glTx.amount) {
    score += 50
  }

  // Date within ±3 days
  const daysDiff = Math.abs(
    daysBetween(bankTx.transactionDate, glTx.transactionDate)
  )
  if (daysDiff === 0) score += 30
  else if (daysDiff <= 1) score += 20
  else if (daysDiff <= 3) score += 10

  // Reference contains invoice/expense number
  if (glTx.referenceType === 'invoice' && bankTx.description?.includes(glTx.referenceId)) {
    score += 20
  }

  return score
}
```

**Auto-match threshold:**
- Score ≥ 90 → Auto-match
- Score 70-89 → Suggest match (user confirms)
- Score < 70 → No match suggested

### Reconciliation Report

```typescript
interface ReconciliationReport {
  bankAccount: {
    id: string
    name: string
    currentBalance: Decimal
  }

  period: { from: string, to: string }

  bankTransactions: {
    total: number
    reconciled: number
    unreconciled: number
    totalAmount: Decimal
  }

  glTransactions: {
    total: number
    reconciled: number
    unreconciled: number
    totalAmount: Decimal
  }

  unmatchedBankTransactions: Array<BankTransaction>
  unmatchedGLTransactions: Array<Transaction>

  balanceDiscrepancy: Decimal       // Should be 0 when fully reconciled
}
```

---

## 7. Chart of Accounts

### Structure

**Hierarchical account codes:**
- 1xxx = Assets
- 2xxx = Liabilities
- 3xxx = Equity
- 4xxx = Revenue
- 5xxx = Expenses

**Example Serbian Chart of Accounts:**

```
1000  Assets
  1100  Current Assets
    1110  Cash
    1120  Bank Accounts
      1121  Intesa RSD Account
      1122  Raiffeisen EUR Account
    1200  Accounts Receivable
  1500  Fixed Assets
    1510  Equipment
    1520  Vehicles

2000  Liabilities
  2100  Current Liabilities
    2110  Accounts Payable
    2120  VAT Payable
  2500  Long-term Liabilities
    2510  Loans Payable

3000  Equity
  3100  Share Capital
  3900  Retained Earnings

4000  Revenue
  4100  Service Revenue
  4200  Product Sales

5000  Expenses
  5100  Operating Expenses
    5110  Salaries
    5120  Rent
    5130  Utilities
  5200  Cost of Goods Sold
```

### Business Rules

#### Rule 1: Account Hierarchy
- Parent account codes must exist before creating child accounts
- Cannot delete parent account if child accounts exist
- Sub-account balance rolls up to parent

#### Rule 2: Account Deactivation
- Cannot deactivate account with transactions
- Deactivated accounts hidden from dropdowns but visible in reports

#### Rule 3: Reserved Accounts
- System creates default accounts on organization registration
- Cannot delete: Cash, Bank Account, Accounts Receivable, Accounts Payable, Revenue, Expense

---

## 8. Fiscal Year

### Definition

**Fiscal year:** 12-month period for financial reporting.

**Default:** January 1 - December 31

**Configurable:** Organization can set custom fiscal year start (e.g., April 1 for UK-style fiscal year)

### Business Rules

#### Rule 1: Year-End Close
- At fiscal year end, close Revenue and Expense accounts
- Transfer net profit/loss to Retained Earnings
- Lock all transactions for closed fiscal year
- Cannot edit locked transactions

#### Rule 2: Period-Based Reports
- Profit & Loss: always for a period (from → to)
- Balance Sheet: always as of a date (point in time)
- Cash Flow: always for a period

---

## 9. Audit Trail

### Purpose

**Immutable log** of all data changes for:
- Compliance (GDPR, financial regulations)
- Debugging (track down errors)
- Rollback simulation (undo mistakes)

### What is Logged

**ALL** INSERT/UPDATE/DELETE operations on:
- Invoices
- Expenses
- Transactions
- Contacts
- Users
- Organization settings

**Captured data:**
- Table name
- User ID (who made the change)
- Timestamp
- Action (INSERT, UPDATE, DELETE)
- Old values (before change)
- New values (after change)
- Client IP address

### Implementation

**Via Prisma Middleware:**
```typescript
prisma.$use(async (params, next) => {
  const result = await next(params)

  if (['create', 'update', 'delete'].includes(params.action)) {
    await prisma.loggedAction.create({
      data: {
        tableName: params.model,
        userId: getCurrentUserId(),
        action: params.action.toUpperCase(),
        rowData: params.action === 'delete' ? params.args.where : null,
        changedFields: params.action === 'update' ? params.args.data : null,
        clientIp: getClientIp(),
        applicationName: 'bilko-api'
      }
    })
  }

  return result
})
```

### Retention Policy

- Audit logs retained for **7 years** (financial compliance requirement)
- After 7 years, archived to cold storage (AWS S3 Glacier)
- NEVER deleted

---

## Summary of Critical Business Rules

1. **Double-entry:** Every transaction has one debit and one credit
2. **Debits = Credits:** Ledger must always balance
3. **Exchange rate locking:** Rates locked at transaction date, NEVER recalculated
4. **Invoice workflow:** draft → sent → paid (creates 2 transactions)
5. **Expense workflow:** pending → approved → paid (creates 2 transactions)
6. **VAT calculation:** `taxAmount = lineTotal * (taxRate / 100)`
7. **Account hierarchy:** Parent-child relationships in Chart of Accounts
8. **Audit trail:** ALL changes logged immutably
9. **Fiscal year close:** Lock transactions, transfer P&L to Retained Earnings
10. **Reconciliation:** Match bank transactions to GL transactions

**End of Business Logic Documentation**

# Middleware Stack

# Bilko Middleware Stack

> **Status:** SPECIFICATION (backend not implemented)
> **Last updated:** 2026-02-20

---

## Purpose

This document specifies the Express middleware stack for Bilko's backend. Middleware order is CRITICAL — security, authentication, validation, and error handling must execute in the correct sequence.

---

## Middleware Execution Order

**The order matters.** Middleware executes top-to-bottom:

### Full Middleware Pipeline

```mermaid
flowchart TD
    REQ[Incoming HTTP Request]

    REQ --> H[1. Helmet\nSecurity Headers\nHSTS, CSP, X-Frame-Options,\nX-Content-Type-Options]

    H --> CORS[2. CORS\nAllow: bilko.io, localhost:3000\ncredentials: true\nmaxAge: 86400]

    CORS --> BP[3. Body Parser\nexpress.json limit=10mb\nexpress.urlencoded]

    BP --> RL{4. Rate Limiter\nAuth: 5 req/min\nGeneral: 100 req/min}

    RL -->|Exceeded| R429[429 Too Many Requests]
    RL -->|OK| LOG[5. Morgan Logger\nWinston transport\nCombined format]

    LOG --> ROUTE[6. Router\n/api/v1/*]

    ROUTE --> AUTH{authGuard\nVerify Bearer JWT}
    AUTH -->|No token| R401A[401 NO_TOKEN]
    AUTH -->|Expired| R401B[401 TOKEN_EXPIRED]
    AUTH -->|Invalid| R401C[401 INVALID_TOKEN]
    AUTH -->|Valid| ATTACH[Attach req.user\n{id, email, role, orgId}]

    ATTACH --> ROLE{roleGuard\nCheck allowed roles}
    ROLE -->|Insufficient| R403[403 INSUFFICIENT_PERMISSIONS]
    ROLE -->|Authorized| VALID{validate\nZod schema}

    VALID -->|Fails| R422[422 VALIDATION_ERROR]
    VALID -->|Passes| SCOPE[organizationScope\nAttach req.organizationId]

    SCOPE --> HANDLER[Route Handler\nBusiness Logic + Prisma]

    HANDLER --> AUDIT[Prisma Middleware\nLoggedAction INSERT]
    AUDIT --> RESP[200/201/204 Response]

    HANDLER -->|Error thrown| ERR[7. Error Handler\nMUST be last middleware]
    ERR --> FMTERR[Format error response\n{error, code, details}]
    FMTERR --> ERRRESP[4xx/500 Response]

    style REQ fill:#00E5A0,color:#000
    style R429 fill:#f87171,color:#fff
    style R401A fill:#f87171,color:#fff
    style R401B fill:#f87171,color:#fff
    style R401C fill:#f87171,color:#fff
    style R403 fill:#f87171,color:#fff
    style R422 fill:#f87171,color:#fff
    style RESP fill:#4ade80,color:#000
```

```typescript
import express from 'express'
import helmet from 'helmet'
import cors from 'cors'
import rateLimit from 'express-rate-limit'
import { authGuard, roleGuard } from './middleware/auth'
import { validate } from './middleware/validation'
import { errorHandler } from './middleware/error-handler'

const app = express()

// 1. Security headers (helmet)
app.use(helmet())

// 2. CORS configuration
app.use(cors(corsOptions))

// 3. Body parsing
app.use(express.json({ limit: '10mb' }))
app.use(express.urlencoded({ extended: true }))

// 4. Rate limiting
app.use(rateLimiter)

// 5. Request logging (Morgan)
app.use(morgan('combined'))

// 6. Routes (with auth + validation per-route)
app.use('/api/v1', routes)

// 7. Error handler (MUST be last)
app.use(errorHandler)
```

---

## 1. Helmet (Security Headers)

**Purpose:** Sets HTTP security headers to prevent common attacks.

**Installation:**
```bash
npm install helmet
```

**Configuration:**
```typescript
import helmet from 'helmet'

app.use(helmet({
  contentSecurityPolicy: {
    directives: {
      defaultSrc: ["'self'"],
      styleSrc: ["'self'", "'unsafe-inline'"],  // Allow inline styles for Next.js
      scriptSrc: ["'self'"],
      imgSrc: ["'self'", "data:", "https://r2.bilko.io"],  // Cloudflare R2
      connectSrc: ["'self'", "https://api.bilko.io"],
      fontSrc: ["'self'"],
      objectSrc: ["'none'"],
      upgradeInsecureRequests: []
    }
  },
  hsts: {
    maxAge: 31536000,            // 1 year
    includeSubDomains: true,
    preload: true
  },
  frameguard: { action: 'deny' },  // Prevent clickjacking
  noSniff: true,                   // Prevent MIME sniffing
  xssFilter: true                  // Enable XSS filter
}))
```

**Headers set:**
- `Strict-Transport-Security` — Force HTTPS
- `X-Content-Type-Options: nosniff` — Prevent MIME sniffing
- `X-Frame-Options: DENY` — Prevent clickjacking
- `X-XSS-Protection: 1; mode=block` — Enable XSS filter
- `Content-Security-Policy` — Restrict resource loading

---

## 2. CORS (Cross-Origin Resource Sharing)

**Purpose:** Allow frontend (Next.js) to call backend API from different origin.

**Installation:**
```bash
npm install cors
```

**Configuration:**
```typescript
import cors from 'cors'

const corsOptions = {
  origin: (origin, callback) => {
    const allowedOrigins = [
      'https://bilko.io',
      'https://www.bilko.io',
      'http://localhost:3000'  // Development only
    ]

    if (!origin || allowedOrigins.includes(origin)) {
      callback(null, true)
    } else {
      callback(new Error('Not allowed by CORS'))
    }
  },
  credentials: true,              // Allow cookies (refresh tokens)
  methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'],
  allowedHeaders: ['Content-Type', 'Authorization'],
  exposedHeaders: ['X-Total-Count', 'X-Page-Count'],  // For pagination
  maxAge: 86400                   // Cache preflight for 24h
}

app.use(cors(corsOptions))
```

**Why credentials: true?**
- Allows httpOnly cookies (refresh tokens)
- Frontend must set `credentials: 'include'` in fetch()

---

## 3. Body Parsing

**Purpose:** Parse JSON request bodies.

**Built-in Express middleware:**
```typescript
app.use(express.json({
  limit: '10mb',                  // Max request body size
  strict: true,                   // Reject non-arrays/objects
  type: 'application/json'
}))

app.use(express.urlencoded({
  extended: true,
  limit: '10mb'
}))
```

**Limits:**
- 10MB for file uploads (receipts, CSV imports)
- Reject if Content-Length > 10MB
- Return `413 Payload Too Large`

---

## 4. Rate Limiting

**Purpose:** Prevent abuse, brute-force attacks, DDoS.

**Installation:**
```bash
npm install express-rate-limit
```

**Configuration:**
```typescript
import rateLimit from 'express-rate-limit'

// General API rate limiter
const apiLimiter = rateLimit({
  windowMs: 60 * 1000,            // 1 minute
  max: 100,                       // Max 100 requests per minute
  message: {
    error: 'Too many requests',
    code: 'RATE_LIMIT_EXCEEDED',
    retryAfter: 60
  },
  standardHeaders: true,          // Return RateLimit-* headers
  legacyHeaders: false,
  handler: (req, res) => {
    res.status(429).json({
      error: 'Too many requests',
      code: 'RATE_LIMIT_EXCEEDED',
      retryAfter: 60
    })
  }
})

// Auth rate limiter (stricter)
const authLimiter = rateLimit({
  windowMs: 60 * 1000,            // 1 minute
  max: 5,                         // Max 5 login attempts per minute
  skipSuccessfulRequests: true,  // Don't count successful logins
  keyGenerator: (req) => {
    return req.ip                 // Rate limit by IP
  }
})

// Apply to routes
app.use('/api/v1', apiLimiter)
app.use('/api/v1/auth/login', authLimiter)
app.use('/api/v1/auth/register', authLimiter)
```

**Rate limits by endpoint:**

| Endpoint | Limit | Window | Why |
|----------|-------|--------|-----|
| /api/v1/auth/login | 5 | 1 min | Prevent brute-force |
| /api/v1/auth/register | 5 | 1 min | Prevent spam registration |
| /api/v1/* (general) | 100 | 1 min | General API protection |
| Write ops (POST/PUT/PATCH) | 50 | 1 min | Prevent resource exhaustion |

---

## 5. Request Logging

**Purpose:** Log all HTTP requests for debugging and monitoring.

**Installation:**
```bash
npm install morgan
npm install winston
```

**Configuration:**
```typescript
import morgan from 'morgan'
import winston from 'winston'

// Winston logger
const logger = winston.createLogger({
  level: 'info',
  format: winston.format.json(),
  transports: [
    new winston.transports.File({ filename: 'error.log', level: 'error' }),
    new winston.transports.File({ filename: 'combined.log' })
  ]
})

if (process.env.NODE_ENV !== 'production') {
  logger.add(new winston.transports.Console({
    format: winston.format.simple()
  }))
}

// Morgan HTTP logging
app.use(morgan('combined', {
  stream: {
    write: (message) => logger.info(message.trim())
  }
}))
```

**Log format:**
```
:remote-addr - :remote-user [:date[clf]] ":method :url HTTP/:http-version" :status :res[content-length] ":referrer" ":user-agent"
```

**Example:**
```
192.168.1.100 - user@example.com [20/Feb/2026:10:30:15 +0000] "POST /api/v1/invoices HTTP/1.1" 201 512 "https://bilko.io" "Mozilla/5.0..."
```

---

### Per-Route Middleware Composition

```mermaid
graph LR
    subgraph PUBLIC [Public Routes — No Auth]
        P1["POST /auth/register\n[rateLimiter(5/min)] → handler"]
        P2["POST /auth/login\n[rateLimiter(5/min)] → handler"]
        P3["POST /auth/refresh\n[rateLimiter(100/min)] → handler"]
        P4["GET /track/email/:id\n[handler — tracking pixel]"]
    end

    subgraph VIEWER [Viewer Routes — All Roles]
        V1["GET /invoices\n[auth] → [orgScope] → handler"]
        V2["GET /reports/*\n[auth] → [orgScope] → handler"]
        V3["GET /contacts\n[auth] → [orgScope] → handler"]
    end

    subgraph ACCOUNTANT [Accountant+ Routes]
        A1["POST /invoices\n[auth] → [role:owner,admin,accountant]\n→ [validate] → [orgScope] → handler"]
        A2["POST /expenses\n[auth] → [role:owner,admin,accountant]\n→ [validate] → handler"]
        A3["POST /transactions\n[auth] → [role:owner,admin,accountant]\n→ [validate] → handler"]
    end

    subgraph ADMIN [Admin+ Routes]
        AD1["PATCH /expenses/:id/approve\n[auth] → [role:owner,admin] → handler"]
        AD2["POST /users/invite\n[auth] → [role:owner,admin] → handler"]
        AD3["PUT /organization\n[auth] → [role:owner,admin] → handler"]
    end

    subgraph OWNER [Owner-Only Routes]
        O1["DELETE /users/:id\n[auth] → [role:owner] → handler"]
        O2["PUT /users/:id/role\n[auth] → [role:owner] → handler"]
        O3["DELETE /organization\n[auth] → [role:owner] → handler"]
    end

    style PUBLIC fill:#e2e8f0,color:#000
    style VIEWER fill:#dcfce7,color:#000
    style ACCOUNTANT fill:#fef9c3,color:#000
    style ADMIN fill:#dbeafe,color:#000
    style OWNER fill:#fce7f3,color:#000
```

## 6. Authentication Middleware

**Purpose:** Verify JWT access token, attach user to request.

**Implementation:**
```typescript
import jwt from 'jsonwebtoken'
import { Request, Response, NextFunction } from 'express'

interface AuthRequest extends Request {
  user?: {
    id: string
    email: string
    role: 'owner' | 'admin' | 'accountant' | 'viewer'
    organizationId: string
  }
}

export async function authGuard(req: AuthRequest, res: Response, next: NextFunction) {
  const authHeader = req.headers.authorization

  if (!authHeader || !authHeader.startsWith('Bearer ')) {
    return res.status(401).json({
      error: 'Unauthorized',
      code: 'NO_TOKEN'
    })
  }

  const token = authHeader.substring(7)  // Remove 'Bearer '

  try {
    const payload = jwt.verify(token, process.env.JWT_SECRET!) as {
      sub: string
      email: string
      role: string
      orgId: string
    }

    // Attach user to request
    req.user = {
      id: payload.sub,
      email: payload.email,
      role: payload.role as any,
      organizationId: payload.orgId
    }

    next()
  } catch (error) {
    if (error.name === 'TokenExpiredError') {
      return res.status(401).json({
        error: 'Token expired',
        code: 'TOKEN_EXPIRED'
      })
    }

    if (error.name === 'JsonWebTokenError') {
      return res.status(401).json({
        error: 'Invalid token',
        code: 'INVALID_TOKEN'
      })
    }

    return res.status(500).json({
      error: 'Authentication error',
      code: 'AUTH_ERROR'
    })
  }
}
```

**Usage in routes:**
```typescript
app.get('/api/v1/invoices', authGuard, getInvoices)
```

---

## 7. Role-Based Access Control (RBAC)

**Purpose:** Restrict endpoints by user role.

**Implementation:**
```typescript
type UserRole = 'owner' | 'admin' | 'accountant' | 'viewer'

export function roleGuard(allowedRoles: UserRole[]) {
  return (req: AuthRequest, res: Response, next: NextFunction) => {
    if (!req.user) {
      return res.status(401).json({
        error: 'Unauthorized',
        code: 'NO_AUTH'
      })
    }

    if (!allowedRoles.includes(req.user.role)) {
      return res.status(403).json({
        error: 'Forbidden',
        code: 'INSUFFICIENT_PERMISSIONS',
        details: {
          required: allowedRoles,
          current: req.user.role
        }
      })
    }

    next()
  }
}
```

**Usage in routes:**
```typescript
// Only owner and admin can delete users
app.delete('/api/v1/users/:id',
  authGuard,
  roleGuard(['owner', 'admin']),
  deleteUser
)

// Everyone can view invoices
app.get('/api/v1/invoices',
  authGuard,
  getInvoices
)

// Only owner, admin, accountant can create invoices
app.post('/api/v1/invoices',
  authGuard,
  roleGuard(['owner', 'admin', 'accountant']),
  createInvoice
)
```

---

## 8. Request Validation

**Purpose:** Validate request body, query, params with Zod schemas.

**Installation:**
```bash
npm install zod
```

**Implementation:**
```typescript
import { z } from 'zod'
import { Request, Response, NextFunction } from 'express'

type ValidateTarget = 'body' | 'query' | 'params'

export function validate(schema: z.ZodSchema, target: ValidateTarget = 'body') {
  return (req: Request, res: Response, next: NextFunction) => {
    try {
      const data = req[target]
      const validated = schema.parse(data)

      // Replace with validated data (coerced types)
      req[target] = validated

      next()
    } catch (error) {
      if (error instanceof z.ZodError) {
        return res.status(422).json({
          error: 'Validation failed',
          code: 'VALIDATION_ERROR',
          details: error.flatten().fieldErrors
        })
      }

      return res.status(500).json({
        error: 'Validation error',
        code: 'VALIDATION_ERROR'
      })
    }
  }
}
```

**Usage in routes:**
```typescript
import { z } from 'zod'

const createInvoiceSchema = z.object({
  customerId: z.string().uuid(),
  invoiceDate: z.string().date(),
  dueDate: z.string().date(),
  items: z.array(z.object({
    description: z.string().min(1).max(500),
    quantity: z.number().positive(),
    unitPrice: z.number().positive(),
    taxRate: z.number().min(0).max(100)
  })).min(1)
})

app.post('/api/v1/invoices',
  authGuard,
  roleGuard(['owner', 'admin', 'accountant']),
  validate(createInvoiceSchema, 'body'),
  createInvoice
)
```

**Error response:**
```json
{
  "error": "Validation failed",
  "code": "VALIDATION_ERROR",
  "details": {
    "customerId": ["Invalid UUID"],
    "items.0.quantity": ["Must be positive"]
  }
}
```

---

## 9. Organization Scoping

**Purpose:** Automatically filter all queries by `organizationId` to enforce multi-tenancy.

**Implementation:**
```typescript
export function organizationScope(req: AuthRequest, res: Response, next: NextFunction) {
  if (!req.user) {
    return res.status(401).json({
      error: 'Unauthorized',
      code: 'NO_AUTH'
    })
  }

  // Attach organizationId to request for easy access
  req.organizationId = req.user.organizationId

  next()
}
```

**Usage in Prisma queries:**
```typescript
async function getInvoices(req: AuthRequest, res: Response) {
  const invoices = await prisma.invoice.findMany({
    where: {
      organizationId: req.user!.organizationId  // Always filter by org
    }
  })

  res.json({ data: invoices })
}
```

**CRITICAL:** NEVER allow cross-organization queries. Always filter by `organizationId`.

---

## 10. Error Handler

**Purpose:** Catch all errors, format consistently, log, return to client.

**MUST be the last middleware.**

```mermaid
flowchart TD
    ERR[Error Thrown by any Middleware or Handler]
    ERR --> LOG_ERR[Log to Winston:\nmessage, stack, path, method, userId]
    LOG_ERR --> TYPE{Error Type?}

    TYPE -->|PrismaClientKnownRequestError| PRISMA{Prisma Code?}
    PRISMA -->|P2002 Unique violation| R400A[400 DUPLICATE_RESOURCE\n{field: target}]
    PRISMA -->|P2003 Foreign key| R404A[404 FOREIGN_KEY_ERROR]
    PRISMA -->|P2025 Record not found| R404B[404 NOT_FOUND]
    PRISMA -->|Other| R500[500 INTERNAL_ERROR]

    TYPE -->|ValidationError| R422[422 VALIDATION_ERROR]
    TYPE -->|JsonWebTokenError| R401A[401 INVALID_TOKEN]
    TYPE -->|TokenExpiredError| R401B[401 AUTH_ERROR]
    TYPE -->|Custom AppError| CUSTOM[err.status / err.code]
    TYPE -->|Unknown| R500

    R400A & R404A & R404B & R422 & R401A & R401B & CUSTOM & R500 --> FORMAT[Format Response:\n{ error, code, details? }]
    FORMAT --> SEND[Send to Client]

    style ERR fill:#f87171,color:#fff
    style R500 fill:#dc2626,color:#fff
    style FORMAT fill:#60a5fa,color:#000
```

**Implementation:**
```typescript
import { Request, Response, NextFunction } from 'express'
import { Prisma } from '@prisma/client'

export function errorHandler(err: any, req: Request, res: Response, next: NextFunction) {
  // Log error
  logger.error('Error:', {
    message: err.message,
    stack: err.stack,
    path: req.path,
    method: req.method,
    user: req.user?.id
  })

  // Prisma errors
  if (err instanceof Prisma.PrismaClientKnownRequestError) {
    // Unique constraint violation
    if (err.code === 'P2002') {
      return res.status(400).json({
        error: 'Resource already exists',
        code: 'DUPLICATE_RESOURCE',
        details: { field: err.meta?.target }
      })
    }

    // Foreign key constraint violation
    if (err.code === 'P2003') {
      return res.status(404).json({
        error: 'Related resource not found',
        code: 'FOREIGN_KEY_ERROR'
      })
    }

    // Record not found
    if (err.code === 'P2025') {
      return res.status(404).json({
        error: 'Resource not found',
        code: 'NOT_FOUND'
      })
    }
  }

  // Validation errors (already handled by validate middleware)
  if (err.name === 'ValidationError') {
    return res.status(422).json({
      error: err.message,
      code: 'VALIDATION_ERROR'
    })
  }

  // JWT errors (already handled by authGuard)
  if (err.name === 'JsonWebTokenError' || err.name === 'TokenExpiredError') {
    return res.status(401).json({
      error: 'Authentication failed',
      code: 'AUTH_ERROR'
    })
  }

  // Default error
  res.status(err.status || 500).json({
    error: err.message || 'Internal server error',
    code: err.code || 'INTERNAL_ERROR'
  })
}
```

**Error response format:**
```json
{
  "error": "Human-readable error message",
  "code": "MACHINE_READABLE_CODE",
  "details": {
    "field": "Additional context"
  }
}
```

---

## Complete Middleware Stack Example

```typescript
import express from 'express'
import helmet from 'helmet'
import cors from 'cors'
import morgan from 'morgan'
import rateLimit from 'express-rate-limit'
import { authGuard, roleGuard, organizationScope } from './middleware/auth'
import { validate } from './middleware/validation'
import { errorHandler } from './middleware/error-handler'
import routes from './routes'

const app = express()

// 1. Security headers
app.use(helmet(helmetConfig))

// 2. CORS
app.use(cors(corsOptions))

// 3. Body parsing
app.use(express.json({ limit: '10mb' }))
app.use(express.urlencoded({ extended: true }))

// 4. Rate limiting
app.use('/api/v1', apiLimiter)
app.use('/api/v1/auth/login', authLimiter)
app.use('/api/v1/auth/register', authLimiter)

// 5. Request logging
app.use(morgan('combined', { stream: logger.stream }))

// 6. Routes
app.use('/api/v1', routes)

// 7. Error handler (MUST be last)
app.use(errorHandler)

// Start server
const PORT = process.env.PORT || 4000
app.listen(PORT, () => {
  console.log(`Server running on port ${PORT}`)
})
```

---

## Middleware Testing

**Unit tests for each middleware:**

```typescript
import { describe, it, expect } from 'vitest'
import request from 'supertest'
import app from '../app'

describe('Auth Middleware', () => {
  it('blocks request without token', async () => {
    const res = await request(app).get('/api/v1/invoices')
    expect(res.status).toBe(401)
    expect(res.body.code).toBe('NO_TOKEN')
  })

  it('blocks request with expired token', async () => {
    const expiredToken = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...'
    const res = await request(app)
      .get('/api/v1/invoices')
      .set('Authorization', `Bearer ${expiredToken}`)

    expect(res.status).toBe(401)
    expect(res.body.code).toBe('TOKEN_EXPIRED')
  })

  it('allows request with valid token', async () => {
    const validToken = generateToken({ sub: 'user-id', role: 'owner', orgId: 'org-id' })
    const res = await request(app)
      .get('/api/v1/invoices')
      .set('Authorization', `Bearer ${validToken}`)

    expect(res.status).not.toBe(401)
  })
})

describe('Role Guard', () => {
  it('blocks accountant from deleting users', async () => {
    const token = generateToken({ sub: 'user-id', role: 'accountant', orgId: 'org-id' })
    const res = await request(app)
      .delete('/api/v1/users/other-user-id')
      .set('Authorization', `Bearer ${token}`)

    expect(res.status).toBe(403)
    expect(res.body.code).toBe('INSUFFICIENT_PERMISSIONS')
  })
})
```

---

**End of Middleware Documentation**

# External Services Integration

# Bilko External Services

> **Status:** SPECIFICATION (backend not implemented)
> **Last updated:** 2026-02-20

---

## Purpose

This document specifies the external service integrations for Bilko's backend. Covers email delivery, file storage, exchange rates, and PDF generation.

---

## Service Integration Architecture

```mermaid
graph TD
    subgraph BILKO [Bilko Backend — apps/api]
        API[Express API\nPort 4000]
        PDF[PDF Service\nPuppeteer]
        RATE[Exchange Rate Service\nCron: daily 00:00 UTC]
        AUDIT[Prisma Middleware\nAudit Logger]
    end

    subgraph EXTERNAL [External Services]
        SG[SendGrid\nnoreply@bilko.io\n100 emails/day free]
        R2[Cloudflare R2\nbilko-files bucket\n10GB free]
        ECB[ECB API\nexchangerate.host\nFree, daily rates]
        FIXER[Fixer.io\n100 req/month free\nFallback]
        CLAM[ClamAV\nVirus Scanner\nlocalhost:3310]
    end

    subgraph STORAGE [Database]
        PG[PostgreSQL 14+\nAll data\nExchangeRate cache]
    end

    API -->|Invoice email + PDF| SG
    API -->|Receipt upload| R2
    PDF -->|Store generated PDF| R2
    RATE -->|Primary rates fetch| ECB
    RATE -->|Fallback if ECB fails| FIXER
    ECB & FIXER -->|Store in DB| PG
    API -->|Scan uploaded files| CLAM
    API -->|All reads/writes| PG
    AUDIT -->|Append-only log| PG

    style SG fill:#00b0f0,color:#fff
    style R2 fill:#f6821f,color:#fff
    style ECB fill:#0070f3,color:#fff
    style FIXER fill:#6366f1,color:#fff
    style PG fill:#336791,color:#fff
    style CLAM fill:#e53e3e,color:#fff
```

---

## Table of Contents

1. [SendGrid (Email Delivery)](#sendgrid-email-delivery)
2. [Cloudflare R2 (File Storage)](#cloudflare-r2-file-storage)
3. [Exchange Rate APIs](#exchange-rate-apis)
4. [PDF Generation](#pdf-generation)
5. [Error Handling & Fallbacks](#error-handling--fallbacks)

---

## 1. SendGrid (Email Delivery)

### Purpose

Send invoice emails, payment reminders, user invitations, and password resets.

### Invoice Email + Tracking Flow

```mermaid
sequenceDiagram
    participant API as Bilko API
    participant PDF as PDF Service\n(Puppeteer)
    participant R2 as Cloudflare R2
    participant SG as SendGrid
    participant CUSTOMER as Customer Email

    Note over API,CUSTOMER: Invoice Send Flow
    API->>PDF: generateInvoicePDF(invoiceId)
    PDF->>PDF: Launch headless Chromium\nRender HTML template\nExport A4 PDF
    PDF-->>API: PDF Buffer
    API->>R2: PUT invoices/{orgId}/INV-2026-001.pdf
    R2-->>API: Public URL stored
    API->>API: Update invoice.pdfUrl
    API->>SG: sendEmail({ to, subject, html, attachment:pdf })
    SG-->>API: { messageId }
    API->>API: Update invoice.sentAt, status='sent'
    SG->>CUSTOMER: Deliver email with PDF attachment
    CUSTOMER->>API: GET /track/email/{invoiceId}\n[1x1 pixel load]
    API->>API: UPDATE invoice SET status='viewed'\nviewedAt=now()
```

### Setup

**Account:** SendGrid (free tier: 100 emails/day)

**Installation:**
```bash
npm install @sendgrid/mail
```

**Environment Variables:**
```bash
SENDGRID_API_KEY=SG.xxxxx
SENDGRID_FROM_EMAIL=noreply@bilko.io
SENDGRID_FROM_NAME=Bilko
```

### Configuration

```typescript
import sgMail from '@sendgrid/mail'

sgMail.setApiKey(process.env.SENDGRID_API_KEY!)

interface SendEmailOptions {
  to: string | string[]
  cc?: string[]
  bcc?: string[]
  subject: string
  text: string
  html: string
  attachments?: Array<{
    content: string      // Base64 encoded
    filename: string
    type: string         // MIME type
    disposition: 'attachment' | 'inline'
  }>
}

async function sendEmail(options: SendEmailOptions) {
  const msg = {
    to: options.to,
    cc: options.cc,
    bcc: options.bcc,
    from: {
      email: process.env.SENDGRID_FROM_EMAIL!,
      name: process.env.SENDGRID_FROM_NAME!
    },
    subject: options.subject,
    text: options.text,
    html: options.html,
    attachments: options.attachments
  }

  try {
    const response = await sgMail.send(msg)
    return {
      success: true,
      messageId: response[0].headers['x-message-id']
    }
  } catch (error) {
    logger.error('SendGrid error:', error)
    throw new Error('Failed to send email')
  }
}
```

### Email Templates

#### 1. Invoice Email

**Template variables:**
- `{{ organizationName }}`
- `{{ invoiceNumber }}`
- `{{ customerName }}`
- `{{ totalAmount }}`
- `{{ currencyCode }}`
- `{{ dueDate }}`
- `{{ viewInvoiceUrl }}`

**HTML template:**
```html
<!DOCTYPE html>
<html>
<head>
  <meta charset="utf-8">
  <title>Invoice {{ invoiceNumber }}</title>
  <style>
    body { font-family: Inter, sans-serif; }
    .container { max-width: 600px; margin: 0 auto; padding: 20px; }
    .header { background: #09090b; color: #fff; padding: 20px; text-align: center; }
    .content { padding: 20px; background: #f9fafb; }
    .footer { padding: 20px; text-align: center; color: #6b7280; }
    .button { display: inline-block; padding: 12px 24px; background: #00E5A0; color: #000; text-decoration: none; border-radius: 6px; }
  </style>
</head>
<body>
  <div class="container">
    <div class="header">
      <h1>{{ organizationName }}</h1>
    </div>
    <div class="content">
      <p>Dear {{ customerName }},</p>
      <p>Your invoice <strong>{{ invoiceNumber }}</strong> is ready.</p>
      <table>
        <tr><td>Amount:</td><td><strong>{{ totalAmount }} {{ currencyCode }}</strong></td></tr>
        <tr><td>Due Date:</td><td>{{ dueDate }}</td></tr>
      </table>
      <p><a href="{{ viewInvoiceUrl }}" class="button">View Invoice</a></p>
      <p>Thank you for your business!</p>
    </div>
    <div class="footer">
      <p>Powered by <a href="https://bilko.io">Bilko</a></p>
    </div>
  </div>
  <!-- Tracking pixel -->
  <img src="{{ trackingPixelUrl }}" width="1" height="1" />
</body>
</html>
```

**Attachment:** Invoice PDF (generated via PDF service)

#### 2. User Invitation Email

**Template variables:**
- `{{ organizationName }}`
- `{{ inviterName }}`
- `{{ inviteLink }}`
- `{{ role }}`

**Subject:** `{{ inviterName }} invited you to {{ organizationName }} on Bilko`

#### 3. Password Reset Email

**Template variables:**
- `{{ resetLink }}`
- `{{ expiresIn }}` (e.g., "15 minutes")

**Subject:** `Reset your Bilko password`

### Email Tracking

**Purpose:** Track when customer views invoice email (for `viewedAt` timestamp).

**How it works:**
1. Embed 1x1 transparent pixel in email HTML
2. Pixel URL: `https://api.bilko.io/track/email/{{ invoiceId }}`
3. When customer opens email, browser loads pixel
4. Backend endpoint logs view:

```typescript
app.get('/track/email/:invoiceId', async (req, res) => {
  const { invoiceId } = req.params

  await prisma.invoice.update({
    where: { id: invoiceId },
    data: {
      status: 'viewed',
      viewedAt: new Date()
    }
  })

  // Return 1x1 transparent GIF
  const pixel = Buffer.from(
    'R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7',
    'base64'
  )
  res.writeHead(200, {
    'Content-Type': 'image/gif',
    'Content-Length': pixel.length
  })
  res.end(pixel)
})
```

### Rate Limits

**SendGrid free tier:**
- 100 emails/day
- 40,000 emails first 30 days
- After 30 days: $0.00025/email

**Recommendation for MVP:** Free tier sufficient for testing. Upgrade to paid plan at launch.

---

## 2. Cloudflare R2 (File Storage)

### Purpose

Store invoice PDFs and expense receipts.

**Why R2 over S3:**
- S3-compatible API (easy migration)
- Zero egress fees (S3 charges $0.09/GB)
- Cheaper storage: $0.015/GB (vs S3 $0.023/GB)

### Setup

**Account:** Cloudflare (free tier: 10GB storage)

**Installation:**
```bash
npm install @aws-sdk/client-s3
npm install @aws-sdk/s3-request-presigner
```

**Environment Variables:**
```bash
R2_ACCOUNT_ID=your-account-id
R2_ACCESS_KEY_ID=your-access-key
R2_SECRET_ACCESS_KEY=your-secret-key
R2_BUCKET_NAME=bilko-files
R2_PUBLIC_URL=https://r2.bilko.io
```

### Configuration

```typescript
import { S3Client, PutObjectCommand, GetObjectCommand } from '@aws-sdk/client-s3'
import { getSignedUrl } from '@aws-sdk/s3-request-presigner'

const s3 = new S3Client({
  region: 'auto',
  endpoint: `https://${process.env.R2_ACCOUNT_ID}.r2.cloudflarestorage.com`,
  credentials: {
    accessKeyId: process.env.R2_ACCESS_KEY_ID!,
    secretAccessKey: process.env.R2_SECRET_ACCESS_KEY!
  }
})

interface UploadFileOptions {
  key: string             // File path (e.g., "invoices/INV-2026-001.pdf")
  body: Buffer | Uint8Array
  contentType: string     // MIME type
  metadata?: Record<string, string>
}

async function uploadFile(options: UploadFileOptions): Promise<string> {
  const command = new PutObjectCommand({
    Bucket: process.env.R2_BUCKET_NAME!,
    Key: options.key,
    Body: options.body,
    ContentType: options.contentType,
    Metadata: options.metadata
  })

  await s3.send(command)

  // Return public URL
  return `${process.env.R2_PUBLIC_URL}/${options.key}`
}

async function getSignedDownloadUrl(key: string, expiresIn: number = 3600): Promise<string> {
  const command = new GetObjectCommand({
    Bucket: process.env.R2_BUCKET_NAME!,
    Key: key
  })

  return getSignedUrl(s3, command, { expiresIn })
}
```

### File Organization

**Bucket structure:**
```
bilko-files/
├── invoices/
│   ├── org-uuid-1/
│   │   ├── INV-2026-001.pdf
│   │   └── INV-2026-002.pdf
│   └── org-uuid-2/
│       └── INV-2026-001.pdf
├── receipts/
│   ├── org-uuid-1/
│   │   ├── EXP-2026-001.jpg
│   │   └── EXP-2026-002.pdf
│   └── org-uuid-2/
└── exports/
    └── org-uuid-1/
        └── report-2026-02-20.xlsx
```

**Key format:** `{category}/{organizationId}/{filename}`

### File Upload Workflow

**Invoice PDF:**
```typescript
async function storeInvoicePDF(invoiceId: string, organizationId: string, pdf: Buffer): Promise<string> {
  const invoice = await prisma.invoice.findUnique({ where: { id: invoiceId } })
  const filename = `${invoice.invoiceNumber}.pdf`
  const key = `invoices/${organizationId}/${filename}`

  const url = await uploadFile({
    key,
    body: pdf,
    contentType: 'application/pdf',
    metadata: {
      invoiceId,
      organizationId,
      uploadedAt: new Date().toISOString()
    }
  })

  await prisma.invoice.update({
    where: { id: invoiceId },
    data: { pdfUrl: url }
  })

  return url
}
```

**Expense Receipt:**
```typescript
async function storeExpenseReceipt(expenseId: string, organizationId: string, file: Express.Multer.File): Promise<string> {
  const expense = await prisma.expense.findUnique({ where: { id: expenseId } })
  const ext = file.mimetype.split('/')[1]  // 'pdf', 'jpeg', 'png'
  const filename = `${expense.expenseNumber}.${ext}`
  const key = `receipts/${organizationId}/${filename}`

  const url = await uploadFile({
    key,
    body: file.buffer,
    contentType: file.mimetype,
    metadata: {
      expenseId,
      organizationId,
      uploadedAt: new Date().toISOString()
    }
  })

  await prisma.expense.update({
    where: { id: expenseId },
    data: { receiptUrl: url }
  })

  return url
}
```

### Security

**1. Signed URLs for private files:**
- Invoice PDFs: public (anyone with URL can view)
- Expense receipts: private (require signed URL)
- Signed URLs expire in 1 hour

**2. Virus scanning:**
- Use ClamAV to scan uploaded files
- Reject if virus detected

```bash
npm install clamscan
```

```typescript
import NodeClam from 'clamscan'

const clam = await new NodeClam().init({
  clamdscan: {
    host: 'localhost',
    port: 3310
  }
})

async function scanFile(filePath: string): Promise<boolean> {
  const { isInfected } = await clam.scanFile(filePath)
  return !isInfected
}
```

---

## 3. Exchange Rate APIs

### Purpose

Fetch daily exchange rates for multi-currency support.

### Exchange Rate Fetch & Fallback Flow

```mermaid
flowchart TD
    CRON[Cron Job\nDaily at 00:00 UTC]
    CRON --> ECB[Fetch from ECB API\nexchangerate.host/latest?base=EUR]

    ECB --> ECB_OK{Success?}
    ECB_OK -->|Yes| STORE[Upsert ExchangeRate records\nsource='ECB']
    ECB_OK -->|No| FIXER[Fallback: Fixer.io\napi.fixer.io/latest]

    FIXER --> FIX_OK{Success?}
    FIX_OK -->|Yes| STORE2[Upsert ExchangeRate records\nsource='fixer.io']
    FIX_OK -->|No| PREV[Use yesterday's rates\nWarn in logs]

    STORE & STORE2 & PREV --> AVAIL[Rates available in DB\nfor transaction date locking]

    subgraph LOOKUP [Rate Lookup at Transaction Time]
        L1[getExchangeRate\nbaseCurrency, targetCurrency, date]
        L2{Same currency?}
        L3[Return rate = 1.0]
        L4[Find exact date in DB]
        L5{Found?}
        L6[Return rate]
        L7[Find nearest available date\norderBy effectiveDate DESC]
        L8[Warn in logs\nReturn nearest rate]

        L1 --> L2
        L2 -->|Yes| L3
        L2 -->|No| L4
        L4 --> L5
        L5 -->|Yes| L6
        L5 -->|No| L7
        L7 --> L8
    end

    AVAIL --> LOOKUP
    style PREV fill:#fb923c,color:#000
    style L8 fill:#fb923c,color:#000
```

### Primary: European Central Bank (ECB)

**Endpoint:** `https://api.exchangerate.host/latest`

**Free:** Yes (unlimited)

**Example:**
```typescript
async function fetchECBRates(baseCurrency: string = 'EUR'): Promise<Record<string, number>> {
  const url = `https://api.exchangerate.host/latest?base=${baseCurrency}`

  const response = await fetch(url)
  const data = await response.json()

  if (!data.success) {
    throw new Error('ECB API error')
  }

  return data.rates  // { RSD: 117.50, BAM: 1.95, HRK: 7.53, USD: 1.07 }
}
```

### Fallback: Fixer.io

**Endpoint:** `https://api.fixer.io/latest`

**Free tier:** 100 requests/month

**Environment Variables:**
```bash
FIXER_API_KEY=your-api-key
```

**Example:**
```typescript
async function fetchFixerRates(baseCurrency: string = 'EUR'): Promise<Record<string, number>> {
  const url = `https://api.fixer.io/latest?base=${baseCurrency}&access_key=${process.env.FIXER_API_KEY}`

  const response = await fetch(url)
  const data = await response.json()

  if (!data.success) {
    throw new Error('Fixer.io API error')
  }

  return data.rates
}
```

### Exchange Rate Service

```typescript
async function updateExchangeRates(): Promise<void> {
  try {
    // Try ECB first
    const rates = await fetchECBRates('EUR')

    // Store in database
    for (const [targetCurrency, rate] of Object.entries(rates)) {
      await prisma.exchangeRate.upsert({
        where: {
          baseCurrency_targetCurrency_effectiveDate: {
            baseCurrency: 'EUR',
            targetCurrency,
            effectiveDate: new Date()
          }
        },
        update: {
          rate,
          source: 'ECB',
          lastUpdated: new Date()
        },
        create: {
          baseCurrency: 'EUR',
          targetCurrency,
          rate,
          effectiveDate: new Date(),
          source: 'ECB'
        }
      })
    }

    logger.info('Exchange rates updated', { source: 'ECB', count: Object.keys(rates).length })
  } catch (error) {
    // Fallback to Fixer.io
    try {
      const rates = await fetchFixerRates('EUR')

      for (const [targetCurrency, rate] of Object.entries(rates)) {
        await prisma.exchangeRate.upsert({
          where: {
            baseCurrency_targetCurrency_effectiveDate: {
              baseCurrency: 'EUR',
              targetCurrency,
              effectiveDate: new Date()
            }
          },
          update: { rate, source: 'fixer.io', lastUpdated: new Date() },
          create: {
            baseCurrency: 'EUR',
            targetCurrency,
            rate,
            effectiveDate: new Date(),
            source: 'fixer.io'
          }
        })
      }

      logger.info('Exchange rates updated', { source: 'fixer.io', count: Object.keys(rates).length })
    } catch (fallbackError) {
      logger.error('Failed to update exchange rates', { error: fallbackError })
      // Use yesterday's rates (better than nothing)
    }
  }
}

// Schedule: Daily at 00:00 UTC
cron.schedule('0 0 * * *', updateExchangeRates)
```

### Get Exchange Rate

```typescript
async function getExchangeRate(
  baseCurrency: string,
  targetCurrency: string,
  date: Date = new Date()
): Promise<number> {
  // If same currency, rate = 1.0
  if (baseCurrency === targetCurrency) {
    return 1.0
  }

  // Find rate for exact date
  let rate = await prisma.exchangeRate.findUnique({
    where: {
      baseCurrency_targetCurrency_effectiveDate: {
        baseCurrency,
        targetCurrency,
        effectiveDate: date
      }
    }
  })

  // If not found, use nearest available rate
  if (!rate) {
    rate = await prisma.exchangeRate.findFirst({
      where: { baseCurrency, targetCurrency },
      orderBy: { effectiveDate: 'desc' }
    })
  }

  if (!rate) {
    throw new Error(`No exchange rate found for ${baseCurrency} → ${targetCurrency}`)
  }

  return parseFloat(rate.rate.toString())
}
```

---

## 4. PDF Generation

### Purpose

Generate invoice PDFs with organization branding.

### Option 1: Puppeteer (Server-Side Rendering)

**Installation:**
```bash
npm install puppeteer
```

**Implementation:**
```typescript
import puppeteer from 'puppeteer'

async function generateInvoicePDF(invoiceId: string): Promise<Buffer> {
  const invoice = await prisma.invoice.findUnique({
    where: { id: invoiceId },
    include: {
      customer: true,
      organization: true,
      items: true
    }
  })

  // Render HTML template
  const html = renderInvoiceHTML(invoice)

  // Launch headless browser
  const browser = await puppeteer.launch({
    headless: true,
    args: ['--no-sandbox', '--disable-setuid-sandbox']
  })

  const page = await browser.newPage()
  await page.setContent(html, { waitUntil: 'networkidle0' })

  // Generate PDF
  const pdf = await page.pdf({
    format: 'A4',
    printBackground: true,
    margin: { top: '1cm', right: '1cm', bottom: '1cm', left: '1cm' }
  })

  await browser.close()

  return pdf
}
```

### Option 2: @react-pdf/renderer (React Components)

**Installation:**
```bash
npm install @react-pdf/renderer
```

**Implementation:**
```typescript
import { Document, Page, Text, View, StyleSheet, pdf } from '@react-pdf/renderer'

const styles = StyleSheet.create({
  page: { padding: 30 },
  header: { fontSize: 24, marginBottom: 20 },
  table: { display: 'table', width: '100%' },
  row: { flexDirection: 'row', borderBottomWidth: 1, borderColor: '#ddd' },
  cell: { padding: 10 }
})

function InvoicePDF({ invoice }) {
  return (
    <Document>
      <Page style={styles.page}>
        <View style={styles.header}>
          <Text>{invoice.organization.name}</Text>
        </View>
        <Text>Invoice {invoice.invoiceNumber}</Text>
        <View style={styles.table}>
          {invoice.items.map((item) => (
            <View style={styles.row} key={item.id}>
              <Text style={styles.cell}>{item.description}</Text>
              <Text style={styles.cell}>{item.quantity}</Text>
              <Text style={styles.cell}>{item.unitPrice}</Text>
              <Text style={styles.cell}>{item.lineTotal}</Text>
            </View>
          ))}
        </View>
      </Page>
    </Document>
  )
}

async function generateInvoicePDF(invoiceId: string): Promise<Buffer> {
  const invoice = await fetchInvoiceData(invoiceId)
  const doc = <InvoicePDF invoice={invoice} />
  const pdfBlob = await pdf(doc).toBlob()
  return Buffer.from(await pdfBlob.arrayBuffer())
}
```

**Recommendation:** Puppeteer for MVP (more flexible HTML/CSS), React PDF for v2 (better TypeScript support).

---

## 5. Error Handling & Fallbacks

### Circuit Breaker & Retry Pattern

```mermaid
stateDiagram-v2
    [*] --> closed : Initial state

    closed --> open : failures >= threshold (5)\nService marked as unavailable

    open --> half_open : timeout elapsed (60s)\nAttempt single test call

    half_open --> closed : Test call succeeded\nReset failure count

    half_open --> open : Test call failed\nReset timeout

    note right of closed
        Normal operation
        All calls pass through
        Failures counted
    end note

    note right of open
        All calls REJECTED immediately
        Error: "Circuit breaker is open"
        No calls to external service
    end note

    note right of half_open
        Single probe call allowed
        Determines if service recovered
    end note
```

### Retry Strategy

**For transient errors (network timeouts, rate limits):**

```typescript
async function withRetry<T>(
  fn: () => Promise<T>,
  maxRetries: number = 3,
  delay: number = 1000
): Promise<T> {
  for (let i = 0; i < maxRetries; i++) {
    try {
      return await fn()
    } catch (error) {
      if (i === maxRetries - 1) throw error

      logger.warn(`Retry ${i + 1}/${maxRetries}`, { error })
      await new Promise((resolve) => setTimeout(resolve, delay * (i + 1)))
    }
  }

  throw new Error('Retry limit exceeded')
}

// Usage
const rates = await withRetry(() => fetchECBRates('EUR'))
```

### Circuit Breaker

**For external services that frequently fail:**

```typescript
class CircuitBreaker {
  private failures = 0
  private threshold = 5
  private timeout = 60000  // 1 minute
  private state: 'closed' | 'open' | 'half-open' = 'closed'
  private nextAttempt = 0

  async execute<T>(fn: () => Promise<T>): Promise<T> {
    if (this.state === 'open') {
      if (Date.now() < this.nextAttempt) {
        throw new Error('Circuit breaker is open')
      }
      this.state = 'half-open'
    }

    try {
      const result = await fn()
      this.onSuccess()
      return result
    } catch (error) {
      this.onFailure()
      throw error
    }
  }

  private onSuccess() {
    this.failures = 0
    this.state = 'closed'
  }

  private onFailure() {
    this.failures++
    if (this.failures >= this.threshold) {
      this.state = 'open'
      this.nextAttempt = Date.now() + this.timeout
      logger.warn('Circuit breaker opened', { failures: this.failures })
    }
  }
}

const sendGridBreaker = new CircuitBreaker()

async function sendEmailWithBreaker(options: SendEmailOptions) {
  return sendGridBreaker.execute(() => sendEmail(options))
}
```

### Graceful Degradation

**If external service fails, degrade gracefully:**

**Example: Invoice email delivery**
```typescript
async function sendInvoiceEmail(invoiceId: string) {
  try {
    await sendEmail({ /* ... */ })
    await prisma.invoice.update({
      where: { id: invoiceId },
      data: { sentAt: new Date(), status: 'sent' }
    })
  } catch (error) {
    logger.error('Failed to send invoice email', { invoiceId, error })

    // Fallback: Mark invoice as sent but flag for manual email
    await prisma.invoice.update({
      where: { id: invoiceId },
      data: {
        status: 'draft',  // Keep in draft
        notes: `Email delivery failed: ${error.message}. Please send manually.`
      }
    })

    // Alert admin
    await sendSlackAlert('Invoice email delivery failed', { invoiceId })
  }
}
```

---

## Environment Variables Summary

```bash
# SendGrid
SENDGRID_API_KEY=SG.xxxxx
SENDGRID_FROM_EMAIL=noreply@bilko.io
SENDGRID_FROM_NAME=Bilko

# Cloudflare R2
R2_ACCOUNT_ID=your-account-id
R2_ACCESS_KEY_ID=your-access-key
R2_SECRET_ACCESS_KEY=your-secret-key
R2_BUCKET_NAME=bilko-files
R2_PUBLIC_URL=https://r2.bilko.io

# Fixer.io (fallback)
FIXER_API_KEY=your-api-key

# Feature Flags
ENABLE_EMAIL_TRACKING=true
ENABLE_VIRUS_SCANNING=false  # For MVP
```

---

**End of Services Documentation**

# API Coverage Report

# API Coverage Report
**Date:** 2026-02-20
**Purpose:** Map every frontend page to required API endpoints. Verify 100% coverage.
**Status:** Backend NOT implemented (specification only)

---

## Coverage Matrix

### Dashboard Page (/)

| UI Element | Data Required | API Endpoint | Status |
|-----------|--------------|-------------|--------|
| Cash Balance metric | Total bank account balances in base currency | GET /api/v1/reports/dashboard | COVERED |
| Revenue MTD metric | Month-to-date revenue | GET /api/v1/reports/dashboard | COVERED |
| Unpaid Invoices metric | Total unpaid invoices | GET /api/v1/reports/dashboard | COVERED |
| Expenses MTD metric | Month-to-date expenses | GET /api/v1/reports/dashboard | COVERED |
| Profit MTD metric | Month-to-date profit | GET /api/v1/reports/dashboard | COVERED |
| Cash Flow Change | Percentage change from last month | GET /api/v1/reports/dashboard | COVERED |
| P&L Bar Chart | 6-month monthly P&L data | GET /api/v1/reports/dashboard | COVERED |
| Receivables Aging Chart | Receivables breakdown by age (current, 30d, 60d, 90d+) | GET /api/v1/reports/dashboard | COVERED |
| Expenses by Category Chart | Expenses grouped by category | GET /api/v1/reports/dashboard | COVERED |
| Recent Transactions table | Last 5 transactions | GET /api/v1/transactions?perPage=5&sort=transactionDate&order=desc | COVERED |

---

### Invoices List Page (/invoices)

| UI Element | Data Required | API Endpoint | Status |
|-----------|--------------|-------------|--------|
| Invoice list (all filters) | Paginated invoices with filter/sort/search | GET /api/v1/invoices?status=X&search=Y&fromDate=Z&toDate=W&page=P&perPage=20&sort=field&order=desc | COVERED |
| Status filter options | Invoice list filtered by status | GET /api/v1/invoices?status={draft\|sent\|viewed\|paid\|overdue\|cancelled} | COVERED |
| Search (customer/number) | Invoice list filtered by search query | GET /api/v1/invoices?search={query} | COVERED |
| Date range filter | Invoice list filtered by date range | GET /api/v1/invoices?fromDate=YYYY-MM-DD&toDate=YYYY-MM-DD | COVERED |
| Summary totals by status | Client-side calculation from filtered list | N/A (client-side) | COVERED |
| Edit invoice action | Get invoice details for editing | GET /api/v1/invoices/:id | COVERED |
| Delete invoice action | Delete invoice | DELETE /api/v1/invoices/:id | MISSING |
| Send invoice action | Send invoice via email | POST /api/v1/invoices/:id/send | COVERED |
| Download PDF action | Get invoice PDF | GET /api/v1/invoices/:id/pdf | COVERED |

**MISSING ENDPOINT:**
- **DELETE /api/v1/invoices/:id** — Delete invoice (only draft invoices should be deletable)
  - Method: DELETE
  - Auth: Bearer token
  - Roles: owner, admin, accountant
  - Rate limit: 10 req/min
  - Response: 204 No Content
  - Errors:
    - 400 — Invoice is not in draft status
    - 404 — Invoice not found

---

### Invoice Creation Wizard (/invoices/new)

| Step | UI Element | Data Required | API Endpoint | Status |
|------|-----------|--------------|-------------|--------|
| 1 | Customer dropdown | List of customers | GET /api/v1/contacts?type=customer | COVERED |
| 1 | Add customer dialog | Create new customer | POST /api/v1/contacts | COVERED |
| 2 | Invoice number | Auto-generated invoice number | Client-side generation (format: INV-YYYY-NNN) | COVERED |
| 2 | Currency options | List of supported currencies | GET /api/v1/currencies | COVERED |
| 3 | Line items | Form input (no API needed) | N/A | COVERED |
| 3 | VAT rate options | Tax rate configuration | GET /api/v1/settings/tax-rates | COVERED |
| 4 | Notes/Terms | Form input (no API needed) | N/A | COVERED |
| 5 | Preview | Client-side rendering of form data | N/A | COVERED |
| 6 | Save as Draft | Create invoice with status=draft | POST /api/v1/invoices | COVERED |
| 6 | Send Invoice | Create + send invoice | POST /api/v1/invoices (then) POST /api/v1/invoices/:id/send | COVERED |
| 6 | Download PDF | Generate PDF | GET /api/v1/invoices/:id/pdf | COVERED |

---

### Expenses List Page (/expenses)

| UI Element | Data Required | API Endpoint | Status |
|-----------|--------------|-------------|--------|
| Expense list | Paginated expenses with filters | GET /api/v1/expenses?period=X&category=Y&search=Z&page=P&perPage=20 | COVERED |
| Period filter | Expenses filtered by date range | GET /api/v1/expenses?fromDate=YYYY-MM-DD&toDate=YYYY-MM-DD | COVERED |
| Category filter | Expenses filtered by category | GET /api/v1/expenses?category={category} | COVERED |
| Search (description/vendor) | Expenses filtered by search query | GET /api/v1/expenses (client-side search on fetched data) | COVERED |
| Summary stats | Client-side calculation from filtered list | N/A (client-side) | COVERED |
| Create expense | Create new expense | POST /api/v1/expenses | COVERED |
| Upload receipt | Upload receipt file | POST /api/v1/expenses (multipart with receiptFile) | COVERED |
| Edit expense | Update expense (pending only) | PUT /api/v1/expenses/:id | COVERED |
| Approve expense | Approve expense | PATCH /api/v1/expenses/:id/approve | COVERED |
| Delete expense | Delete expense (pending only) | DELETE /api/v1/expenses/:id | COVERED |
| Download receipt | Get receipt file | GET /api/v1/expenses/:id/receipt | MISSING |

**MISSING ENDPOINT:**
- **GET /api/v1/expenses/:id/receipt** — Download expense receipt
  - Method: GET
  - Auth: Bearer token
  - Roles: All
  - Rate limit: 100 req/min
  - Response: 200 OK
    - Content-Type: image/jpeg, image/png, or application/pdf
    - Content-Disposition: attachment; filename="receipt-{expenseNumber}.{ext}"
  - Errors:
    - 404 — Expense or receipt not found

---

### Purchases Page (/purchases)

| UI Element | Data Required | API Endpoint | Status |
|-----------|--------------|-------------|--------|
| (Same as /expenses) | Same data as expenses page | Same as /expenses | COVERED |

**Note:** This is an alias route to the expenses page. No additional API endpoints needed.

---

### Banking Page (/banking)

#### Accounts Tab

| UI Element | Data Required | API Endpoint | Status |
|-----------|--------------|-------------|--------|
| Bank account list | List of bank accounts | GET /api/v1/bank-accounts | COVERED |
| Add bank account | Create new bank account | POST /api/v1/bank-accounts | COVERED |
| Account balance | Current balance per account | GET /api/v1/bank-accounts (included in response) | COVERED |

#### Reconcile Tab

| UI Element | Data Required | API Endpoint | Status |
|-----------|--------------|-------------|--------|
| Account selector | List of bank accounts | GET /api/v1/bank-accounts | COVERED |
| Unreconciled transactions | Unreconciled bank transactions for selected account | GET /api/v1/bank-accounts/:id/transactions?reconciled=false | COVERED |
| Match confidence | Client-side calculation based on amount/date/description | N/A (client-side) | COVERED |
| Approve match | Mark transaction as reconciled | POST /api/v1/bank-accounts/:id/reconcile | COVERED |
| Link to invoice/expense | Link bank transaction to existing record | POST /api/v1/bank-accounts/:id/reconcile (with transactionId) | COVERED |
| Create new transaction | Create GL transaction from unmatched bank transaction | POST /api/v1/transactions | COVERED |

#### Transactions Tab

| UI Element | Data Required | API Endpoint | Status |
|-----------|--------------|-------------|--------|
| All bank transactions | All bank transactions (paginated) | GET /api/v1/bank-accounts/:id/transactions | COVERED |
| Reconciliation status filter | Bank transactions filtered by reconciled status | GET /api/v1/bank-accounts/:id/transactions?reconciled={true\|false} | COVERED |
| Date range filter | Bank transactions filtered by date | GET /api/v1/bank-accounts/:id/transactions?fromDate=X&toDate=Y | COVERED |

#### Import Transactions

| UI Element | Data Required | API Endpoint | Status |
|-----------|--------------|-------------|--------|
| Import CSV | Upload bank statement CSV | POST /api/v1/bank-accounts/:id/import | COVERED |

---

### Reports Hub Page (/reports)

| UI Element | Data Required | API Endpoint | Status |
|-----------|--------------|-------------|--------|
| P&L Report preview | Profit & Loss data for current month | GET /api/v1/reports/profit-loss?from=YYYY-MM-01&to=YYYY-MM-DD | COVERED |
| Balance Sheet preview | Balance sheet data (coming soon) | GET /api/v1/reports/balance-sheet?date=YYYY-MM-DD | COVERED |
| Cash Flow preview | Cash flow data (coming soon) | GET /api/v1/reports/cash-flow?from=X&to=Y | COVERED |
| VAT Report preview | VAT report data (live at /reports/vat) | GET /api/v1/reports/vat?from=X&to=Y | COVERED |
| Trial Balance preview | Trial balance data (coming soon) | GET /api/v1/reports/trial-balance?date=YYYY-MM-DD | COVERED |
| General Ledger preview | Transaction list (coming soon) | GET /api/v1/transactions | COVERED |

---

### VAT Report Page (/reports/vat)

| Step | UI Element | Data Required | API Endpoint | Status |
|------|-----------|--------------|-------------|--------|
| 1 | Reconciliation status check | Count of unreconciled bank transactions | GET /api/v1/bank-accounts (aggregate unreconciled count client-side) | PARTIAL |
| 2 | VAT transaction table | All invoices and expenses with VAT for period | GET /api/v1/reports/vat?from=X&to=Y | COVERED |
| 2 | Summary boxes (collected/paid/due) | Calculated from VAT transaction data | GET /api/v1/reports/vat?from=X&to=Y | COVERED |
| 3 | VAT return boxes | Formatted VAT return data | GET /api/v1/reports/vat?from=X&to=Y | COVERED |
| 3 | Export PDF | Generate PDF report | GET /api/v1/reports/vat/export/pdf?from=X&to=Y | MISSING |
| 3 | Export XML | Generate XML for e-filing | GET /api/v1/reports/vat/export/xml?from=X&to=Y | MISSING |
| 3 | Submit return | Submit VAT return (Phase 2) | POST /api/v1/reports/vat/submit | MISSING |

**MISSING ENDPOINTS:**
- **GET /api/v1/reports/vat/export/pdf** — Export VAT report as PDF
  - Method: GET
  - Auth: Bearer token
  - Roles: All
  - Query: from (ISO date), to (ISO date)
  - Rate limit: 50 req/min
  - Response: 200 OK
    - Content-Type: application/pdf
    - Content-Disposition: attachment; filename="vat-report-{from}-{to}.pdf"
  - Errors: 422 — Invalid date range

- **GET /api/v1/reports/vat/export/xml** — Export VAT report as XML for e-filing
  - Method: GET
  - Auth: Bearer token
  - Roles: All
  - Query: from (ISO date), to (ISO date)
  - Rate limit: 50 req/min
  - Response: 200 OK
    - Content-Type: application/xml
    - Content-Disposition: attachment; filename="vat-return-{from}-{to}.xml"
  - Errors: 422 — Invalid date range

- **POST /api/v1/reports/vat/submit** — Submit VAT return to tax authority (Phase 2)
  - Method: POST
  - Auth: Bearer token
  - Roles: owner, admin, accountant
  - Rate limit: 5 req/min
  - Request:
    ```typescript
    interface SubmitVATRequest {
      period: string           // ISO date range, e.g., "2026-02"
      confirmationEmail: string
    }
    ```
  - Response: 201 Created
    ```typescript
    interface SubmitVATResponse {
      submissionId: string
      submittedAt: string
      status: 'pending' | 'accepted' | 'rejected'
      confirmationNumber: string | null
    }
    ```
  - Errors: 400 — VAT period already submitted

**PARTIAL COVERAGE:**
- Reconciliation status check requires aggregating unreconciled count from GET /api/v1/bank-accounts response. Consider dedicated endpoint:
  - **GET /api/v1/bank-accounts/unreconciled-count**
    - Returns: `{ total: number, byAccount: Array<{accountId: string, count: number}> }`

---

### Settings Page (/settings)

#### Company Section

| UI Element | Data Required | API Endpoint | Status |
|-----------|--------------|-------------|--------|
| Company profile form | Organization data | GET /api/v1/organization | COVERED |
| Save company profile | Update organization | PUT /api/v1/organization | COVERED |

#### Users Section

| UI Element | Data Required | API Endpoint | Status |
|-----------|--------------|-------------|--------|
| User list | List of users in organization | GET /api/v1/users | COVERED |
| Invite user | Send user invite | POST /api/v1/users/invite | COVERED |
| Change user role | Update user role | PUT /api/v1/users/:id/role | COVERED |
| Remove user | Delete user | DELETE /api/v1/users/:id | COVERED |

#### Tax & Compliance Section

| UI Element | Data Required | API Endpoint | Status |
|-----------|--------------|-------------|--------|
| Tax settings form | Tax rate configuration | GET /api/v1/settings/tax-rates | COVERED |
| Save tax settings | Update tax rates | PUT /api/v1/settings/tax-rates | COVERED |
| VAT registration toggle | Update organization (vatNumber field) | PUT /api/v1/organization | COVERED |

#### Integrations Section

| UI Element | Data Required | API Endpoint | Status |
|-----------|--------------|-------------|--------|
| Connected integrations | List of integrations | GET /api/v1/integrations | MISSING |
| Available integrations | List of integrations | GET /api/v1/integrations | MISSING |
| Connect integration | Connect to integration | POST /api/v1/integrations/:id/connect | MISSING |
| Disconnect integration | Disconnect integration | DELETE /api/v1/integrations/:id/disconnect | MISSING |

**MISSING ENDPOINTS (Integrations):**
All integration-related endpoints are missing. These should be Phase 2, but placeholders needed:

- **GET /api/v1/integrations** — List integrations
  - Method: GET
  - Auth: Bearer token
  - Roles: All
  - Response: 200 OK
    ```typescript
    interface IntegrationsResponse {
      connected: Array<{
        id: string
        name: string
        type: string
        status: 'active' | 'inactive' | 'error'
        connectedAt: string
        lastSync: string | null
      }>
      available: Array<{
        id: string
        name: string
        type: string
        description: string
        icon: string
      }>
    }
    ```

- **POST /api/v1/integrations/:id/connect** — Connect integration
  - Method: POST
  - Auth: Bearer token
  - Roles: owner, admin
  - Request body varies by integration type
  - Response: 201 Created

- **DELETE /api/v1/integrations/:id/disconnect** — Disconnect integration
  - Method: DELETE
  - Auth: Bearer token
  - Roles: owner, admin
  - Response: 204 No Content

#### Notifications Section

| UI Element | Data Required | API Endpoint | Status |
|-----------|--------------|-------------|--------|
| Notification preferences | User notification settings | GET /api/v1/settings/notifications | MISSING |
| Save preferences | Update notification settings | PATCH /api/v1/settings/notifications | MISSING |

**MISSING ENDPOINTS (Notifications):**
- **GET /api/v1/settings/notifications** — Get notification preferences
  - Method: GET
  - Auth: Bearer token
  - Roles: All
  - Response: 200 OK
    ```typescript
    interface NotificationSettings {
      email: {
        invoicePaid: boolean
        invoiceOverdue: boolean
        expenseApproved: boolean
        bankAccountSynced: boolean
      }
      inApp: {
        invoiceUpdates: boolean
        expenseUpdates: boolean
        reconciliationMatches: boolean
      }
    }
    ```

- **PATCH /api/v1/settings/notifications** — Update notification preferences
  - Method: PATCH
  - Auth: Bearer token
  - Roles: All
  - Request: Same as GET response (partial updates allowed)
  - Response: 200 OK (updated settings)

#### Security Section

| UI Element | Data Required | API Endpoint | Status |
|-----------|--------------|-------------|--------|
| Enable 2FA | Enable 2FA for user | POST /api/v1/auth/2fa/enable | MISSING |
| Disable 2FA | Disable 2FA for user | DELETE /api/v1/auth/2fa/disable | MISSING |
| Session timeout | User/org settings | GET /api/v1/settings/security | MISSING |
| Save security settings | Update security settings | PATCH /api/v1/settings/security | MISSING |
| View audit log | Audit trail | GET /api/v1/security/audit-log | MISSING |
| Request data export | Export all data | POST /api/v1/security/data-export | MISSING |
| Delete company | Delete organization | DELETE /api/v1/organization | MISSING |

**MISSING ENDPOINTS (Security):**
- **POST /api/v1/auth/2fa/enable** — Enable 2FA
  - Response: QR code + backup codes

- **DELETE /api/v1/auth/2fa/disable** — Disable 2FA
  - Requires current password confirmation

- **GET /api/v1/settings/security** — Get security settings
  - Returns: session timeout, password policy, etc.

- **PATCH /api/v1/settings/security** — Update security settings

- **GET /api/v1/security/audit-log** — Get audit log (paginated)
  - Query: fromDate, toDate, userId, action, tableName
  - Response: Paginated list of LoggedAction records

- **POST /api/v1/security/data-export** — Request GDPR data export
  - Response: Job ID, email sent when ready

- **DELETE /api/v1/organization** — Delete organization (danger zone)
  - Requires password confirmation
  - Cascades to all related data

---

## Forms → API Mapping

| Form | Submit Action | API Endpoint | Request Body | Status |
|------|-------------|-------------|-------------|--------|
| Create Invoice (Step 6) | POST | POST /api/v1/invoices | CreateInvoiceRequest | COVERED |
| Send Invoice (Step 6) | POST | POST /api/v1/invoices/:id/send | SendInvoiceRequest | COVERED |
| Add Customer (Wizard Step 1) | POST | POST /api/v1/contacts | CreateContactRequest | COVERED |
| Create Expense (Dialog) | POST | POST /api/v1/expenses | CreateExpenseRequest (multipart) | COVERED |
| Upload Receipt (Expense Dialog) | POST | POST /api/v1/expenses (multipart receiptFile) | File upload | COVERED |
| Update Company Profile | PUT | PUT /api/v1/organization | UpdateOrganizationRequest | COVERED |
| Update Tax Settings | PUT | PUT /api/v1/settings/tax-rates | UpdateTaxRatesRequest | COVERED |
| Invite User | POST | POST /api/v1/users/invite | InviteUserRequest | COVERED |
| Change User Role | PUT | PUT /api/v1/users/:id/role | ChangeRoleRequest | COVERED |
| Import Bank Statement | POST | POST /api/v1/bank-accounts/:id/import | CSV file upload | COVERED |
| Create Manual Transaction | POST | POST /api/v1/transactions | CreateTransactionRequest | COVERED |
| Update Notification Preferences | PATCH | PATCH /api/v1/settings/notifications | NotificationSettings | MISSING |
| Update Security Settings | PATCH | PATCH /api/v1/settings/security | SecuritySettings | MISSING |
| Connect Integration | POST | POST /api/v1/integrations/:id/connect | Integration-specific | MISSING |
| Submit VAT Return | POST | POST /api/v1/reports/vat/submit | SubmitVATRequest | MISSING |

---

## Coverage Summary

| Page | Endpoints Required | Endpoints Documented | Coverage |
|------|-------------------|---------------------|----------|
| Dashboard | 2 | 2 | 100% |
| Invoices List | 6 | 5 | 83% (missing DELETE) |
| Invoice Wizard | 6 | 6 | 100% |
| Expenses | 7 | 6 | 86% (missing receipt download) |
| Purchases | 7 | 6 | 86% (same as expenses) |
| Banking | 7 | 7 | 100% |
| Reports Hub | 6 | 6 | 100% |
| VAT Report | 7 | 4 | 57% (missing PDF/XML export, submit) |
| Settings - Company | 2 | 2 | 100% |
| Settings - Users | 4 | 4 | 100% |
| Settings - Tax | 2 | 2 | 100% |
| Settings - Integrations | 3 | 0 | 0% (Phase 2) |
| Settings - Notifications | 2 | 0 | 0% (missing) |
| Settings - Security | 6 | 0 | 0% (missing) |
| **TOTAL** | **67** | **50** | **75%** |

---

## Missing Endpoints

### High Priority (Core Features)

1. **DELETE /api/v1/invoices/:id** — Delete invoice (draft only)
   - Reason: Invoice list has delete action

2. **GET /api/v1/expenses/:id/receipt** — Download expense receipt
   - Reason: Expense list shows receipt indicator, needs download link

3. **GET /api/v1/reports/vat/export/pdf** — VAT report PDF export
   - Reason: VAT report has export button (placeholder currently)

4. **GET /api/v1/reports/vat/export/xml** — VAT report XML export (e-filing)
   - Reason: VAT report has export button (placeholder currently)

### Medium Priority (Settings)

5. **GET /api/v1/settings/notifications** — Get notification preferences
   - Reason: Settings page has notification section with checkboxes

6. **PATCH /api/v1/settings/notifications** — Update notification preferences
   - Reason: Settings page has save button for notification preferences

7. **GET /api/v1/settings/security** — Get security settings
   - Reason: Settings page has security section (session timeout, password policy)

8. **PATCH /api/v1/settings/security** — Update security settings
   - Reason: Settings page has save button for security settings

9. **GET /api/v1/security/audit-log** — Audit log
   - Reason: Settings security section has "View Audit Log" button

10. **POST /api/v1/security/data-export** — GDPR data export
    - Reason: Settings security section has "Request Data Export" button

11. **DELETE /api/v1/organization** — Delete company
    - Reason: Settings security section has "Delete Company" button (danger zone)

12. **POST /api/v1/auth/2fa/enable** — Enable 2FA
    - Reason: Settings security section has "Enable 2FA" button

13. **DELETE /api/v1/auth/2fa/disable** — Disable 2FA
    - Reason: Settings security section needs disable option if 2FA enabled

### Low Priority (Phase 2 Features)

14. **GET /api/v1/integrations** — List integrations
    - Reason: Settings integrations section (Phase 2)

15. **POST /api/v1/integrations/:id/connect** — Connect integration
    - Reason: Settings integrations section (Phase 2)

16. **DELETE /api/v1/integrations/:id/disconnect** — Disconnect integration
    - Reason: Settings integrations section (Phase 2)

17. **POST /api/v1/reports/vat/submit** — Submit VAT return
    - Reason: VAT report has submit button (explicitly marked "Coming in Phase 2")

18. **GET /api/v1/bank-accounts/unreconciled-count** — Unreconciled transaction count
    - Reason: VAT report Step 1 checks reconciliation status (currently client-side aggregation, could be dedicated endpoint)

---

## Redundant Endpoints

None identified. All endpoints in API-REFERENCE.md are consumed by at least one frontend page.

---

## Recommendations

### 1. Add Missing Core Endpoints
**Priority:** HIGH
**Scope:** Endpoints 1-4 from Missing Endpoints list

These are referenced directly in existing UI actions. Without them, users will encounter broken functionality:
- Delete invoice button will not work
- Receipt download links will not work
- VAT export buttons will not work

**Implementation Order:**
1. DELETE /api/v1/invoices/:id (simplest, no business logic)
2. GET /api/v1/expenses/:id/receipt (file download)
3. GET /api/v1/reports/vat/export/pdf (report generation + PDF library)
4. GET /api/v1/reports/vat/export/xml (report generation + XML serialization)

### 2. Implement Settings Endpoints
**Priority:** MEDIUM
**Scope:** Endpoints 5-13 from Missing Endpoints list

Settings page is fully implemented with save buttons, but no backend to persist data. Current behavior: console.log() only.

**Recommendation:** Implement all settings endpoints together in one feature branch. They share similar patterns (GET/PATCH pairs, org-scoped data).

### 3. Phase 2 Features as Stubs
**Priority:** LOW
**Scope:** Endpoints 14-18 from Missing Endpoints list

These are explicitly marked as "Phase 2" or "Coming Soon" in the UI. Consider implementing stub endpoints that return:
- 501 Not Implemented
- Or minimal placeholder data

This allows frontend to gracefully handle the "not yet implemented" state without errors.

### 4. Add Endpoint: GET /api/v1/bank-accounts/unreconciled-count
**Priority:** LOW
**Scope:** New endpoint not in API-REFERENCE.md

VAT report Step 1 checks for unreconciled transactions. Currently, frontend must:
1. Fetch GET /api/v1/bank-accounts (all accounts)
2. For each account, fetch GET /api/v1/bank-accounts/:id/transactions?reconciled=false
3. Aggregate counts

**Recommendation:** Add dedicated endpoint to avoid N+1 query pattern.

```typescript
// Proposed endpoint
GET /api/v1/bank-accounts/unreconciled-count

Response:
{
  total: number
  byAccount: Array<{
    accountId: string
    accountName: string
    count: number
  }>
}
```

### 5. Verify TypeScript Interface Consistency
**Priority:** MEDIUM

API-REFERENCE.md defines TypeScript interfaces for request/response bodies. Frontend uses these types in forms and state management.

**Action Items:**
- Create shared types package (`packages/types/`)
- Export all API interfaces from API-REFERENCE.md
- Import in both `apps/api/` (backend validation) and `apps/web/` (frontend forms)
- Use Zod schemas for runtime validation + TypeScript type generation

**Example:**
```typescript
// packages/types/src/invoice.ts
import { z } from 'zod'

export const CreateInvoiceRequestSchema = z.object({
  customerId: z.string().uuid(),
  invoiceDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
  dueDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
  currencyCode: z.enum(['EUR', 'RSD', 'BAM', 'HRK']).optional(),
  items: z.array(z.object({
    description: z.string().min(1),
    quantity: z.number().positive(),
    unitPrice: z.number().nonnegative(),
    taxRate: z.number().nonnegative(),
    accountId: z.string().uuid().optional()
  })),
  notes: z.string().optional(),
  terms: z.string().optional()
})

export type CreateInvoiceRequest = z.infer<typeof CreateInvoiceRequestSchema>
```

Backend uses schema for validation:
```typescript
// apps/api/src/routes/invoices.ts
import { CreateInvoiceRequestSchema } from '@bilko/types'

router.post('/invoices', async (req, res) => {
  const data = CreateInvoiceRequestSchema.parse(req.body) // Throws if invalid
  // ...
})
```

Frontend uses type for forms:
```typescript
// apps/web/app/(dashboard)/invoices/new/page.tsx
import { CreateInvoiceRequest } from '@bilko/types'

const [formData, setFormData] = useState<CreateInvoiceRequest>({...})
```

### 6. Authentication Endpoints Missing from Coverage
**Priority:** HIGH

Frontend has no login/register pages yet, but API-REFERENCE.md defines 5 auth endpoints:
- POST /api/v1/auth/register
- POST /api/v1/auth/login
- POST /api/v1/auth/refresh
- POST /api/v1/auth/logout
- GET /api/v1/auth/me

**Recommendation:** Create auth pages in Phase 2:
- `/login` page
- `/register` page
- `/logout` action (server action)
- Auth middleware to protect all /dashboard routes

### 7. Error Handling Pattern
**Priority:** MEDIUM

API-REFERENCE.md defines error response format:
```typescript
interface ApiError {
  error: string
  code: string
  details?: Record<string, string[]>
}
```

**Recommendation:** Create frontend error handling utility:
```typescript
// lib/api-error.ts
export function handleApiError(error: Response) {
  const apiError: ApiError = await error.json()

  if (apiError.details) {
    // Field-level validation errors
    return Object.entries(apiError.details).map(([field, errors]) => ({
      field,
      message: errors.join(', ')
    }))
  }

  // Generic error
  return { message: apiError.error, code: apiError.code }
}
```

Use in forms:
```typescript
try {
  await fetch('/api/v1/invoices', { method: 'POST', body: JSON.stringify(data) })
} catch (error) {
  const errors = handleApiError(error)
  // Display errors in form
}
```

---

## Implementation Priority Matrix

| Endpoint | Priority | Reason | Est. Effort |
|----------|---------|--------|------------|
| DELETE /api/v1/invoices/:id | HIGH | Invoice list delete button | 2h |
| GET /api/v1/expenses/:id/receipt | HIGH | Receipt download links | 3h |
| GET /api/v1/reports/vat/export/pdf | HIGH | VAT export button | 8h |
| GET /api/v1/reports/vat/export/xml | HIGH | VAT e-filing | 6h |
| GET /api/v1/settings/notifications | MEDIUM | Settings page persistence | 3h |
| PATCH /api/v1/settings/notifications | MEDIUM | Settings page persistence | 2h |
| GET /api/v1/settings/security | MEDIUM | Settings page persistence | 4h |
| PATCH /api/v1/settings/security | MEDIUM | Settings page persistence | 3h |
| GET /api/v1/security/audit-log | MEDIUM | Audit log viewer | 5h |
| POST /api/v1/security/data-export | MEDIUM | GDPR compliance | 8h |
| DELETE /api/v1/organization | MEDIUM | Delete company action | 4h |
| POST /api/v1/auth/2fa/enable | MEDIUM | 2FA setup | 8h |
| DELETE /api/v1/auth/2fa/disable | MEDIUM | 2FA removal | 2h |
| GET /api/v1/integrations | LOW | Phase 2 feature | 6h |
| POST /api/v1/integrations/:id/connect | LOW | Phase 2 feature | 12h |
| DELETE /api/v1/integrations/:id/disconnect | LOW | Phase 2 feature | 3h |
| POST /api/v1/reports/vat/submit | LOW | Phase 2 feature | 16h |
| GET /api/v1/bank-accounts/unreconciled-count | LOW | Performance optimization | 3h |

**Total Estimated Effort:** 98 hours (~12-15 working days for 1 developer)

**Suggested Implementation Phases:**

**Phase 2a (Core Features)** — 19h
- DELETE /api/v1/invoices/:id
- GET /api/v1/expenses/:id/receipt
- GET /api/v1/reports/vat/export/pdf
- GET /api/v1/reports/vat/export/xml

**Phase 2b (Settings Persistence)** — 31h
- All settings endpoints (notifications, security, audit log, data export, org delete, 2FA)

**Phase 2c (Integrations + VAT Submit)** — 37h
- Integrations endpoints (stub or full implementation)
- VAT submit endpoint (requires external API integration)

**Phase 2d (Polish)** — 11h
- Unreconciled count endpoint
- Shared types package
- Error handling utilities
- Auth pages

---

## Conclusion

**Overall Coverage:** 75% (50 out of 67 required endpoints documented)

**API-REFERENCE.md is 75% complete.** The 50 documented endpoints cover all core business logic:
- Invoice CRUD (except delete)
- Expense CRUD (except receipt download)
- Banking & reconciliation
- Reporting (except export formats)
- User management
- Organization settings
- Chart of accounts
- Transactions

**Missing 25%:**
- 4 high-priority endpoints (delete invoice, receipt download, VAT exports)
- 9 medium-priority settings endpoints
- 4 low-priority Phase 2 endpoints

**No redundant endpoints.** Every endpoint in API-REFERENCE.md is consumed by at least one frontend page.

**Recommendation:** Implement Phase 2a (core features, 19h) before beta launch. Settings persistence can follow in Phase 2b after user feedback.

**Next Steps:**
1. Review this coverage report with team
2. Prioritize missing endpoints based on business needs
3. Update API-REFERENCE.md with missing endpoint specs
4. Create implementation tickets in Mission Control
5. Build apps/api/ following API-REFERENCE.md contract

# Bilko Authentication -- Entra External ID (CIAM)

## Overview

Bilko uses **Microsoft Entra External ID (CIAM)** as its sole identity provider. Entra authenticates users; Bilko authorises them. Roles and permissions live exclusively in the Bilko database — no role claims are read from Entra tokens.

**Decision anchor (ADR):** "Entra authenticates, Bilko authorises; single-role v1; multi-org deferred (MC #103089)."

**Stage status:** Live on stage (branch stack WP1–WP4, feat/rbac-wp4-retire-legacy-auth commit 3ac1388). Production cutover pending consolidated PR.

## Tenant Configuration

<table id="bkmrk-fieldvalue-tenant-id"><thead><tr><th>Field</th><th>Value</th></tr></thead><tbody><tr><td>Tenant ID</td><td>`20bb17de-9be5-4143-a7e5-8c1ddae6a064`</td></tr><tr><td>Display name</td><td>Bilko CIAM</td></tr><tr><td>Domain</td><td>`bilkociam.onmicrosoft.com`</td></tr><tr><td>Type</td><td>Entra External ID (CIAM), EU data residency, Norway</td></tr><tr><td>Billing</td><td>MAU — free tier from 2026-06-07</td></tr><tr><td>Authority (MSAL)</td><td>`https://bilkociam.ciamlogin.com/20bb17de-9be5-4143-a7e5-8c1ddae6a064`</td></tr><tr><td>Issuer (exact, from OIDC discovery)</td><td>`https://20bb17de-9be5-4143-a7e5-8c1ddae6a064.ciamlogin.com/20bb17de-9be5-4143-a7e5-8c1ddae6a064/v2.0`</td></tr><tr><td>JWKS URI</td><td>`https://bilkociam.ciamlogin.com/20bb17de-9be5-4143-a7e5-8c1ddae6a064/discovery/v2.0/keys`</td></tr><tr><td>OIDC discovery</td><td>`https://bilkociam.ciamlogin.com/20bb17de-9be5-4143-a7e5-8c1ddae6a064/v2.0/.well-known/openid-configuration`</td></tr></tbody></table>

**Issuer note:** OIDC discovery returns the issuer with the tenant-ID subdomain (`20bb17de-...ciamlogin.com`), NOT the named subdomain (`bilkociam.ciamlogin.com`). The Kotlin backend `ENTRA_EXTERNAL_ID_ISSUER` env var MUST match the discovery value exactly. See evidence: `/tmp/evidence-103076/phase0-config.md`.

## App Registrations

<table id="bkmrk-appclient-idflownote"><thead><tr><th>App</th><th>Client ID</th><th>Flow</th><th>Notes</th></tr></thead><tbody><tr><td>Bilko API (resource)</td><td>`fe39e0f5-513e-40af-93f0-c3ee624df56c`</td><td>Exposes scope</td><td>Scope: `access_as_user`; full scope string: `api://fe39e0f5-513e-40af-93f0-c3ee624df56c/access_as_user`; audience for token validation = client ID; no client secret (resource app)</td></tr><tr><td>Bilko Web SPA</td><td>`c2902239-ea63-41bd-8619-6cf096d7d45a`</td><td>PKCE auth code (SPA)</td><td>Redirects: localhost:3000, stage Cloud Run URL, bilko-demo.alai.no, app.bilko.cloud, app.bilko.io, app.bilko.company (+ /auth/callback variants); no client secret</td></tr><tr><td>Bilko Mobile (native)</td><td>`916bb9f3-658d-4729-b5a0-64b1f157c8c2`</td><td>PKCE auth code (native/public)</td><td>Redirect: `com.alai.bilko://auth`, `msauth.com.alai.bilko://auth`; `isFallbackPublicClient=true`; Expo Go workaround documented in Phase 0 config</td></tr></tbody></table>

Secrets (none exist — all public clients). Non-secret configuration is stored in GCP Secret Manager (`bilko-entra-issuer`, `bilko-entra-audience`, `bilko-entra-jwks-url`) and Bitwarden ("Bilko CIAM Tenant Config").

## Token Claims

- `oid` — object ID, immutable cross-app anchor; **mandatory identity key** (built-in in CIAM, always present)
- `sub` — pairwise pseudonymous per app; NOT used as identity anchor (changes on app re-registration). The backend logs a warning when `sub != oid` (expected in CIAM) and uses `oid` exclusively. Confirmed live: E2E test showed `sub=053nt0lk` vs `oid=3b53a25a`.
- `email` / `preferred_username` — informational only; mutable; NOT used for identity resolution in JWT claims issued by Bilko
- `name`, `family_name`, `given_name` — optional claims

Bilko JWTs issued after exchange use the internal Bilko user UUID as the subject claim — email is not the identity anchor in issued tokens.

## Authentication Flow — Web (MSAL Direct Bearer)

1. Browser opens login page → MSAL browser (`@azure/msal-browser` + `@azure/msal-react`) initiates PKCE auth code flow
2. Redirect to `bilkociam.ciamlogin.com` → user authenticates (email/password or social) → Entra issues auth code
3. MSAL exchanges code for tokens (PKCE, in memory — NOT localStorage)
4. MSAL acquires access token for scope `api://fe39e0f5.../access_as_user`
5. Web sends `Authorization: Bearer <entra-access-token>` to Kotlin API
6. Kotlin `EntraExternalIdService.verifyIdToken()` validates: RS256 signature via live JWKS, issuer exact match, audience = `fe39e0f5...`, `oid` claim present, JWKS URL domain-pinned to `ciamlogin.com`/`microsoftonline.com`
7. JIT provisioning or email-match link (see JIT section below) → Bilko session returned
8. Session cookie (`SameSite=Lax`, httpOnly) established; subsequent requests use Bilko refresh token
9. Sign-out calls Entra logout endpoint to invalidate Entra session + clears local cookie

## Authentication Flow — Mobile (Token Exchange)

1. Expo native app initiates PKCE via `expo-auth-session` / `useEntraAuthRequest`
2. Redirect to Entra → auth code returned to `com.alai.bilko://auth`
3. MSAL exchanges code; **id\_token** (not access\_token) sent to `POST /auth/entra/session`
4. Kotlin backend verifies id\_token, runs JIT provisioning or link, returns `{ accessToken, refreshToken }`
5. Tokens stored in SecureStore; TTL aligns with Bilko 7-day refresh token window

## JIT Provisioning + Identity Linking

Implemented in `AuthService.createSessionFromEntraIdToken()` (SERIALIZABLE transaction — martin-kleppmann race-prevention mandate):

1. Lookup `entra_external_identities` by `issuer + oid`. If found: return session.
2. If not found: email-match lookup in `users` (case-normalised, lowercase both sides). If unique match and `email_verified`: insert `entra_external_identities` row, log audit event `entra_jit_link`, return session.
3. If no match: call `UserProvisioningService.provisionNewUserForEntra()` — creates a new org + user with role `viewer` + inserts `entra_external_identities` row. New user must be promoted by an admin.

**Design dissent on record (martin-kleppmann + bruce-momjian):** email-match JIT is risky if email is mutable or duplicate. Pre-provision by OID (via admin invite or MS Graph export script) is the safer path. JIT email-match is constrained with a serializable transaction as a partial mitigation. The pre-provision script path (D8 in MC #103075) is the recommended path for production migration.

## JWKS Cache + Key Rotation

`EntraExternalIdService` maintains a time-bound JWKS key cache:

- TTL: 12 hours per key (stored as `Pair<RSAPublicKey, Instant>`; evicted on read if age &gt; 12h)
- On `kid` miss: force re-fetch regardless of other cached keys
- JWKS URL domain-pinned: must match `^https://([a-z0-9-]+\.)*ciamlogin\.com/` or `^https://login\.microsoftonline\.com/`
- Startup fail-closed: if `ENTRA_EXTERNAL_ID_ISSUER` set but any config absent or URL fails domain assertion → `IllegalStateException` at Ktor module init (not lazy 503)
- JWKS verification: live E2E test confirmed 6 RSA keys, all `kty=RSA`, TLS valid 2026-11-22

## Refresh Token Revocation (Known Limitation)

Bilko refresh tokens are 7-day HMAC validated locally. A disabled Entra account remains valid in Bilko for up to 7 days. **Open CEO/Securion decision (OC#4 from MC #103075):**

- Option A (not yet implemented): revalidate Entra account status on every refresh (~50ms latency)
- Option B (current default): 7-day window; immediate revocation requires an admin to also disable the user in the Bilko DB. Documented as a risk-acceptance decision in `AuthService.kt` (code comment references MC #103075).

## Legacy Email/Password — RETIRED (410 Gone)

As of branch `feat/rbac-wp4-retire-legacy-auth` (commit 3ac1388), the following endpoints return `HTTP 410 Gone` with body `{"code":"ENDPOINT_RETIRED"}`:

- `POST /auth/register`
- `POST /auth/login`
- `POST /auth/forgot-password`
- `GET /auth/reset-password`
- `POST /auth/reset-password`

Kept active: `POST /auth/entra/session`, `POST /auth/refresh`, `POST /auth/mobile/refresh`, `POST /auth/2fa/challenge`.

Web login page: email/password form removed; Entra primary CTA only. Self-serve register page removed; shows "contact your admin" message. Forgot/reset password redirects to Entra SSPR portal.

Break-glass (first-admin bootstrap): run `./gradlew :apps:api:bootStrapAdmin` with `BOOTSTRAP_ADMIN_EMAIL` + `BOOTSTRAP_ADMIN_PASSWORD` env vars. Calls `AuthService.register()` directly; no HTTP endpoint exposed.

## Phase 0–4 Deployment Facts

<table id="bkmrk-phase-%2F-wpscopebranc"><thead><tr><th>Phase / WP</th><th>Scope</th><th>Branch</th><th>Status</th></tr></thead><tbody><tr><td>Phase 0 (MC #103076)</td><td>CIAM tenant provisioning, 3 app registrations, JWKS verification</td><td>FlowForge standalone</td><td>DONE — stage live</td></tr><tr><td>WP1 (MC #103141)</td><td>RBAC permissions catalog V67, PermissionService, BilkoPrincipal, requirePermission, 204 matrix tests</td><td>feat/rbac-wp1-permissions-catalog</td><td>DONE — Proveo PARTIAL (integration test fix applied post-verification)</td></tr><tr><td>WP2 (MC #103142)</td><td>JIT provisioning V68, UserProvisioningService, admin/invite API, role-assign endpoint</td><td>feat/rbac-wp2-user-provisioning</td><td>DONE — Proveo PASS</td></tr><tr><td>WP3 (MC #103143)</td><td>Web: Entra primary CTA, register retired, forgot/reset SSPR, RBAC admin UI</td><td>feat/rbac-wp3-web-entra-ui</td><td>DONE — Proveo PASS</td></tr><tr><td>WP4 (MC #103144)</td><td>Retire legacy endpoints (410), web login Entra-only, break-glass documented</td><td>feat/rbac-wp4-retire-legacy-auth</td><td>DONE — Proveo PASS</td></tr><tr><td>WP5 (MC #103145)</td><td>E2E: live CIAM token, OID anchor, JIT provision, RBAC enforcement, invalid token rejection</td><td>feat/rbac-wp3-web-entra-ui</td><td>DONE — PASS (browser MSAL flow deferred to Proveo pre-prod)</td></tr></tbody></table>

Evidence bundles: `/tmp/evidence-103141` through `/tmp/evidence-103145`, `/tmp/evidence-103076/phase0-config.md`.

# Bilko RBAC -- Users / Roles / Permissions

## Overview

Bilko uses a **flat RBAC model**: users have one role per organisation; roles map to a permission catalog via a DB seed table. Permission resolution is live from the database on every request (no JWT role claim for authorisation). The system was built in WP1 (MC #103141, branch `feat/rbac-wp1-permissions-catalog`).

## Roles

<table id="bkmrk-rolelevelscope-owner"><thead><tr><th>Role</th><th>Level</th><th>Scope</th></tr></thead><tbody><tr><td>`owner`</td><td>3</td><td>All permissions including billing, account deletion, user management</td></tr><tr><td>`admin`</td><td>2</td><td>All permissions except billing and account deletion; can manage users and roles</td></tr><tr><td>`accountant`</td><td>1</td><td>Create and manage financial records; cannot delete; cannot manage users</td></tr><tr><td>`viewer`</td><td>0</td><td>Read-only access; default for newly JIT-provisioned Entra users</td></tr></tbody></table>

Roles are stored in `users.role` (VARCHAR 50) with a CHECK constraint added in V67 limiting values to these four. Single role per user per organisation (multi-role/multi-org deferred, MC #103089).

## Permissions Catalog (V67 — 52 keys)

Source: `apps/api/src/main/resources/db/migration/V67__rbac_permissions_catalog.sql` (commit 66629bd). The catalog is stored in the `permissions` table; all application code references permission keys as string constants.

Format: `<resource>:<verb>` enforced by a DB CHECK constraint (`permission_key_format`). Example keys by resource group:

<table id="bkmrk-resource-groupexampl"><thead><tr><th>Resource group</th><th>Example keys</th></tr></thead><tbody><tr><td>Invoices</td><td>`invoice:read`, `invoice:create`, `invoice:update`, `invoice:delete`, `invoice:submit`</td></tr><tr><td>Expenses</td><td>`expense:read`, `expense:create`, `expense:update`, `expense:delete`</td></tr><tr><td>Contacts</td><td>`contact:read`, `contact:create`, `contact:update`, `contact:delete`</td></tr><tr><td>Transactions</td><td>`transaction:read`, `transaction:create`, `transaction:reconcile`</td></tr><tr><td>Reports</td><td>`report:read`, `report:export`</td></tr><tr><td>Settings / billing</td><td>`settings:read`, `settings:update`, `billing:read`, `billing:update`</td></tr><tr><td>Users</td><td>`users:read`, `users:manage`, `users:invite`</td></tr><tr><td>Account admin</td><td>`account:delete`</td></tr><tr><td>Documents</td><td>`document:read`, `document:upload`, `document:delete`</td></tr><tr><td>Articles / products</td><td>`article:read`, `article:create`, `article:update`, `article:delete`</td></tr></tbody></table>

Full 52-key baseline stored in: `apps/api/src/main/resources/rbac/requireRole-baseline-v67.tsv` (commit 0bf18fd, 51 data rows).

## Role-to-Permission Seed (Strategy A — Flat Inheritance)

Source: `role_permissions` table seeded in V67. Each row: `(role, permission_key)`. No runtime inheritance logic — the seed embeds the full flattened set for each role.

<table id="bkmrk-rolepermissions-coun"><thead><tr><th>Role</th><th>Permissions count</th><th>Principle</th></tr></thead><tbody><tr><td>viewer</td><td>13</td><td>Read-only: all :read + :export keys</td></tr><tr><td>accountant</td><td>40</td><td>viewer permissions + create/update on financial resources; no delete, no user management</td></tr><tr><td>admin</td><td>49</td><td>accountant permissions + delete + user management; no billing:update, no account:delete</td></tr><tr><td>owner</td><td>52</td><td>All 52 permissions (complete set)</td></tr></tbody></table>

The seed exactly reproduces the behaviour of the legacy `requireRole()` numeric hierarchy — verified by 204 RbacMatrixTest cases (0 failures). No behaviour regression.

## PermissionService — Live DB Resolution

Source: `apps/api/src/main/kotlin/no/alai/bilko/services/PermissionService.kt` (commit dee4fb1)

- Interface method: `fun resolve(role: String): Set<String>` (2 implementations: interface + `DbPermissionService`)
- Live DB query against `role_permissions` on every resolve call
- **Fail-closed:** if role is unknown or DB returns empty set, resolves to `emptySet()` — no permissions granted
- CEO OCD O1 decision: global per-role cache (4 known values); result cached per role string key. Per CEO spec, cache keyed `userId+role-version` was the ideal; current implementation uses global per-role cache (simpler, advisory gap noted in Proveo verdict)

## BilkoPrincipal + requirePermission

Source: `apps/api/src/main/kotlin/no/alai/bilko/auth/BilkoPrincipal.kt` and `RbacHelper.kt` (commit dee4fb1)

- `BilkoPrincipal` carries `permissions: Set<String>` — resolved at authentication time via `PermissionService`
- `RoutingContext.requirePermission(permissionKey: String)` — Kotlin extension function; throws `ForbiddenException` (HTTP 403 `BILKO-AUTH-003`) if key not in principal's permission set; calls `AuthzAuditLogger`
- All 51 formerly-`requireRole()` call sites migrated to `requirePermission()` (17 route files, 0 residual `requireRole` in routes — verified by grep)
- `requireRole()` is kept as a thin compatibility shim (RbacHelper.kt)

## Role-to-Permission Matrix

<table id="bkmrk-permission-keyviewer"><thead><tr><th>Permission key</th><th>viewer</th><th>accountant</th><th>admin</th><th>owner</th></tr></thead><tbody><tr><td>`invoice:read`</td><td>Y</td><td>Y</td><td>Y</td><td>Y</td></tr><tr><td>`invoice:create`</td><td>-</td><td>Y</td><td>Y</td><td>Y</td></tr><tr><td>`invoice:update`</td><td>-</td><td>Y</td><td>Y</td><td>Y</td></tr><tr><td>`invoice:delete`</td><td>-</td><td>-</td><td>Y</td><td>Y</td></tr><tr><td>`invoice:submit`</td><td>-</td><td>Y</td><td>Y</td><td>Y</td></tr><tr><td>`expense:read`</td><td>Y</td><td>Y</td><td>Y</td><td>Y</td></tr><tr><td>`expense:create`</td><td>-</td><td>Y</td><td>Y</td><td>Y</td></tr><tr><td>`expense:delete`</td><td>-</td><td>-</td><td>Y</td><td>Y</td></tr><tr><td>`users:read`</td><td>Y</td><td>Y</td><td>Y</td><td>Y</td></tr><tr><td>`users:manage`</td><td>-</td><td>-</td><td>Y</td><td>Y</td></tr><tr><td>`users:invite`</td><td>-</td><td>-</td><td>Y</td><td>Y</td></tr><tr><td>`billing:read`</td><td>-</td><td>-</td><td>-</td><td>Y</td></tr><tr><td>`billing:update`</td><td>-</td><td>-</td><td>-</td><td>Y</td></tr><tr><td>`account:delete`</td><td>-</td><td>-</td><td>-</td><td>Y</td></tr><tr><td>`settings:read`</td><td>Y</td><td>Y</td><td>Y</td><td>Y</td></tr><tr><td>`settings:update`</td><td>-</td><td>-</td><td>Y</td><td>Y</td></tr><tr><td>`report:read`</td><td>Y</td><td>Y</td><td>Y</td><td>Y</td></tr><tr><td>`report:export`</td><td>Y</td><td>Y</td><td>Y</td><td>Y</td></tr><tr><td>... (52 total)</td><td colspan="4">Full catalog in V67 seed</td></tr></tbody></table>

Full read-only matrix visible to admins/owners in the web admin UI at `/admin/users` (component: `lib/permissions.ts ROLE_PERMISSION_MATRIX`).

## Authorization Audit Log

Source: `apps/api/src/main/kotlin/no/alai/bilko/auth/AuthzAuditLogger.kt` (commit dee4fb1)

- Every `requirePermission()` call logs an `authz_decision` event (SLF4J structured log)
- Log fields: `userId`, `orgId`, `permissionKey`, `granted` (boolean), `route`
- `RbacHelper.kt` references `AuthzAuditLogger` at 4 call sites (verified)

## V67/V68 Migration Summary

<table id="bkmrk-migrationcontents-v6"><thead><tr><th>Migration</th><th>Contents</th></tr></thead><tbody><tr><td>V67 (`V67__rbac_permissions_catalog.sql`)</td><td>Creates `permissions` table (52 keys, format CHECK); `role_permissions` table with full 4-role seed; adds `users.role` CHECK constraint; GRANT SELECT to bilko\_app; no RLS (global catalog)</td></tr><tr><td>V68 (`V68__rbac_user_provisioning.sql`)</td><td>Adds `users:manage` and `users:invite` permission keys; SECURITY DEFINER function `bilko_auth.provision_user_with_org(issuer, oid, email, fullName)` returning new user UUID; seeds new permissions to admin + owner roles</td></tr></tbody></table>

## Test Coverage

- 204 RbacMatrixTest cases (all 51 call sites x 4 roles): 0 failures — `feat/rbac-wp1-permissions-catalog`
- 8 UserProvisioningWp2Test cases (T1–T8: JIT, admin CRUD, role guards, self-escalation block): PASS
- Total test suite: 2534 tests (1070 unit + 1283 integration + 181 web), 0 failures — WP5 E2E evidence `/tmp/evidence-103145`

## Out of Scope (v1)

- Multi-role per user (single role per org; MC #103089)
- Multi-org membership (single org per user; MC #103089)
- ABAC / conditional permissions (e.g. "delete only own drafts")
- Accountant Portal multi-tier permissions (Collaborator/Approver roles from ACCOUNTANT-PORTAL-SPEC.md §2.2)

# Bilko Auth Migration Runbook + Admin Guide

## Scope

This runbook covers: (1) how an operator bootstraps the first admin after legacy auth is retired, (2) how an admin creates and invites users, (3) how to assign and change roles, (4) the full user lifecycle via Entra, (5) migration notes from the WP1–WP4 branch stack. For architecture detail see [Bilko Authentication — Entra External ID (CIAM)](/books/bilko-balkan-accounting-saas/page/bilko-authentication-entra-external-id-ciam).

## 1. Bootstrapping the First Admin (Break-Glass)

After legacy `/auth/register` is retired (HTTP 410), there is no HTTP endpoint for creating the first user. Use the Gradle break-glass task:

```
# Set environment variables (never commit these):
export BOOTSTRAP_ADMIN_EMAIL="admin@yourorg.com"
export BOOTSTRAP_ADMIN_PASSWORD="<strong-temporary-password>"

# Run from the api project root:
cd apps/api
./gradlew :apps:api:bootStrapAdmin
```

What this does: calls `AuthService.register()` directly (bypasses HTTP routing), creates an organisation + owner user. No HTTP endpoint is exposed — zero backdoor surface. The temporary password should be rotated immediately via Entra SSPR after the admin first signs in.

Full runbook file: `apps/api/docs/runbooks/BREAK-GLASS-BOOTSTRAP.md` (on branch `feat/rbac-wp4-retire-legacy-auth`).

## 2. Creating / Inviting Users (Admin Flow)

User creation is now admin-gated. Self-serve registration is retired.

### Via API

```
POST /api/v1/admin/users
Authorization: Bearer <admin-or-owner-access-token>

{
  "email": "newuser@example.com",
  "fullName": "Full Name",
  "role": "viewer"        // viewer | accountant | admin | owner
}
```

Response: `HTTP 201 Created` — returns the new user object including their UUID. The user receives an invite; they sign in via Entra (JIT provisioning links their Entra identity on first sign-in).

### Permission required

`users:manage` — held by `admin` and `owner` roles.

### Via Web Admin UI

1. Sign in as admin or owner
2. Navigate to **Settings &gt; Users** (`/admin/users`)
3. Click **Invite User**
4. Enter email, full name, and select role
5. Submit — user receives invite email (Entra CIAM invitation flow)

Viewers and accountants see a redirect to the dashboard if they navigate to `/admin/users`.

## 3. Assigning / Changing Roles

### Via API

```
PUT /api/v1/users/:id/role
Authorization: Bearer <admin-or-owner-access-token>

{
  "role": "accountant"    // viewer | accountant | admin | owner
}
```

Constraints enforced:

- Caller must have `users:manage` permission (admin+)
- A user **cannot change their own role** (self-escalation blocked — HTTP 403)
- The `owner` role cannot be changed via this endpoint (owner is protected in `SettingsService.changeUserRole()`)
- Invalid role values return HTTP 400

### Via Web Admin UI

1. Navigate to **Settings &gt; Users**
2. Find the user row
3. Click the role dropdown (visible to admin/owner only)
4. Select the new role — saved immediately via `PUT /users/:id/role`

## 4. User Lifecycle

1. **Admin creates user** via `POST /admin/users` (role = viewer by default, or specified role)
2. **User receives Entra invite** (email from `bilkociam.onmicrosoft.com`)
3. **First sign-in**: user clicks Entra sign-in on Bilko web login → authenticates in Entra CIAM → Bilko backend calls `createSessionFromEntraIdToken()`:
- Looks up `entra_external_identities` by `oid` → not found (first login)
- Email-match lookup → finds pre-created user → inserts `entra_external_identities` row (JIT link) → audit event `entra_jit_link`
- Bilko session returned; user is logged in as viewer

5. **Admin promotes role** if needed via `PUT /users/:id/role`
6. **Subsequent logins**: Entra → backend finds `entra_external_identities` by `oid` → direct session, no email-match step
7. **Sign-out**: MSAL calls Entra logout endpoint → Entra session invalidated → Bilko session cookie cleared → next visit redirects to Entra login

## 5. What Changed — Migration Notes (WP1–WP4)

<table id="bkmrk-areabefore-%28pre-wp1%29"><thead><tr><th>Area</th><th>Before (pre-WP1)</th><th>After (WP1–WP4)</th></tr></thead><tbody><tr><td>Backend auth enforcement</td><td>`requireRole("admin")` inline in 51+ route handlers</td><td>`requirePermission("invoice:create")` via extension fn; 0 residual requireRole in routes</td></tr><tr><td>Permission data</td><td>No tables; hardcoded numeric hierarchy in RbacHelper</td><td>V67: `permissions` table (52 keys), `role_permissions` seed; V68: provisioning function</td></tr><tr><td>User provisioning</td><td>Self-serve `POST /auth/register`</td><td>Admin invite (`POST /admin/users`) + JIT Entra link on first sign-in; `UserProvisioningService`</td></tr><tr><td>Web login</td><td>Email/password form + "Sign in with Microsoft" coexisting</td><td>Entra-only CTA; no email/password form; register page shows "contact your admin"</td></tr><tr><td>Legacy endpoints</td><td>Active: /auth/login, /auth/register, /auth/forgot-password, /auth/reset-password</td><td>HTTP 410 Gone + ENDPOINT\_RETIRED body</td></tr><tr><td>Password reset</td><td>Email-based reset-password flow (V57 table)</td><td>Redirect to Entra SSPR portal (self-service via Microsoft account)</td></tr><tr><td>RBAC admin UI</td><td>No UI; role changes required direct DB query</td><td>Web: Settings &gt; Users page with role dropdown (admin/owner only)</td></tr></tbody></table>

## 6. Branch Stack (WP1–WP4 Stacked PRs)

<table id="bkmrk-wpbranchlatest-commi"><thead><tr><th>WP</th><th>Branch</th><th>Latest commit</th><th>Key files</th></tr></thead><tbody><tr><td>WP1 — RBAC catalog</td><td>`feat/rbac-wp1-permissions-catalog`</td><td>890168d (last route commit)</td><td>V67 migration, PermissionService, BilkoPrincipal, RbacHelper, 17 route files migrated</td></tr><tr><td>WP2 — Provisioning</td><td>`feat/rbac-wp2-user-provisioning`</td><td>a9fa67c</td><td>V68 migration, UserProvisioningService, UserManagementRoutes</td></tr><tr><td>WP3 — Web UI</td><td>`feat/rbac-wp3-web-entra-ui`</td><td>3c1c019</td><td>login/page.tsx (Entra CTA), register/page.tsx (retired), admin/users/page.tsx, lib/permissions.ts</td></tr><tr><td>WP4 — Retire legacy</td><td>`feat/rbac-wp4-retire-legacy-auth`</td><td>3ac1388</td><td>AuthRoutes.kt (5 x 410), api.ts (removed methods), auth-store.ts, 13 new web tests</td></tr></tbody></table>

These branches are stacked and NOT yet merged to main. Production cutover requires a consolidated merge PR after CEO/Securion sign-off.

## 7. Rollback Procedure

If the consolidated deploy to main needs to be rolled back:

1. **Feature flag path** (if `FEATURE_ENTRA_AUTH_ENABLED` env var is present): set to `false` to re-enable the password auth provider path (AuthProvider interface, D5 in MC #103075)
2. **Hard rollback**: revert to the pre-WP1 commit; Flyway handles down-migration if reversible V67/V68 down scripts were authored (check migration files)
3. **password\_hash**: column was made nullable in V66 but existing rows retain their hash values — password-based login can be re-enabled without data loss during the rollback window
4. **Password reset tokens table (V57)**: NOT dropped. Must not be dropped until the rollback window closes (minimum 30 days post-production cutover). See D8 plan in MC #103075
5. **Entra disable**: if Entra must be disabled urgently, also disable users in Bilko DB to enforce immediate revocation (7-day refresh window caveat — OC#4)

## 8. Database Schema Reference

<table id="bkmrk-tablekey-columnsnote"><thead><tr><th>Table</th><th>Key columns</th><th>Notes</th></tr></thead><tbody><tr><td>`users`</td><td>`id`, `organization_id`, `email`, `password_hash` (nullable), `role` (CHECK owner|admin|accountant|viewer), `two_factor_*`</td><td>V66: password\_hash nullable; V67: role CHECK added</td></tr><tr><td>`entra_external_identities`</td><td>`issuer`, `subject` (= oid), `user_id`, `last_login_at`</td><td>V64: created; UNIQUE(issuer,subject), UNIQUE(user\_id,issuer); RLS: V66</td></tr><tr><td>`permissions`</td><td>`permission_key` (PK, CHECK format)</td><td>V67: 52 keys; global catalog, no RLS, GRANT SELECT bilko\_app</td></tr><tr><td>`role_permissions`</td><td>`role`, `permission_key`</td><td>V67: exhaustive flat seed; V68: users:manage + users:invite added</td></tr></tbody></table>

## Evidence Files

- WP1 verification: `/tmp/evidence-103141/wp1-verification.md`, proveo-verdict.json
- WP2 verification: `/tmp/evidence-103142/wp2-verification.md`, proveo-wp2-verdict.json
- WP3 Proveo: `/tmp/evidence-103143/proveo-wp3-validation.md`
- WP4 verification: `/tmp/evidence-103144/wp4-verification.md`
- WP5 E2E: `/tmp/evidence-103145/wp5-e2e.md`, verification.json
- Phase 0 config: `/tmp/evidence-103076/phase0-config.md`

# ADR-037 -- Entra Authenticates, Bilko Authorises; Single-Role v1; Multi-Org Deferred

## ADR-037 — Entra Authenticates, Bilko Authorises; Single-Role v1; Multi-Org Deferred

<table id="bkmrk-fieldvalue-adr-numbe"><thead><tr><th>Field</th><th>Value</th></tr></thead><tbody><tr><td>ADR number</td><td>ADR-037</td></tr><tr><td>Date</td><td>2026-06-08</td></tr><tr><td>Status</td><td>Accepted</td></tr><tr><td>Author</td><td>John (AI Director, ALAI Holding AS)</td></tr><tr><td>CEO decision</td><td>Alem Basic — confirmed 2026-06-07 (CEO resolution addendum, MC #103075)</td></tr><tr><td>Related MCs</td><td>MC #103075 (Entra migration plan), MC #103141–103146 (WP1–WP6 execution), MC #103089 (multi-org, parked)</td></tr><tr><td>Supersedes</td><td>Existing inline requireRole() pattern (pre-WP1)</td></tr></tbody></table>

## Context

Bilko had a custom email/password authentication system and a simple numeric role hierarchy (`requireRole()` inline in route handlers). No permission catalog, no RBAC tables, no admin UI for user management. The CEO decision (June 2026) was to:

1. Replace email/password authentication with Microsoft Entra External ID (CIAM) — hard REPLACE, not phased coexist
2. Build a real permission-catalog RBAC system with a DB-backed role-to-permission mapping

Multiple design forks were evaluated by a multi-agent panel (Parisa Tabriz, Martin Kleppmann, Petter Graff, Bruce Momjian, Devils Advocate — MC #103075 forged prompt). Key unresolved tensions: web direct-bearer vs exchange, email-match JIT vs pre-provision-by-oid, roles-in-Entra-claims vs roles-in-Bilko-DB.

## Decision

### D1 — Identity Provider Boundary

**Entra External ID (CIAM) authenticates. Bilko authorises.**

- Entra issues tokens; Bilko backend validates JWKS RS256 signature, issuer, audience
- Bilko reads `oid` from the Entra token as the sole identity anchor (`sub` is pairwise-pseudonymous per app and must NOT be used)
- Bilko issues its own access + refresh tokens after Entra token exchange; downstream services consume Bilko tokens, not Entra tokens directly
- Role and permission data live in `users.role` + `role_permissions` (Bilko DB). No role or permission claims are read from Entra tokens

### D2 — Single Role per User per Organisation (v1)

One role per user per org: `owner | admin | accountant | viewer`. The role is stored in `users.role` (single column). Multi-role per user and multi-org membership are explicitly deferred to a separate epic (MC #103089).

**Rationale:** zero live clients; single-org Entra tenant; keep scope tightly bounded; multi-org requires a `organization_members` join table and CIAM tenant model decisions that are not yet resolved.

### D3 — Permission Catalog in DB; Flat Inheritance Seed

A `permissions` catalog table (52 keys, `resource:verb` format enforced by CHECK) and a `role_permissions` mapping table (V67) replace the inline `requireRole()` calls. Seed strategy: flat exhaustive rows per role (Strategy A) — no runtime hierarchy derivation. The seed exactly reproduces existing behaviour (no regression — verified by 204 RbacMatrixTest cases).

### D4 — Live DB Permission Resolution; Fail-Closed

`PermissionService.resolve(role)` queries `role_permissions` at request time. Unknown role resolves to `emptySet()` (no permissions). `BilkoPrincipal` carries the resolved permission set. All route-level checks use `requirePermission("resource:verb")`.

### D5 — Multi-Org Deferred

Entra CIAM is provisioned as a single tenant. JIT provisioning assigns a new Entra user to one Bilko organisation. Multi-org (one user in multiple orgs) requires: a `organization_members` join table, per-org permission resolution, and CIAM tenant model decisions. All deferred to MC #103089.

## Consequences

### Positive

- Authentication complexity moved to Microsoft (password policies, MFA, SSPR, account lifecycle)
- Bilko no longer stores password hashes for new users (`password_hash` is nullable)
- Permission model is auditable and admin-configurable without code changes (role-to-permission seed is data)
- Authz decisions are logged (`AuthzAuditLogger`) for incident investigation
- Admin UI for user + role management (no more raw SQL for role changes)

### Negative / Trade-offs

- Entra CIAM has MAU-based pricing; cost gate was raised (OC#1, MC #103075) — free tier starts June 2026
- 7-day refresh token revocation window: a disabled Entra account remains valid in Bilko for up to 7 days (documented risk OC#4; mitigation: admin disables user in Bilko DB)
- Email-match JIT carries race risk if email is mutable or duplicated (martin-kleppmann + bruce-momjian dissent on record); serializable transaction is a partial mitigation; pre-provision-by-OID is the recommended production path
- Single-role v1 limits fine-grained delegation scenarios (e.g. "viewer + approve-only on specific documents") — documented as out of scope

## Alternatives Considered

<table id="bkmrk-alternativerejected-"><thead><tr><th>Alternative</th><th>Rejected reason</th></tr></thead><tbody><tr><td>Roles in Entra claims (Entra app roles)</td><td>Couples authorisation to IdP; role changes require Entra admin action not Bilko admin action; prevents clean multi-IdP future. Rejected per petter-graff + parisa-tabriz panel consensus.</td></tr><tr><td>Phased coexist (email/password + Entra in parallel for 2+ weeks)</td><td>CEO confirmed hard REPLACE. Panel devils-advocate raised phased coexist as safer; CEO re-confirmed hard REPLACE given zero live users. AuthProvider interface (D5 MC #103075) technically enables a revert if needed.</td></tr><tr><td>Denormalised entra\_oid on users table (bruce-momjian alternative)</td><td>Separate-table V64 model kept; enables multi-IdP future; join cost is negligible at current scale. Fork preserved but not resolved — separate-table remains.</td></tr><tr><td>ABAC / policy engine (v1)</td><td>Premature for current scale and requirements; adds complexity; deferred as explicit out-of-scope with comment in plan.</td></tr></tbody></table>

## Open Decisions Not Resolved by This ADR

- **OC#4 — Refresh revalidation vs risk acceptance:** Option A (revalidate Entra account status on refresh, ~50ms) vs Option B (7-day window, documented risk). Requires CEO/Securion explicit decision. Code stub for Option A is in `AuthService.kt` referencing MC #103075.
- **OC#2 — Hard REPLACE confirmed** but AuthProvider interface (D5, MC #103075) enables reversion if needed.
- **Web direct-bearer vs mobile exchange (parisa-tabriz dissent LIVE):** Web: MSAL acquires Entra access token, sends as Bearer to API. Mobile: id\_token exchange at `POST /auth/entra/session`. Web direct-bearer is implemented; exchange path preserved as commented stub per spec.

## Document Links

- [Bilko Authentication — Entra External ID (CIAM)](/books/bilko-balkan-accounting-saas/page/bilko-authentication-entra-external-id-ciam)
- [Bilko RBAC — Users / Roles / Permissions](/books/bilko-balkan-accounting-saas/page/bilko-rbac-users-roles-permissions)
- [Bilko Auth Migration Runbook + Admin Guide](/books/bilko-balkan-accounting-saas/page/bilko-auth-migration-runbook-admin-guide)
- Source plan: `/Users/makinja/system/specs/bilko-web-entra-cutover-and-rbac-plan-2026-06-08.md`
- Forged prompt (panel dissent log): `/Users/makinja/system/prompts/forged/103075.md`
- Phase 0 config: `/tmp/evidence-103076/phase0-config.md`

# Bilko Self-Serve Trial — CIAM Architecture and Auth Pattern (MC #103232)

# Bilko Self-Serve Trial — CIAM Architecture &amp; Auth Pattern

**MC:** #103232 | **Status:** LIVE — Proveo 11/11 PASS | **Last updated:** 2026-06-09 | **Securion verdict:** LAUNCH WITH CONDITIONS

---

## 1. Overview

A prospect navigates to `app.bilko.cloud` (or `bilko-demo.alai.no`), clicks **"Sign in or create a free account with your email"**, and completes a Microsoft CIAM Email-OTP sign-up. On first login, the backend JIT-provisions an empty Bilko organisation with a **7-day trial** directly on the real production database (`bilko-demo-db`). There is no separate demo build, no invite-only flow, and no org Microsoft account required — any personal email address works.

The deployment target is the standard `bilko-main-deploy` semver-tag trigger. Stage and demo share the same Kotlin/Ktor binary and the same database instance (multi-tenant via RLS). The CIAM tenant (`bilkociam`) is a dedicated Microsoft Entra External ID tenant, completely separate from the Bilko staff Entra tenant.

### 1.1 Flow diagram

```

Prospect → app.bilko.cloud/login
         → "Sign in with Microsoft" (MSAL redirect)
         → bilkociam.ciamlogin.com [BilkoSignUpSignIn user flow]
         → Email OTP verification (8-digit code, ~6s delivery)
         → Consent pages (2 pages on first login only)
         → Redirect to app.bilko.cloud/auth/callback
         → MSAL: LOGIN_SUCCESS fires, payload.idToken available
         → POST /auth/entra/session { idToken }   [B1 exchange fix]
         → bilko-api: JWKS RS256 verify → OID lookup → JIT provision
         → Response: Bilko HMAC JWT + org { trialEndsAt }
         → setAuthFromRegistration()              [B1.2 session fix]
         → checkAuth() in-memory JWT fast-path    [B1.3 session fix]
         → /dashboard — empty org, trial active ("Probno: 6 dana preostalo")
```

---

## 2. CIAM Tenant Configuration

<table id="bkmrk-propertyvalue-tenant"> <thead><tr><th>Property</th><th>Value</th></tr></thead> <tbody> <tr><td>Tenant name</td><td>bilkociam</td></tr> <tr><td>Tenant ID</td><td>20bb17de-9be5-4143-a7e5-8c1ddae6a064</td></tr> <tr><td>Tenant type</td><td>CIAM (Entra External ID)</td></tr> <tr><td>SPA app name</td><td>Bilko Web (SPA)</td></tr> <tr><td>SPA client ID</td><td>c2902239-ea63-41bd-8619-6cf096d7d45a</td></tr> <tr><td>API resource app ID</td><td>fe39e0f5-513e-40af-93f0-c3ee624df56c</td></tr> <tr><td>Authority URL</td><td>https://20bb17de-9be5-4143-a7e5-8c1ddae6a064.ciamlogin.com/20bb17de-9be5-4143-a7e5-8c1ddae6a064/v2.0</td></tr> <tr><td>OIDC issuer</td><td>same as authority URL (confirmed via discovery endpoint)</td></tr> </tbody></table>

### 2.1 User flow: BilkoSignUpSignIn

<table id="bkmrk-propertyvalue-flow-i"> <thead><tr><th>Property</th><th>Value</th></tr></thead> <tbody> <tr><td>Flow ID</td><td>aa86084b-01dc-453f-9e10-679dfefdd824</td></tr> <tr><td>Type</td><td>externalUsersSelfServiceSignUpEventsFlow</td></tr> <tr><td>Display name</td><td>BilkoSignUpSignIn</td></tr> <tr><td>Identity provider</td><td>EmailOtpSignup-OAUTH (Email One Time Passcode)</td></tr> <tr><td>isSignUpAllowed</td><td>true</td></tr> <tr><td>userTypeToCreate</td><td>member (not guest)</td></tr> <tr><td>Attributes collected</td><td>email (auto-filled by OTP verification)</td></tr> <tr><td>Linked app</td><td>c2902239-ea63-41bd-8619-6cf096d7d45a (Bilko Web SPA)</td></tr> </tbody></table>

**Authority URL note:** Unlike Azure AD B2C, Entra External ID CIAM does *not* require a user flow policy name suffix in the authority URL. The BilkoSignUpSignIn flow is applied automatically at the tenant level when the SPA app is linked to it. The deployed authority URL requires no changes.

### 2.2 Registered SPA redirect URIs

- https://app.bilko.cloud/auth/callback and https://app.bilko.cloud
- https://app.bilko.company/auth/callback and https://app.bilko.company
- https://app.bilko.io/auth/callback and https://app.bilko.io
- https://bilko-demo.alai.no/auth/callback and https://bilko-demo.alai.no
- https://bilko-web-stage-dh4m46blja-lz.a.run.app/auth/callback and .a.run.app
- http://localhost:3000/auth/callback and http://localhost:3000

### 2.3 Adding identity providers or attributes

To add social identity providers (Google, Apple) or additional signup attributes (e.g. display name, company name): Microsoft Entra admin centre → External Identities → User flows → BilkoSignUpSignIn → Identity providers / Attributes. No code changes or redeploys are required for attribute-only changes. Adding a social provider requires app registration on the provider side and linking in the CIAM tenant.

---

## 3. Auth Flow — The Hard-Won Pattern

This section documents three bugs that were discovered and fixed during Proveo E2E validation (MC #103232 WS-V). The fixes are canonical — do not revert them.

### B1 — Token exchange (commit 660f410, tag v0.2.45)

**Problem:** MSAL's `LOGIN_SUCCESS` event fires with an Entra `access_token` (RS256, Microsoft-issued). The original code set this directly as the API Bearer header. The Bilko API validates HMAC256 JWTs only — all calls returned 401.

**Fix:** After MSAL fires, pass `payload.idToken` (not `payload.accessToken`) to a `POST /auth/entra/session { idToken }` call. The backend verifies the CIAM RS256 idToken via JWKS, looks up or JIT-provisions the user, and returns a Bilko HMAC JWT.

```

// apps/web/lib/msal/msal-provider.tsx — corrected token selection
const idToken = payload.idToken ?? payload.accessToken
if (idToken) { handleEntraLogin(idToken) }

// apps/web/lib/msal/use-entra-auth.ts — exchange call
const sessionResult = await api.auth.entraSession(idToken)
const bilkoJwt = sessionResult?.tokens?.accessToken
setAccessToken(bilkoJwt)
```

### B1.2 — Session persistence via setAuthFromRegistration (commit e1e31c5, tag v0.2.46)

**Problem:** After the B1 exchange, `checkAuth()` was called to hydrate the store. `checkAuth()` internally calls `POST /auth/refresh` using the httpOnly refresh-token cookie. The CIAM exchange path does not set a cookie — so `/auth/refresh` returned 401, which cleared the Bilko JWT and redirected back to `/login`.

**Fix:** Replace the `checkAuth()` call in `handleEntraLogin` with `setAuthFromRegistration()`, which hydrates the Zustand auth store directly from the `/auth/entra/session` response body. No cookie round-trip needed.

```

// apps/web/lib/msal/use-entra-auth.ts — hydrate from session response
const { setAuthFromRegistration } = useAuthStore.getState()
setAuthFromRegistration({
  user: sessionResult.user,
  organization: sessionResult.organization,
  tokens: { accessToken: bilkoJwt },
})
// Navigate to /dashboard — Bilko JWT is in-memory Bearer
```

### B1.3 — checkAuth in-memory JWT fast-path (commit 30a8c85, tag v0.2.47)

**Problem:** Even with B1.2, `setAuthFromRegistration()` in `handleEntraLogin` correctly set `isAuthenticated=true`. However, `AuthProvider` mounts on every protected route and calls `checkAuth()`. That call hit `/auth/refresh` (cookie path) → 401 → store reset to unauthenticated → redirect to `/login` on every page navigation.

**Fix:** Added an in-memory JWT fast-path at the top of `checkAuth()` in `auth-store.ts`. If a Bilko JWT is already in memory (set via the CIAM exchange), `checkAuth()` uses `GET /auth/me` with that Bearer token instead of falling through to the cookie-refresh path.

```

// apps/web/lib/stores/auth-store.ts — in-memory fast-path
checkAuth: async () => {
  const inMemoryToken = getAccessToken()
  if (inMemoryToken) {
    try {
      const me = await api.auth.me()
      set({ isAuthenticated: true, isLoading: false,
            user: { ...me, name: me.fullName },
            organization: me?.organization ?? null })
      return true
    } catch {
      set({ isAuthenticated: false, isLoading: false, user: null, organization: null })
      return false
    }
  }
  // Original cookie-refresh fallback (unchanged — non-CIAM sessions)
  ...
}
```

### ENTRA\_EXTERNAL\_ID\_AUDIENCE — critical build var (fixed in v0.2.47 trigger update)

**Problem:** The `bilko-main-deploy` Cloud Build trigger had `_ENTRA_EXTERNAL_ID_AUDIENCE` set to `fe39e0f5` (the API resource app ID). This is wrong — the CIAM idToken audience is the *SPA client ID* (`c2902239`), because MSAL requests id\_tokens scoped to the requesting app. Every new deploy reverted the Cloud Run env to the wrong value, requiring a manual patch.

**Fix:** The trigger substitution was updated:

```

# infrastructure/gcp/cloudbuild.yaml — correct value
_ENTRA_EXTERNAL_ID_AUDIENCE: c2902239-ea63-41bd-8619-6cf096d7d45a   # SPA client ID
# NOT: fe39e0f5-513e-40af-93f0-c3ee624df56c  (that is the API resource app — wrong for idToken aud)
```

This is now stable in the trigger — it will not revert on future deploys.

---

## 4. Backend JIT Provisioning

### 4.1 Database migrations (Flyway V66–V69)

<table id="bkmrk-migrationpurpose-v66"> <thead><tr><th>Migration</th><th>Purpose</th></tr></thead> <tbody> <tr><td>V66\_\_entra\_rls\_and\_password\_nullable.sql</td><td>Makes `password_hash` nullable (Entra-only users have no password). Adds RLS policy on `entra_external_identities` (FORCE + fail-closed). Adds CHECK constraint: issuer must not end with trailing slash.</td></tr> <tr><td>V67\_\_rbac\_permissions\_catalog.sql</td><td>RBAC permissions catalog seeding.</td></tr> <tr><td>V68\_\_rbac\_user\_provisioning.sql</td><td>SECURITY DEFINER function `bilko_auth.provision_user_with_org()`: creates org (7-day trial, `trial_starts_at`, `trial_ends_at = now() + 7 days`), creates user (`role='viewer'`, `password_hash=NULL`), inserts `entra_external_identities` row (issuer, OID, user\_id). Default: `country='BA'`, `currency='BAM'`.</td></tr> <tr><td>V69\_\_fix\_provision\_rls.sql</td><td>RLS fix: calls `set_config('app.current_org_id', v_org_id, true)` before the users INSERT so that RLS policies on the users table pass during JIT provisioning.</td></tr> </tbody></table>

### 4.2 JIT provisioning call flow

```

POST /auth/entra/session { idToken }
  → EntraExternalIdService.verifyIdToken()         [RS256, JWKS, issuer+audience+exp]
  → AuthUserRepository.findByEntraIdentity(issuer, oid)  → null (new user)
  → AuthUserRepository.findByEmail(email)                → null (no existing Bilko account)
  → UserProvisioningService.provisionNewUserForEntra()
      → bilko_auth.provision_user_with_org()  [SECURITY DEFINER, SERIALIZABLE]
          → INSERT organizations (trial 7 days)
          → INSERT users (role=viewer, password=null)
          → INSERT entra_external_identities (issuer, oid)
  → jwtService.signAccessToken(userId, email, role='viewer', orgId)
  → Response: { user, organization { trialEndsAt }, tokens { accessToken, refreshToken } }
```

**Idempotency:** Re-login with the same OID returns the existing user and org; trial end date is not reset. The `entra_external_identities` table has a UNIQUE constraint on `(issuer, subject)`.

### 4.3 trialEndsAt in /auth/me

The `GET /auth/me` response includes `organization.trialEndsAt` (ISO 8601). The frontend auth store exposes this on the `Organization` interface. The trial expiry is enforced server-side by `TrialGatePlugin` which queries the DB on every gated request — the JWT does not embed expiry.

### 4.4 RLS isolation

All JIT-provisioned tenants are isolated via PostgreSQL Row Level Security. The `app.current_org_id` session variable is set by `OrgScopePlugin` from the `BilkoPrincipal` (JWT-derived, not from any HTTP header). `orgTransaction()` uses `SET LOCAL` scoped to the transaction — connection pool does not carry state between requests. Cross-tenant isolation is verified by `RlsOrgIsolationV46IntegrationTest`.

---

## 5. Deploy

<table id="bkmrk-propertyvalue-deploy"> <thead><tr><th>Property</th><th>Value</th></tr></thead> <tbody> <tr><td>Deploy trigger</td><td>bilko-main-deploy (europe-north1, project tribal-sign-487920-k0)</td></tr> <tr><td>Trigger type</td><td>semver tag on main: `git tag vX.Y.Z && git push origin vX.Y.Z`</td></tr> <tr><td>Config</td><td>infrastructure/gcp/cloudbuild.yaml</td></tr> <tr><td>Current live tag</td><td>v0.2.47 (commit 30a8c85)</td></tr> <tr><td>Web revision</td><td>bilko-web-demo-00080-tq5</td></tr> <tr><td>API revision</td><td>bilko-api-demo-00155-524</td></tr> <tr><td>CIAM env vars in trigger</td><td>NEXT\_PUBLIC\_ENTRA\_CLIENT\_ID, NEXT\_PUBLIC\_ENTRA\_AUTHORITY, NEXT\_PUBLIC\_ENTRA\_SCOPE, ENTRA\_EXTERNAL\_ID\_ISSUER, ENTRA\_EXTERNAL\_ID\_AUDIENCE (= c2902239), ENTRA\_EXTERNAL\_ID\_JWKS\_URL</td></tr> </tbody></table>

**ZAKON PI2:** Do not run `cloudbuild.yaml` manually. Use `git tag + git push origin` only. The stage pipeline (`bilko-stage-auto-deploy`) fires on every push to main and is unrelated to the demo deploy.

---

## 6. Known Follow-Ups

<table id="bkmrk-idprioritydescriptio"> <thead><tr><th>ID</th><th>Priority</th><th>Description</th></tr></thead> <tbody> <tr> <td>H1</td> <td>HIGH — must-fix before scale launch</td> <td>**Abuse gate (MC #103245):** JIT provisioning has no server-side rate gate on tenant creation. An attacker with many email inboxes can script CIAM sign-ups (each requires a real OTP but automation services exist). Fix: add a platform-level provision rate gate in `UserProvisioningService.provisionNewUserForEntra()` (max N JIT orgs per hour) + CIAM tenant configuration to block disposable email domains.</td> </tr> <tr> <td>B3</td> <td>MEDIUM</td> <td>**Migadu email OTP blocking:** Migadu (one.com), used for `@alai.no`, blocks Microsoft Azure CIAM OTP emails. Prospects with Gmail or Outlook receive OTP in ~6 seconds. Alai staff using `@alai.no` addresses cannot sign up. Fix: whitelist `accountprotection.microsoft.com` sender in Migadu SPF settings, or configure a custom CIAM email sender domain.</td> </tr> <tr> <td>UX-1</td> <td>LOW</td> <td>**Org display name:** JIT-provisioned orgs are named "unknown's Organization" (no display name collected at signup). The user flow only collects email. Fix: add displayName to the BilkoSignUpSignIn attribute collection (Azure config, no code change), or collect it on first post-login screen.</td> </tr> <tr> <td>UX-2</td> <td>LOW</td> <td>**Default country/currency:** JIT-provisioned org defaults to `country='BA'`, `currency='BAM'`. Prospects outside Bosnia must update via Settings. A country selection step at signup would improve the onboarding experience (follow-on, not a blocker).</td> </tr> <tr> <td>M1</td> <td>MEDIUM</td> <td>**INGRESS\_TRAFFIC\_ALL (MC #99924):** Direct `*.run.app` access bypasses GCLB, which degrades IP-based rate limiting to per-GFE-region keying. Pre-existing risk, not introduced by CIAM. Fix: lock ingress to internal-only when load balancer is provisioned.</td> </tr> <tr> <td>M3</td> <td>MEDIUM</td> <td>**No alert on rapid tenant creation:** Add a GCP Cloud Monitoring alert triggering when more than N organisations are JIT-provisioned per hour.</td> </tr> </tbody></table>

---

## 7. Validation Evidence

### Proveo — 11/11 PASS (v0.2.47, 2026-06-09T02:55Z)

Real Gmail sign-up (alembasic@gmail.com) end-to-end on `bilko-demo.alai.no`:

<table id="bkmrk-stepresultdetails-1p"> <thead><tr><th>Step</th><th>Result</th><th>Details</th></tr></thead> <tbody> <tr><td>1</td><td>PASS</td><td>Self-serve copy present; "Contact your administrator" absent</td></tr> <tr><td>2</td><td>PASS</td><td>"Sign in with Microsoft" → ciamlogin.com (tenant 20bb17de) redirect</td></tr> <tr><td>3</td><td>PASS</td><td>Email entered on CIAM; OTP sent immediately</td></tr> <tr><td>4</td><td>PASS</td><td>Returning user — OTP sent directly (no create-account needed)</td></tr> <tr><td>5</td><td>PASS</td><td>8-digit OTP (17717965) received via Gmail UID:75644 in 7 seconds</td></tr> <tr><td>6</td><td>PASS</td><td>Redirect back to bilko-demo.alai.no/dashboard</td></tr> <tr><td>7</td><td>PASS</td><td>POST /auth/entra/session → 200, Bilko HMAC JWT, org 4e96b6ff confirmed</td></tr> <tr><td>8</td><td>PASS</td><td>/dashboard with trial UI ("Probno: 6 dana preostalo"), /auth/me → 200 + trialEndsAt 2026-06-15</td></tr> <tr><td>9</td><td>PASS</td><td>/invoices via SPA nav — empty org (0 invoices), session alive</td></tr> <tr><td>10</td><td>PASS</td><td>/invoices/new — invoice form visible, trial tenant usable</td></tr> <tr><td>11</td><td>PASS</td><td>Regression clear — admin wall absent, self-serve copy confirmed</td></tr> </tbody></table>

**Zero /auth/refresh calls** during SPA navigation after B1.3 fix (confirmed by network capture count=0). Cross-tenant RLS: org 4e96b6ff shows 0 invoices and 0 BAM balances (no data from other tenants).

### Securion — LAUNCH WITH CONDITIONS

- CRITICAL: None found.
- HIGH: H1 (JIT provisioning rate gate) — must fix before scale launch (MC #103245).
- PASS areas: RS256 JWKS verification, issuer/audience pinning, OID as identity anchor, alg:none bypass blocked, org\_id derived from DB (not Entra token), RLS fail-closed, FORCE RLS on all 9 tenant tables, role=viewer hardcoded (no self-escalation), trial re-signup blocked, refresh token rotation (jti-based single-use), legacy auth endpoints retired (HTTP 410).

---

## 8. DEPLOY-MAP Reference

The CIAM substitutions live in `infrastructure/gcp/cloudbuild.yaml` under the `bilko-main-deploy` trigger. The Cloudflare Turnstile entries in DEPLOY-MAP.md cover the marketing landing forms and are unrelated to the CIAM auth flow. No DEPLOY-MAP.md changes are required for the CIAM self-serve trial feature — the trigger substitutions are already updated.

Do not add CIAM secrets to the DEPLOY-MAP secrets table — these are build-time substitutions injected directly from the trigger, not GCP Secret Manager secrets.

---

## 9. Environment Variables Reference

<table id="bkmrk-variableservicecorre"> <thead><tr><th>Variable</th><th>Service</th><th>Correct value note</th></tr></thead> <tbody> <tr><td>NEXT\_PUBLIC\_ENTRA\_CLIENT\_ID</td><td>bilko-web-demo (build-time)</td><td>c2902239-ea63-41bd-8619-6cf096d7d45a (SPA app)</td></tr> <tr><td>NEXT\_PUBLIC\_ENTRA\_AUTHORITY</td><td>bilko-web-demo (build-time)</td><td>https://\[tenant-id\].ciamlogin.com/\[tenant-id\]/v2.0 — no user flow suffix needed</td></tr> <tr><td>NEXT\_PUBLIC\_ENTRA\_SCOPE</td><td>bilko-web-demo (build-time)</td><td>api://fe39e0f5.../access\_as\_user</td></tr> <tr><td>ENTRA\_EXTERNAL\_ID\_ISSUER</td><td>bilko-api-demo</td><td>https://\[tenant-id\].ciamlogin.com/\[tenant-id\]/v2.0</td></tr> <tr><td>ENTRA\_EXTERNAL\_ID\_AUDIENCE</td><td>bilko-api-demo</td><td>**c2902239-ea63-41bd-8619-6cf096d7d45a** (SPA client ID — NOT the API resource ID)</td></tr> <tr><td>ENTRA\_EXTERNAL\_ID\_JWKS\_URL</td><td>bilko-api-demo</td><td>https://\[tenant-id\].ciamlogin.com/\[tenant-id\]/discovery/v2.0/keys</td></tr> </tbody></table>

**Critical:** `ENTRA_EXTERNAL_ID_AUDIENCE` must be the SPA client ID (`c2902239`), not the API resource app ID. MSAL requests id\_tokens with the SPA client as audience. If set to the API app ID, the backend rejects every CIAM idToken with audience mismatch.

---

*Page created by Skillforge (MC #103232 WS-D, 2026-06-09). Source evidence: /tmp/evidence-103232/. Validation: Proveo 11/11 PASS + Securion LAUNCH WITH CONDITIONS.*

# AI Support Agent — bilko.cloud (HR)

# AI Support Agent — bilko.cloud (HR)

**Status:** Phase 1 operational (internal copilot); Phase 2 customer-facing widget HELD for Securion review  
**MC Task:** #104429  
**Owner:** John → CodeCraft (Petter Graff lead)  
**Built:** 2026-06-28

## 1. Purpose &amp; Scope

The **Bilko Cloud AI Support Agent** is a dedicated support system for the `bilko.cloud` (Croatia) jurisdiction of the Bilko SaaS platform. It handles **both technical problems** (login/Entra SSO, e-invoice/OIB failures, RLS, deploy errors) **and user/accounting questions** (PDV rates, HR-FISK/eRačun, invoices, bookkeeping, general ledger) — grounded in the Bilko knowledge base, validated by HR accounting domain experts, and gated by anti-hallucination verification.

**Phased deployment:**

- **Phase 1 — Internal copilot** (current): Assists ALAI/SnowIT support staff via ticketing workflow; human approves and sends answers. No direct customer exposure.
- **Phase 2 — Customer-facing widget** (planned, HELD): In-app chat widget on `bilko.cloud` that auto-drafts answers in `triageJson` with guardrails (PII allowlist, prompt-injection guard, scope, escalation). Requires Securion security review before go-live.

**Jurisdiction:** Croatia only. The agent reasons under **HR tax/accounting rules** and answers in **Croatian (hr-HR)**.

**Cost target:** $0 per question (runs on local tier-2 models: Qwen 2.5 Coder 32B / MLX).

## 2. The Team — 5 Agents

<table id="bkmrk-roleagentregistryjob"><thead><tr><th>Role</th><th>Agent</th><th>Registry</th><th>Job</th></tr></thead><tbody><tr><td>Lead architect</td><td>**Petter Graff**</td><td>`~/.claude/agents/petter-graff.md`</td><td>Owns architecture, gates phases, integration design</td></tr><tr><td>Research (Phase 0)</td><td>Datavera / Explore</td><td>ephemeral dispatches</td><td>HR support taxonomy, competitor patterns, KB extraction (completed)</td></tr><tr><td>Support persona</td><td>**bilko-support**</td><td>`~/.claude/agents/bilko-support.md`</td><td>Orchestrates answer pipeline, enforces anti-hallucination, escalation</td></tr><tr><td>HR accounting expert #1</td><td>**bilko-racunovodstvo-hr**</td><td>`~/.claude/agents/bilko-racunovodstvo-hr.md`</td><td>Dvojno knjigovodstvo, RRiF chart of accounts, GL, expenses, financial statements (RDG/bilanca); KB author + validation gate</td></tr><tr><td>HR tax/fiscalization expert #2</td><td>**bilko-porez-fiskalizacija-hr**</td><td>`~/.claude/agents/bilko-porez-fiskalizacija-hr.md`</td><td>PDV (25/13/5/0%), HR-FISK 2.0/eRačun (UBL 2.1/EN 16931), OIB validation, FINA, filing deadlines; KB author + validation gate</td></tr></tbody></table>

All three personas (bilko-support + 2 experts) registered in `~/system/agents/specialist-mapping.json`:

- `bilko-support` → company `ALAI`, domain `product-support`
- `bilko-racunovodstvo-hr` + `bilko-porez-fiskalizacija-hr` → company `Finverge`, domain `hr-accounting`

## 3. Architecture — Answer Pipeline

**Orchestrator:** `~/system/tools/bilko-support-answer.js` (305 lines, CLI + module.exports).

**Pipeline (8 steps, deterministic):**

```
question (+ contextBundle: errorCode, appRoute, country=HR, planTier, orgId…)
 ↓
 [1] classify + route         tier-router.js (local $0 model)
 ↓
 [2] cache check              rag-router.js (flywheel.db hit? early return)
      ⮑ if cached + confidence >= 0.70 (or not acct/tax/e-invoice) → DONE
      ⮑ if cached BUT classification ∈ {accounting, tax, e-invoice}
          AND confidence < 0.70 → escalate:true, return
 ↓
 [3] retrieve context         KB JSONL search (40 Q&A pairs)
                              + LightRAG hybrid (knowledge.db)
                              + HiveMind semantic (hivemind.db, fire-and-forget)
 ↓
 [4] draft answer (Croatian)  tier-2 local model (Qwen/MLX)
 ↓
 [5] anti-hallucination gate  mini-verifier.js
      ⮑ HALLUCINATION (cited file missing/empty) → escalate
      ⮑ DRIFT (file present but ambiguous/language mismatch) → reduce confidence
      ⮑ CONFIRMED (grounded) → boost confidence
      ⮑ SKIP (verifier offline) → neutral
 ↓
 [6] accounting validation    if classification ∈ {accounting, tax, e-invoice}:
      ⮑ re-verify vs domain-specific KB (hr-tax-fiscalization, hr-accounting sources)
      ⮑ if no domain KB → escalate
      ⮑ if HALLUCINATION or CONFIRMED < 0.70 → escalate
 ↓
 [7] confidence floor gate    belowAccountingFloor(cls, confidence)
      ⮑ if acct/tax/e-invoice AND confidence < 0.70 → escalate:true
      (same helper used on BOTH cache path [L139] and pipeline path [L258])
 ↓
 [8] cost log + return        {answer, confidence, evidence_paths,
                               classification, severity, escalate, source}

```

**Output schema:**

```
{
  "answer": "<croatian text="">",
  "confidence": 0.0–1.0,
  "evidence_paths": ["<file cited="" paths="">"],
  "classification": "technical | accounting | tax | e-invoice | billing | onboarding | other",
  "severity": "P1 | P2 | P3 | P4",
  "escalate": boolean,
  "source": "cached | local | escalated"
}
</file></croatian>
```

## 4. Escalation Policy

**Hard rule (enforced in code):** `ACCOUNTING_CONF_FLOOR = 0.70`

Any answer with classification ∈ `{accounting, tax, e-invoice}` AND confidence &lt; 0.70 MUST escalate to a human specialist (or to `bilko-racunovodstvo-hr` / `bilko-porez-fiskalizacija-hr` personas in async workflow).

**Escalation triggers:**

- Verifier verdict = `HALLUCINATION` (fabricated citation)
- Unknown question (no KB match, no LightRAG answer)
- Accounting/tax claim: no domain-specific KB evidence OR domain-verified confidence &lt; 0.70
- Confidence &lt; 0.70 on cache hit for acct/tax/e-invoice (uses same `belowAccountingFloor()` helper as pipeline path)
- Severity P1 (critical production blocker)

**Single source of truth:** The `belowAccountingFloor(cls, confidence)` function (line 62) is called on BOTH the cache early-return path (line 139) and the post-step-6 gate (line 258) — guarantees no drift in threshold logic.

## 5. Knowledge Base — 40 Source-Cited Q&amp;A Pairs

**Storage (current):** JSONL files read directly by orchestrator; ingestion into `lightrag/knowledge.db` is a pending enhancement.

- `~/system/reports/bilko-kb/hr-tax-fiscalization.jsonl` — 20 pairs (PDV rates/deadlines, HR-FISK 2.0/eRačun B2B mandatory 2026-01-01, OIB validation ISO 7064 MOD 11,10, FINA rejection reasons, B2G since 2019-07-01, UBL 2.1/EN 16931, EUR since 2023-01-01)
- `~/system/reports/bilko-kb/hr-accounting.jsonl` — 20 pairs (RRiF chart classes 0-7, GL journal on invoice lifecycle sent→paid, expense approve→paid, storno/cancel logic, tečaj lock, RDG/bilanca/trial balance, fiscal year 1.1.–31.12., audit trail 7y retention)

**Sources cited:**

- `~/business/ALAI-Holding-AS/products/Bilko/docs/regulatory/CROATIA-ERACUN.md` (authoritative HR doc, HIGH confidence)
- `~/business/ALAI-Holding-AS/products/Bilko/packages/domain-hr/src/tax/index.ts`
- `~/business/ALAI-Holding-AS/products/Bilko/packages/domain-hr/src/chart/index.ts` (RRiF CoA implementation)
- `~/business/ALAI-Holding-AS/products/Bilko/packages/domain-hr/src/fisk/index.ts` (OIB validateOIB)
- `~/business/ALAI-Holding-AS/products/Bilko/packages/domain-hr/src/filing/index.ts` (eRačun UBL 2.1 generation)
- `~/business/ALAI-Holding-AS/products/Bilko/docs/backend/BUSINESS-LOGIC.md`

Each entry has: `{q, a, source, confidence: "HIGH" | "MEDIUM", domain: "hr-tax-fiscalization" | "hr-accounting"}`.

**Confidence levels:**

- **HIGH** — direct from CROATIA-ERACUN.md or implemented code (domain-hr packages)
- **MEDIUM** — inferred deadlines or regulatory interpretations (recommend user confirm with Porezna uprava / FINA / certified accountant)

## 6. Integration with Bilko Backend

**Existing infrastructure (reused, not built for this):**

- `apps/api/src/routes/SupportTicketRoutes.kt` (437 lines) 
    - `support_tickets` table (org\_id, subject, description, status open/in\_progress/resolved/closed, priority P1–P4, created\_at, updated\_at)
    - `POST /support/tickets` — idempotency key `(org_id, request_id)`
    - `GET /admin/support/tickets`, `PATCH /admin/support/tickets/:id`
    - PII-guarded `context_bundle` field (errorCode, appRoute, planTier, locale — allowlist enforced server-side)
    - **Reserved field:** `triageJson` (line 62 comment: "for AI assistant V2") — this is the Phase 2 hook

**Phase 1 workflow:**

1. Staff create/triage ticket via `~/system/tools/support-ticket.js` (SLA P1–P4)
2. Call `node ~/system/tools/bilko-support-answer.js "<question>" '<contextbundlejson>'</contextbundlejson></question>`
3. Agent returns JSON with answer + evidence + classification + escalate flag
4. Human reviews, approves, sends to customer (via email / admin panel)

**Phase 2 workflow (planned, HELD):**

1. Customer opens in-app chat widget on `bilko.cloud`
2. Widget calls `POST /support/tickets` with `context_bundle`
3. Backend triggers `bilko-support-answer.js` (via internal job queue or sync call)
4. Agent output written to `triageJson` field: `{suggestedAnswer, classification, severity, escalate, confidence, evidencePaths}`
5. Render in UI: if `escalate:false` AND `confidence >= 0.70` → show AI answer with citation; else → "Eskaliran na podršku, očekujte odgovor u <sla>"</sla>
6. Guardrails: PII allowlist (already enforced), prompt-injection guard (add Securion layer), scope validation (no cross-org queries)

**Security note:** Phase 2 requires Securion/Parisa Tabriz security review before live deployment (MC follow-up task).

## 7. Product Bugs Found by Experts (During KB Build)

The 2 HR accounting experts found **8 Bilko product/documentation defects** while authoring the knowledge base:

<table id="bkmrk-mcseverityissue-%23104"><thead><tr><th>MC</th><th>Severity</th><th>Issue</th></tr></thead><tbody><tr><td>\#104441</td><td>P3</td><td>Bilko MVP GL: only 1 D/P per transaction → PDV not split to konto 450 Obveze za PDV (tracking via reports only, not GL)</td></tr><tr><td>\#104446</td><td>P3</td><td>OIB validation: CROATIA-ERACUN.md missing ISO 7064 MOD 11,10 detail (in code, not doc)</td></tr><tr><td>\#104447</td><td>P2</td><td>CoA setup friction: not available in wizard, only post-onboarding in Settings (USER-ONBOARDING.md gap)</td></tr><tr><td>\#104442</td><td>P3</td><td>Storno logic: cannot cancel `paid` invoices (needs credit note, not auto-generated in MVP)</td></tr><tr><td>\#104448</td><td>P4</td><td>BUSINESS-LOGIC.md: HR PDV table lists only 25%/13%, omits 5%/0% (available via free input, just not in doc table)</td></tr><tr><td colspan="3">*3 additional minor doc inconsistencies logged*</td></tr></tbody></table>

These are now tracked separately and do not block the support agent (KB answers work around them with "Bilko MVP limitation" disclaimers where needed).

## 8. Runbook — How to Invoke

**CLI:**

```
node ~/system/tools/bilko-support-answer.js "<question>" '<contextBundleJSON>'

```

**Example:**

```
node ~/system/tools/bilko-support-answer.js \
  "Koja je stopa PDV-a na knjige u Hrvatskoj?" \
  '{"country":"HR","planTier":"PRO","locale":"hr-HR"}'

```

**Output (JSON to stdout):**

```
{
  "answer": "Stopa PDV-a od 5% primjenjuje se na knjige...",
  "confidence": 0.78,
  "evidence_paths": ["/Users/makinja/.../CROATIA-ERACUN.md"],
  "classification": "tax",
  "severity": "P3",
  "escalate": false,
  "source": "local"
}

```

**Module API:**

```
const { answer } = require('~/system/tools/bilko-support-answer');
const result = await answer(question, contextBundle);

```

**Cost tracking (automatic):** Each call logs to `~/system/tools/cost-tracker.js` with `{source: 'bilko-support-agent', backend: 'ollama', model: '...', cost_usd: 0, duration_ms, metadata}`.

## 9. Open Follow-Ups

<table id="bkmrk-mctaskowner-%23104432p"><thead><tr><th>MC</th><th>Task</th><th>Owner</th></tr></thead><tbody><tr><td>\#104432</td><td>**Proveo UAT** — end-to-end verification: empty-KB degradation, offline-model fallback, severity classification coverage, schema validation, real ticket replay</td><td>Proveo / Angie Jones</td></tr><tr><td>\#104430</td><td>**Phase 1 deployment** — staff onboarding, `support-ticket.js` workflow integration, SLA monitoring</td><td>FlowForge / Kelsey Hightower</td></tr><tr><td>\#104431</td><td>**Phase 2 customer-facing widget** — frontend component, POST hook, guardrails (HELD pending Securion)</td><td>Vizu / Brad Frost + Securion / Parisa Tabriz</td></tr><tr><td>(backlog)</td><td>KB ingestion into `lightrag/knowledge.db` (enhancement: replace JSONL direct-read with Neo4j-backed hybrid retrieval)</td><td>AgentForge / Chip Huyen</td></tr><tr><td>(backlog)</td><td>Securion review: confidence thresholds, cache-of-escalated-answers audit, PII leak surface, prompt-injection tests</td><td>Securion / Parisa Tabriz</td></tr></tbody></table>

## 10. Verification Evidence

**MC #104429 verdict:** `PASS` (2026-06-28, john verified)  
**Method:** Company-Mesh (CodeCraft built, John verified via real CLI runs)  
**Evidence:** `~/system/evidence/104429/verdict.json`

**Verified behaviors:**

- Tax Q 'PDV na knjige' → 5% grounded (CROATIA-ERACUN.md + domain-hr/src/tax)
- Unknown Q → `escalate:true`, `source:escalated`
- Pipeline tax conf 0.62 → `escalate:true`
- Cache tax conf 0.6547 → `escalate:true` (floor enforced on cache path)
- Cache tax conf 1.0 → `escalate:false` (no over-escalation)
- `belowAccountingFloor` defined once (L62), called L139 (cache) + L258 (pipeline) — no threshold drift

**2 under-escalation defects caught &amp; fixed:** Sub-0.70 tax answers on both pipeline AND cache paths were initially missing the confidence floor check; John's independent runs caught these, and CodeCraft fixed them via the shared `belowAccountingFloor()` helper.

---

*This page is the deliverable for MC #104433 (ZAKON PLAN mandatory documentation task). Last updated: 2026-06-28.*

# Bilko HR — Inbound e-Invoice Reject + Monthly Rejection eIzvještavanje (MC #106149 / #106146)

*MC #106149 (reject action, M) + #106146 (monthly eIzvještavanje report, H, RED-ZONE HR tax/compliance). Coupled/parent #106145. Status as of 2026-07-21: built and tested in an isolated worktree, **NOT merged or pushed** — no PR opened yet. This page documents the built state for handoff / next-session continuation.*

## What was built

- **Reject action** on `received_einvoice` (inbound/AP e-invoice): transitions `processing_status` and records a Bilko-internal rejection reason code, reject timestamp, and eIzvještavanje transmission tracking fields.
- **`RejectionReportService`**: builds the monthly batch of rejected inbound e-invoices Bilko must report to Porezna uprava under čl.52 (rok: 20th of the month, for the prior calendar month — domain-gate CONFIRMED, web-verified via 6 convergent sources, distinct from the separate NN 151/2025 PDV-filing deadline change).

## IMPORTANT — N/U/O is Bilko's own taxonomy, NOT Porezna's

The domain gate (agent `bilko-porez-fiskalizacija-hr`) caught and corrected a first-draft modeling error before this shipped:

- Porezna's actual N/U/O are **three different eIzvještavanje REPORT TYPES**, not three rejection sub-reasons: 
    - **N** = Naplata — issuer reports collection/payment of an issued e-invoice (unrelated to rejection)
    - **U** = Usluge/Isporuke — paper-fallback reporting when an e-invoice couldn't be issued (unrelated to rejection)
    - **O** = Odbijanje — recipient reports a REJECTED inbound e-invoice under čl.52. This is the only one relevant to Bilko's use case.
- Every row Bilko reports to Porezna is type **O** — there is no per-rejection N/U/O choice on the Porezna side.
- Fix applied: `received_einvoice.reason_code` is documented (Kotlin KDoc + V140 SQL comment) as **Bilko's own internal rejection-reason taxonomy** for the UI/audit trail (satisfying čl.52 st.1's "obrazloženo"/justified requirement), reusing the N/U/O letters only for DB/product convenience — **not** a claim of matching Porezna's N/U/O.
- `RejectionReportService.RECORD_TYPE` is hardcoded to `"O"` and is **never** derived from `received_einvoice.reason_code`. Tested explicitly (`RejectionReportIntegrationTest` test 5: `recordType == "O"` while `bilkoReasonCode == "U"`, asserted as two independently-preserved fields).

Full domain-gate detail: `~/system/evidence/106149/domain-gate-verdict.md`

## Schema

<table id="bkmrk-migrationcontents-v1"><tbody><tr><th>Migration</th><th>Contents</th></tr><tr><td>`V140__received_einvoice_reject_reason.sql`</td><td>Adds to `received_einvoice`: `reason_code CHAR(1) CHECK IN ('N','U','O')` (Bilko-internal taxonomy, see above), `rejected_at`, `report_transmission_status CHECK IN ('pending','sent')`, `report_transmitted_at`, `report_period`. Does not edit V139.</td></tr><tr><td>`V141__rejection_report_rls_bypass.sql`</td><td>Two `bilko_auth` `SECURITY DEFINER` functions — `find_rejected_einvoices_for_period`, `mark_einvoice_report_transmission` — plus `GRANT SELECT, UPDATE ON TABLE public.received_einvoice TO bilko_admin`. Same ADR-017 Pattern B shape as V139's own `find_org_by_registration_number`.</td></tr></tbody></table>

## Lessons — RLS/SECURITY DEFINER pattern has now recurred 6x (V33/V39/V100/V111/V139/V141)

Two real bugs were caught live by the Testcontainers integration test (real Postgres + full V1..V141 Flyway chain, no SchemaUtils/mocks) during this build — not assumed, not pre-empted from memory:

1. **Cross-org RLS blind spot.** `RejectionReportService`'s monthly batch query initially used a raw Exposed `transaction{}` against `received_einvoice` (FORCE ROW LEVEL SECURITY, V139) with no `app.current_org_id` set. This silently returned ZERO rows for every org, every time — not an error, because RLS here is fail-closed-permissive: the query "succeeds" with an empty result set. Fixed via V141 SECURITY DEFINER functions.
2. **SECURITY DEFINER without table GRANT.** After adding the SECURITY DEFINER functions, they still failed with "permission denied for table received\_einvoice" — `bilko_admin` has BYPASSRLS (V30\_1/V32) but is not superuser and never received an explicit GRANT on this specific table (V139 only granted `bilko_app`). Fixed by adding the GRANT in V141, following the exact precedent already set by V33/V39/V100/V111 for other tables `bilko_admin` needed to read via SECURITY DEFINER functions.

**Callout for future FORCE-RLS table + batch/admin-query work:** any new table with FORCE ROW LEVEL SECURITY that needs a cross-org admin/batch read path will hit both of these unless it (a) goes through a SECURITY DEFINER function from the start, and (b) that function's owning role has an explicit table GRANT. This is now a 6-occurrence pattern — worth turning into a migration checklist item or lint rule rather than re-discovering it per-feature.

## What's explicitly NOT done

- **Actual HTTP submission to Porezna is not implemented.** `transmitToPorezna()` is a clearly-marked TODO stub that always returns `false` (even with `SVERACUN_HR_LIVE=true`), with a loud `log.warn` explaining why. `submitMonthlyReport()` therefore never falsely claims "sent".
- **Reason:** payload/schema for the real eIzvještavanje submission is not confirmed at field level. `https://porezna-uprava.gov.hr/fiskalizacija/api/dokumenti/157` returns HTTP 200 but serves a client-rendered SPA shell ("Naslovna"), not the raw document body — the literal field-level schema needs either human/browser (JS-rendering) access to that page, or the FINA/Porezna eIzvještavanje XSD.
- **No cron wiring yet.** The `POST /internal/compliance/rejection-report` endpoint is not wired to an active ACA scheduled trigger in this task — same "safe to deploy inert" posture as the existing send-reminders sibling endpoint. FlowForge/John to wire per the KDoc go-live note.
- **No UI.** API-only, M-tier task, out of scope per the original brief.

## Verification performed

- `./gradlew compileKotlin compileTestKotlin` — clean.
- `RejectionReportIntegrationTest` (new, Testcontainers postgres:16-alpine, real V1..V141 Flyway chain, no mocks): 8/8 passed. Covers reject persistence + status transition, no-op same-reason re-reject vs tracked different-reason correction, org isolation both directions, rejecting an already-accepted row throws, report payload recordType=O with correct bilkoReasonCode, non-rejected rows excluded, transmission state null→pending (never falsely 'sent'), report\_period stamping, and idempotency (a row already 'sent' for a period is not re-transmitted).
- Regression: pre-existing `ReceivedEInvoiceIntegrationTest` (MC #106162) re-run after V140/V141/Tables.kt changes — still 0 failures.
- Full suite: `./gradlew test` — 1808 tests, 25 failed, all 25 isolated to 3 unrelated pre-existing classes (root cause: `SVERACUN_SENDER_VAT not configured`, a local-env config gap in outbound sveRacun e-invoicing, grep-verified unrelated to ReceivedEInvoice\*/RejectionReport\*/ComplianceCron\*).

## Branch / merge status

- Branch: `feat/task-106149-reject-report`, commit `faf3aa78`.
- Based on: `feat/task-106162-inbound-einvoice-persist`, commit `06812347` (V139 `received_einvoice` table + persist).
- **Neither branch is merged or pushed to azdo main. No PR opened yet.**
- Built in isolated worktree `.claude/worktrees/codecraft-106149` — the main Bilko working tree was left untouched (it had unrelated in-flight work on `feat/task-106147`).

## Next steps

1. Human/browser session to pull field-level schema from `porezna-uprava.gov.hr/fiskalizacija/api/dokumenti/157` (or FINA XSD) before implementing real `transmitToPorezna()`.
2. Proveo end-to-end verification (reject → report, real evidence).
3. Open PR: `feat/task-106162-inbound-einvoice-persist` → main, then `feat/task-106149-reject-report` → main (or squash/rebase as one PR chain).
4. FlowForge: wire cron trigger for `POST /internal/compliance/rejection-report`.
5. `mc.js ready` for #106149/#106146 after Proveo pass.

## Source evidence

- `~/system/evidence/106149/build-verdict.md` — full build verdict (CodeCraft, agent id `codecraft-106149`, PASS)
- `~/system/evidence/106149/domain-gate-verdict.md` — domain/legal correction (agent `bilko-porez-fiskalizacija-hr`, CORRECTION NEEDED on Q1, applied)

# MC #106259 — Bilko Admin Portal Contract Fix

**MC #106259** — fix for 4 contract defects found by Angie Jones' platform\_admin UAT catalog (**MC #106258**). Backend DTOs, frontend types/guards, and one missing route were brought back in sync so the admin portal stops crashing and stops showing wrong data.

## Defects fixed

<table id="bkmrk-%23severitydefectfix-1"><thead><tr><th>\#</th><th>Severity</th><th>Defect</th><th>Fix</th></tr></thead><tbody><tr><td>1</td><td>H</td><td>`GET /admin/dashboard` returned **404** — no handler existed anywhere server-side despite being called on first admin page load and documented in the OpenAPI spec. Likely the first thing CEO saw fail on `/admin`.</td><td>Implemented in `AdminPortalRoutes.kt` (`get("/dashboard")` inside the existing `route("/admin")` block, same `requirePlatformAdmin()` gate as every other admin route). `AdminOrgService.getDashboardStats()` added with real DB aggregates: `totalOrgs` (COUNT non-deleted orgs), `activeTrials` (BASIC plan + trial not expired), `recentAudit` (audit\_log LEFT JOIN users, DESC limit 10), `mrr` (explicit `0.0`, see limitation below).</td></tr><tr><td>2</td><td>H</td><td>`/admin/orgs/{id}` detail page **crashed on every org**. Backend `OrgDetail` DTO never set `mrr`, so Jackson omitted the key entirely; frontend called `Intl.NumberFormat().format(org.mrr)` → `TypeError: Cannot convert undefined to a number`, tripping the global Next.js error boundary. This is almost certainly the "admin stranice puca" CEO reported.</td><td>`OrgDetail` DTO gained `mrr: Double` (always explicit, never omitted), plus `vatMethod`/`trialStatus`. Frontend defense-in-depth: all three MRR render sites (`orgs/[id]/page.tsx`, `orgs/page.tsx`, admin dashboard `page.tsx`) now guard `== null` before formatting, not just the one crash site.</td></tr><tr><td>3</td><td>M</td><td>`OrgListItem` (the `/admin/orgs` list) was missing `vatMethod`, `trialStatus`, `userCount`, `mrr` even though the frontend TS interface declared them as required. Effect: VAT Method column always "—", Users column always blank, and every org — trial or paid — rendered with the green "paid" badge since `trialBadge(undefined)` silently fell through.</td><td>`OrgListItem` gained all four fields. `trialStatus` derived via new `resolveTrialStatus(planTier, trialEndsAt)` (read-model projection only — does not touch `TrialGatePlugin`/`TrialService` enforcement). `userCount` is one batched query per page (grouped/counted in one round-trip), not N+1 per row.</td></tr><tr><td>4</td><td>L</td><td>Pagination/total count silently broken. Backend has always returned nested `{data, meta:{total,page,perPage,totalPages}}`; the frontend `AdminOrgsListResponse`/`AdminOrgUsersResponse` TS types declared a **flat** shape (`{data,total,...}`). Result: header count never rendered, and pagination controls would silently never appear once org count exceeded the 50-per-page limit (untriggered today at 9 orgs, but a live landmine).</td><td>Frontend types changed to nest under `meta: AdminOrgsPaginationMeta` (chosen over flattening the backend, to avoid touching other `PaginatedResponse` consumers). Call sites in `orgs/page.tsx` updated to read `data.meta.total` / `data.meta.totalPages`.</td></tr></tbody></table>

## Branch / commit

- Repo: `Bilko` (canonical remote `azdo`)
- Branch: `feat/106259-admin-contract-fix`, created off `azdo/main` at `cb3746d6`
- Commit: `5513d0e6339864b31c2d3507ca0219ede5b052ba` — pushed to `azdo`, verified on remote via `git ls-remote`
- 8 files changed: `AdminPortalRoutes.kt`, `AdminOrgService.kt`, `AdminPortalRoutesHttpIntegrationTest.kt` (new tests T16–T19b), `orgs/[id]/page.tsx`, `orgs/page.tsx`, admin dashboard `page.tsx`, `lib/api.ts`, and a new frontend test file `admin-contract-106259.test.tsx`

## Test results

- **Backend**: `AdminPortalRoutesHttpIntegrationTest` — **23/23 passed** (9 new tests T16–T19b, one per fix + auth-gate coverage, plus 14 pre-existing T1–T15 unmodified, zero regressions). Sibling admin suites unmodified and green: `AdminPortalRoutesP2Test` 10/10, `AdminRoutesHttpIntegrationTest` 6/6.
- **Frontend**: new `admin-contract-106259.test.tsx` — **9/9 passed** (React Testing Library, real rendered component tree — MRR undefined/null guards, real-number formatting, vatMethod/trialStatus/userCount render, nested `meta.total`/`meta.totalPages` reads, pagination-controls visibility). Full production `next build` succeeds (65/65 routes), `tsc --noEmit` clean, pre-push `turbo run type-check` across all 12 workspace packages green.
- **Independent verification (Angie Jones / Proveo)** — read code directly in the isolated worktree at commit `5513d0e6` (did not trust the builder's self-report), re-ran backend integration tests and frontend Vitest herself, and diffed the commit against base to confirm scope boundaries. **Verdict: PASS** on all 4 items. Confirmed independently via her own grep that no aggregate MRR computation exists anywhere in the codebase, and via direct diff that zero auth files were touched.

## Known limitation

`mrr` is returned as an explicit `0.0` everywhere (dashboard, org detail, org list) — **not fabricated, but not real**. There is no Stripe MRR aggregation anywhere in the Bilko codebase today: `stripeSubscriptionId` is stored per-org but only ever resolved for trial-gate checks and billing/webhook flows, never rolled up into an aggregate revenue figure. Wiring a real MRR source (either a live Stripe API call per org, or a synced `mrr_amount` column updated via webhook) is separate, larger follow-on work. Flagged in code comments on the 3 DTOs (`OrgDetail`, `OrgListItem`, dashboard stats) for whoever picks that up next.

## Deliberately not touched

- `AdminAuthPlugin.kt`, `JwtService.kt`, `Authentication.kt`, and the `bilko-jwt` verifier — confirmed untouched by direct diff (`git diff cb3746d6 5513d0e6 --name-only`, zero overlap). Angie's original catalog established that the `/admin/orgs` 401 CEO/UAT saw was a stale/expired access-token test artefact, not a server-side auth defect — nothing in the auth stack needed fixing.
- `/admin/users` 405 — by design, only a `POST` handler exists; no frontend page calls `GET /admin/users`. Not a bug.
- "Report error" support-ticket button — confirmed working live during the original catalog investigation (real `POST /support/tickets` → 201).
- Flyway V146 warning — not reproducible; DB and deployed image agreed on schema version at investigation time.

## Follow-on: deploy is separate

This ticket covers **code + test correctness only** (worktree/local verification). PR → CI → merge to `azdo/main` → stage/demo promotion is a separate follow-on step, not yet done as of this page. Do not treat this fix as live on stage/demo until that pipeline runs and is verified (curl/browser check on the actual environment, per ZAKON PI2).

## Evidence

- Defect catalog (spec): `~/system/evidence/106258/admin-defect-catalog.md`
- Build evidence: `~/system/evidence/106259/build-evidence.md`
- Independent verify (PASS): `~/system/evidence/106259/angie-verify-2026-07-24.md`

# Deep-Link Redirects Survive Login — Mechanism, Consumers, Open Gaps (MC #106384)

# Deep-Link Redirects Survive Login — Mechanism, Consumers, and Open Gaps

**MC:** #106384 | **Status:** LIVE on azdo/main (merge commit `480d95ca`, PR 272) | **Verified against:** `azdo/main` @ `50e4223a` (2026-07-28) | **Independent peer-verify verdict:** PARTIAL (angie-106384-peer) | **Last updated:** 2026-07-28

**One-line summary:** a user hitting a private URL while logged out is now returned to that exact URL after signing in — except on one landing (an already-open bug, #106423) — where before this fix the `redirect` parameter was written by middleware and read by nothing, so every login silently discarded it.

---

## 1. The problem this replaced

Before this fix there were two independent defects, both required to explain the reported symptom (CEO clicks an emailed link like `/expenses/new?type=purchase`, has to log in, lands on `/dashboard` instead):

1. **Half 1** — `middleware.ts` built the login-redirect URL from the request **pathname only**. The query string was silently dropped before the user ever saw the login screen.
2. **Half 2** — even where a `redirect` parameter did survive, **nothing in the app read it**. The login success handler hardcoded `router.replace('/dashboard')`. A parameter that is written and never read is a control that renders and does nothing — the same class of defect as the dead filters/dead locale switcher this audit program was raised to find.

Fixing only those two would still not have worked: a real Entra sign-in does not return to the URL the user was on at all (see §2). The shipped fix (PR 272, two witness rounds + one independent post-merge peer-verify) addresses five loss points end to end. This page documents the mechanism as it exists in the code today, not as originally scoped.

---

## 2. End-to-end mechanism

```

GET /expenses/new?type=purchase                      (unauthenticated)
  → middleware.ts:93  builds /login?redirect=%2Fexpenses%2Fnew%3Ftype%3Dpurchase
                      (carries PATHNAME + SEARCH — this is the Half-1 fix)
  → user clicks "Sign in with Microsoft"
  → use-entra-auth.ts:214  signInWithMicrosoft() reads the CURRENT ?redirect= param
                      and stashes it in sessionStorage — the LAST moment it still exists
  → instance.loginRedirect() hands control to Entra
  → https://<ciam>/authorize?...&redirect_uri=https://app.bilko.cloud&response_mode=fragment
                      (bare origin, no path, no query — see §2.1)
  → back to https://app.bilko.cloud/#code=...
                      window.location.search is EMPTY here — the query param is gone for good
  → use-entra-auth.ts:170  handleEntraLogin() calls resolvePostLoginPath(...),
                      which falls through the (now-empty) query param to the STASH
  → router.replace('/expenses/new?type=purchase')   ← the deep link, recovered
```

### 2.1 Why the query param cannot survive the Entra round trip

MSAL is configured (`msal-config.ts`) with `redirectUri` defaulting to the bare page origin, `navigateToLoginRequestUrl: false`, and CIAM returns the auth code in the URL **fragment** (`response_mode=fragment`), not the query. This was measured live, credential-free, against production: the authorize request the app actually sends to Entra carries no path and no query, and the browser lands back on `https://app.bilko.cloud/#code=...` with `window.location.search === ''`. Any code that reads only `window.location.search` on the way back — which is exactly what the first version of this fix did — will always fall through to the dashboard fallback, regardless of how carefully the query param was carried on the way *in*. This was the fifth loss point found in witness round 1 and is why the stash (§3) exists at all.

---

## 3. The stash — surviving the round trip

`apps/web/lib/safe-redirect.ts` defines the mechanism:

<table id="bkmrk-stash-table"> <thead><tr><th>Property</th><th>Value</th></tr></thead> <tbody> <tr><td>Storage key</td><td>`bilko_post_login_redirect` (`STASH_KEY`, safe-redirect.ts:92)</td></tr> <tr><td>Storage mechanism</td><td>`sessionStorage`, not `localStorage` — survives a full-page redirect in the same tab, dies with the tab, matches MSAL's own `cacheLocation` (also sessionStorage, chosen for XSS reasons)</td></tr> <tr><td>Written</td><td>`stashRedirectTarget()` — one call site, `use-entra-auth.ts:214`, inside `signInWithMicrosoft()`, immediately before `loginRedirect()`</td></tr> <tr><td>TTL</td><td>10 minutes (`STASH_TTL_MS`, safe-redirect.ts:96) — long enough for a sign-in, short enough that a stale value cannot hijack an unrelated later login in the same tab</td></tr> <tr><td>Sanitized</td><td>on write (invalid target is never stored) AND on read (a stored value is not trusted more than a URL param just because the app put it there)</td></tr> </tbody></table>

### 3.1 Why the read is deliberately non-consuming

`readStashedRedirectTarget()` (safe-redirect.ts:129) does **not** delete the key when it reads it. This is deliberate and load-bearing, not an oversight: there are two consumers that both run on the way back from Entra (`handleEntraLogin` and the `auth-provider.tsx` safety net, §4), and **their execution order is not guaranteed**. If the first reader to run cleared the stash, the second would find nothing, fall through to `/dashboard`, and could overwrite the correct navigation the first reader just made. Both readers see the same answer instead. The value is retired by three other means: the next sign-in click (which unconditionally overwrites or clears it before any navigation happens), logout (§5), and the 10-minute TTL.

---

## 4. Two consumers of `resolvePostLoginPath` — one fixed, one still open

`resolvePostLoginPath(params)` = `readRedirectTarget(params) ?? readStashedRedirectTarget() ?? '/dashboard'` (param-first, see §5). There are exactly two call sites in the app that ever consulted the stash-fallback version. A third call site (`/demo`) was later re-scoped to **stop** using the fallback — see §4.1.

<table id="bkmrk-consumers-table"> <thead><tr><th>Consumer</th><th>File:line</th><th>Scoped to genuine post-Entra return?</th></tr></thead> <tbody> <tr><td>`handleEntraLogin`</td><td>`lib/msal/use-entra-auth.ts:170`</td><td>YES — this function only runs after MSAL fires `LOGIN_SUCCESS`</td></tr> <tr><td>Authenticated-landing safety net</td><td>`lib/auth-provider.tsx:109`</td><td>**NO — see §4.2, this is the open defect**</td></tr> </tbody></table>

### 4.1 `/demo` — re-scoped, does NOT use the stash

**Correction to the originating tickets:** the instant-demo page lives at `apps/web/app/(auth)/demo/page.tsx`, not under a `(dashboard)` route group as earlier ticket text said. The code is the source of truth here.

`demo/page.tsx` was the **sixth loss point**: it hardcoded `router.replace('/dashboard')`, so the journey *deep link → `/login?redirect=...` → instant sign-in → `/demo?...&redirect=...`* still landed on `/dashboard` even once the Entra path was fixed, because the cross-domain hop to `/demo` forwards the whole query string and the page just wasn't reading it.

The fix (demo/page.tsx:190) is **not** `resolvePostLoginPath(searchParams)` — it is `readRedirectTarget(searchParams) ?? DEFAULT_POST_LOGIN_PATH`, i.e. it reads **only its own query params, never the stash**. This distinction was found the hard way: a first attempt used the stash-fallback version and a witness proved live that an abandoned sign-in leaves a target in the stash, and a later `/demo?country=HR` visit in the **same tab**, inside the 10-minute TTL, silently followed the old target instead of going to the demo the user just asked for — a journey where that version of the branch was worse than main. `/demo` has no Entra redirect of its own to survive, so it never needs the stash; it already carries the param whenever one exists.

### 4.2 OPEN DEFECT — the safety net was never re-scoped (MC #106423)

The independent post-merge peer-verify (angie-106384-peer, verdict PARTIAL) found that `/demo` was fixed on the surface where the stash-leak bug was *found*, but `auth-provider.tsx:90-114` — the "authenticated user landing on `/` or `/login`" safety net — is the **other** caller of `resolvePostLoginPath` and was **not** given the same treatment. It still falls back to the stash for **any** authenticated landing on `/` or `/login`, not only a genuine post-Entra return.

Proven by rendering the real `AuthProvider` component directly (not by reading the code): with an already-authenticated client state, a plain navigation to `/` with no query param, and a stash pre-seeded from an earlier sign-in, `router.replace` fired with the **stale stashed target** instead of leaving the user on `/`.

**Reachability, honestly scoped narrower than the original N1 finding:** because the stash is non-consuming by design (§3.1), even a fully **successful** sign-in leaves the target sitting in `sessionStorage` for up to 10 more minutes. There is no in-app link to bare `/` from inside the authenticated dashboard shell, so this requires the user to navigate to the site root directly (URL bar, bookmark, external link) within that window while a stash from their own earlier sign-in — completed or abandoned — is still live. Real and user-visible, but a specific navigation habit rather than the default customer-facing path `/demo` is.

**Why no test caught this:** no test in the entire suite renders `AuthProvider` or drives `handleEntraLogin` — flagged as a residual (R6) in the very first witness round and never closed. The fix that scoped `/demo` correctly (§4.1) left this sibling caller untouched because nothing exercises it.

**Status: OPEN, tracked as MC #106423.** The prescribed fix is the same one-line treatment `/demo` got: this effect should read only whether it is landing here as a genuine post-Entra return (empty search, code in the fragment) versus any other authenticated landing on `/` or `/login`, and only consult the stash for the former — plus a test that actually renders `AuthProvider`, so this class of gap stops being invisible to the suite.

---

## 5. Why the cleanup lives in `authStore.logout()`, not `signOutFromEntra`

**This is the single most useful fact on this page for whoever touches this code next.**

`use-entra-auth.ts` defines and exports a function called `signOutFromEntra` (line 250) which does the "correct" full teardown: it calls the real logout, clears the marker cookie, clears the locale choice, calls `clearStashedRedirectTarget()` (line 271), and finally calls MSAL's `logoutRedirect` to end the Entra session itself. Reading that function in isolation, it looks like the obvious place the stash gets cleaned up.

**It is not called anywhere.** Grepping `lib`, `app`, `components` for `signOutFromEntra` at `azdo/main` today returns exactly: the interface member declaration, the function definition, and the object literal that exports it from the hook. Zero call sites. The two logout paths a user can actually reach in this app are:

- `components/top-bar.tsx:203` — `await useAuthStore.getState().logout()`, the user-menu "Log out" button
- `components/DemoBannerWrapper.tsx` — "exit demo" also routes through the real `logout()`

Neither of those goes anywhere near `signOutFromEntra`. So while this fix was being built, the first version of the cleanup was written correctly and placed in `signOutFromEntra` — and reproduced, inside its own fix, the **exact defect class the fix exists to kill**: a control that reads correctly and has no consumer. The original bug was a `redirect` query param nobody read; the accidental recreation was a cleanup function nobody called.

**The actual fix:** `clearStashedRedirectTarget()` was moved into `lib/stores/auth-store.ts:233`, inside `logout()` itself — the one function both real logout paths in the app call through. That is confirmed reachable from both buttons. The lesson generalises past this one bug: **for any fix of this shape, enumerate every caller of the mechanism, including teardown** — not just the one the bug report happened to describe.

**What this means for `signOutFromEntra` today:** it is dead code on a cleanup path, tracked separately as **MC #106421**, still open. That ticket deliberately does *not* conclude "delete it" — it asks whether Bilko's real logout is *supposed* to also end the user's Microsoft/Entra session (the way `signOutFromEntra`'s `logoutRedirect` call would do) and currently does not, because nothing calls the function that would do it. If that's the case, the actual bug is that `signOutFromEntra` is **not wired up** — a possible security-relevant gap (Entra SSO session surviving a Bilko logout on a shared machine), not simply dead code to delete. That question is still open.

---

## 6. Param-first on the return leg — the decision, and its residual

`resolvePostLoginPath` checks the URL query param **before** the stash. This was deliberately re-examined (not just carried over) after a reviewer asked whether a fresh param should ever lose to an old stash, and the answer is: it depends which leg you're on.

- **The true post-Entra return** (bare origin, code in the fragment) has **no query at all** — `window.location.search` is empty (§2.1). The stash wins here by default; param-vs-stash does not even arise.
- **The contested case** is `/login?redirect=X` reached with a live `bilko_auth` cookie but no in-tab MSAL session — e.g. a link opened in a new tab, or a repeat visit later the same day. Here `X` was written seconds ago, by `middleware.ts:93` or `auth-provider.tsx:126`, from the path the user **just requested**. The stash, by construction, is older — written up to 10 minutes earlier, at the start of a *previous* sign-in.

**Param-first was kept** on the reasoning that preferring a 10-minute-old stash over a just-expressed navigation would generalise the exact bug found in the `/demo` case (§4.1) rather than fix anything.

**Residual, stated plainly:** this reasoning holds only while *every* writer of the `redirect` query param is writing genuine, current user intent. Today there are exactly two writers — `middleware.ts:93` and `auth-provider.tsx:126` — and both were re-checked and confirmed to derive the value from the path the user was actually just bounced from. If a future change adds a third writer of that param for some other purpose, or repurposes one of the two existing writers, param-first silently becomes the wrong rule on this leg. Re-verify this list before trusting the rule again.

---

## 7. What is NOT verified live

**The real browser round-trip through Entra — redirect\_uri, the fragment-mode code return, and sessionStorage surviving the cross-origin navigation to the CIAM host and back — has never been observed by us with a real, credentialed sign-in.** There are no Entra credentials in the agent environment this work was built and reviewed in.

What *was* observed live, credential-free, and should not be conflated with the above:

- The authorize request the app sends to Entra (redirect\_uri = bare origin, response\_mode = fragment) — captured from a real headless click on the "Sign in with Microsoft" button, no password entered.
- The mechanism the whole fix rests on — a value written to `sessionStorage` on `app.bilko.cloud`, invisible while the browser is on the CIAM host (that *is* the trap this fix exists to survive), then intact again back on `app.bilko.cloud` with an empty `window.location.search` — reproduced against production with a real navigation to the CIAM host and back.
- The `/demo` journey end to end, including the open-redirect guard surviving a real navigation decision, on a production build.

What remains genuinely unobserved: a **successfully authenticated** user's landing page after a real password/consent flow. That half of the original defect is verified in code and by component-level tests, not by watching it happen. Do not present this mechanism as fully live-verified — it is proven live up to the boundary Entra credentials impose, and proven in code beyond that boundary.

---

## 8. Related open tickets

<table id="bkmrk-related-tickets"> <thead><tr><th>MC #</th><th>Status</th><th>What it is</th></tr></thead> <tbody> <tr><td>\#106423</td><td>OPEN</td><td>Safety-net caller (`auth-provider.tsx:109`) not re-scoped like `/demo` was — §4.2 above. Same bug class, one caller behind.</td></tr> <tr><td>\#106412</td><td>OPEN</td><td>Both the locale choice AND the redirect stash leak on session expiry / token-drop — four token-drop paths in `lib/api.ts` (lines 413, 476, 1028, 2835 on current main) do not clear either. Note: traced from the code, not yet live-verified in a browser; the stash's own writer-overwrite property (§3) makes its exposure narrower than the locale cookie's.</td></tr> <tr><td>\#106421</td><td>OPEN</td><td>Dead `signOutFromEntra` function — §5 above. Possibly a security gap (Entra session not ended on Bilko logout) rather than simply dead code; do not close as "delete" until that's checked.</td></tr> <tr><td>\#106422</td><td>OPEN</td><td>A separate, deliberately-not-merged-back-in question: whose language choice wins when an anonymous visitor's locale pick meets an org default on first login. Related to the same stash/cookie mechanism family but a distinct product decision, not a bug in this mechanism.</td></tr> </tbody></table>

---

## 9. Source

Verified against `azdo/main` @ `50e4223a` (2026-07-28), which includes the merge commit `480d95ca` (PR 272, MC #106384).

- `apps/web/middleware.ts`
- `apps/web/lib/safe-redirect.ts`
- `apps/web/lib/msal/use-entra-auth.ts`
- `apps/web/lib/msal/msal-config.ts`
- `apps/web/lib/auth-provider.tsx`
- `apps/web/lib/stores/auth-store.ts`
- `apps/web/app/(auth)/demo/page.tsx`
- `apps/web/components/top-bar.tsx`

Evidence: `~/system/evidence/106384/fix-verdict.md`, `witness-verdict.md` (two rounds), `peer-verify-verdict.md` (independent, post-merge, verdict PARTIAL).