# Backend

API reference, database, authentication, services, middleware

# API & Data

# API Reference

# Drop Backend API Reference

> Auto-generated from source code analysis. All file references are relative to `src/drop-app/src/`.

## Overview

Drop uses **Next.js App Router** API routes (`app/api/`). All responses use a consistent JSON envelope:

```json
{ "data": { ... } }                    // Success
{ "error": "code", "message": "...", "details": [...] }  // Error
```

Authentication is via **httpOnly cookie** (`drop_token`) containing a signed JWT (HS256, 24h expiry).

**Pass-Through Model:** Drop uses a PSD2 pass-through model — it NEVER holds customer money. There is no wallet, no balance, no top-up. User funds remain in their bank account at all times. Drop uses:

- **AISP** (Account Information Service Provider) — reads bank balance via Open Banking
- **PISP** (Payment Initiation Service Provider) — initiates transfers directly from user's bank account

The `bank_accounts.balance` field stores the last AISP-read balance from the user's real bank (cached for display) — NOT a Drop-held balance.

---

## Authentication

### POST /api/auth/register

Create a new user account.

| Field      | Source                           |
| ---------- | -------------------------------- |
| File       | `app/api/auth/register/route.ts` |
| Auth       | None                             |
| Rate Limit | 10 req/min per IP                |

**Request Body:**

| Field       | Type   | Required | Validation                                                          |
| ----------- | ------ | -------- | ------------------------------------------------------------------- |
| email       | string | Yes      | RFC-like regex, unique                                              |
| password    | string | Yes      | Min 8 chars, must contain letters + digits                          |
| firstName   | string | Yes      | `validateName()` — 1-100 chars, at least one letter, no HTML/script |
| lastName    | string | Yes      | Same as firstName                                                   |
| phone       | string | No       | International format `+XXXXXXXXXXXX` (8-15 digits)                  |
| dateOfBirth | string | Yes      | ISO date string, must be >= 18 years old                            |

**Success Response (201):**

```json
{
  "data": {
    "id": "usr_...",
    "email": "user@example.com",
    "firstName": "...",
    "lastName": "...",
    "dateOfBirth": "...",
    "kycStatus": "pending",
    "createdAt": "2026-..."
  }
}
```

**Error Responses:**

| Status | Code             | Condition                                               |
| ------ | ---------------- | ------------------------------------------------------- |
| 400    | bad_request      | Invalid JSON body                                       |
| 409    | conflict         | Email already registered                                |
| 422    | validation_error | Field validation failures (returned in `details` array) |
| 429    | rate_limited     | Too many requests                                       |

---

### POST /api/auth/login

Authenticate with email and password.

| Field      | Source                        |
| ---------- | ----------------------------- |
| File       | `app/api/auth/login/route.ts` |
| Auth       | None                          |
| Rate Limit | 10 req/min per IP             |

**Request Body:**

| Field    | Type   | Required |
| -------- | ------ | -------- |
| email    | string | Yes      |
| password | string | Yes      |

**Success Response (200):**

```json
{
  "data": {
    "id": "usr_...",
    "email": "...",
    "firstName": "...",
    "lastName": "...",
    "kycStatus": "approved"
  }
}
```

**Error Responses:**

| Status | Code         | Condition                 |
| ------ | ------------ | ------------------------- |
| 400    | bad_request  | Missing email or password |
| 401    | unauthorized | Invalid credentials       |
| 429    | rate_limited | Too many requests         |

---

### GET /api/auth/me

Get current authenticated user with bank accounts.

| Field | Source                     |
| ----- | -------------------------- |
| File  | `app/api/auth/me/route.ts` |
| Auth  | Required (cookie)          |

**Success Response (200):**

```json
{
  "data": {
    "id": "usr_...",
    "email": "...",
    "firstName": "...",
    "lastName": "...",
    "totalBalance": 58030.0,
    "bankAccounts": [
      {
        "id": "ba_1",
        "bankName": "DNB",
        "accountNumber": "1234.56.78901",
        "balance": 45230.0,
        "currency": "NOK",
        "isPrimary": true
      }
    ],
    "kycStatus": "approved",
    "createdAt": "..."
  }
}
```

---

### POST /api/auth/logout

Logout and revoke all sessions.

| Field | Source                         |
| ----- | ------------------------------ |
| File  | `app/api/auth/logout/route.ts` |
| Auth  | Required (cookie)              |

Calls `revokeAllSessions()` to invalidate all session records, then clears the auth cookie.

**Success Response (200):**

```json
{ "message": "Logged out" }
```

---

### POST /api/auth/refresh

Refresh the authentication token (issue new JWT, create new session record).

| Field | Source                          |
| ----- | ------------------------------- |
| File  | `app/api/auth/refresh/route.ts` |
| Auth  | Required (cookie)               |

**Success Response (200):**

```json
{
  "data": {
    "userId": "usr_...",
    "email": "...",
    "role": "user"
  }
}
```

---

## Transactions

### GET /api/transactions

List user's transactions with pagination and filtering.

| Field | Source                          |
| ----- | ------------------------------- |
| File  | `app/api/transactions/route.ts` |
| Auth  | Required                        |

**Query Parameters:**

| Param  | Type   | Default | Notes                                  |
| ------ | ------ | ------- | -------------------------------------- |
| page   | int    | 1       | Min 1                                  |
| limit  | int    | 20      | Min 1, Max 50                          |
| type   | string | -       | `remittance` or `qr_payment`           |
| status | string | -       | `processing`, `completed`, or `failed` |

**Success Response (200):**

```json
{
  "data": [
    {
      "id": "tx_rem_1",
      "type": "remittance",
      "status": "completed",
      "amount": -2000,
      "currency": "NOK",
      "recipientName": "Mama Jasmina",
      "createdAt": "..."
    }
  ],
  "pagination": { "page": 1, "limit": 20, "total": 3 }
}
```

Note: `amount` is negated in the response (always shown as outgoing).

---

### GET /api/transactions/[id]

Get single transaction details with exchange rate info.

| Field | Source                               |
| ----- | ------------------------------------ |
| File  | `app/api/transactions/[id]/route.ts` |
| Auth  | Required                             |

**Success Response (200):**

```json
{
  "data": {
    "id": "tx_rem_1",
    "type": "remittance",
    "status": "completed",
    "sendAmount": 2000,
    "sendCurrency": "NOK",
    "receiveAmount": 23400,
    "receiveCurrency": "RSD",
    "exchangeRate": 11.7,
    "fee": 10,
    "total": 2010,
    "recipientName": "Mama Jasmina",
    "recipientCountry": "Serbia",
    "createdAt": "...",
    "completedAt": "..."
  }
}
```

---

### GET /api/transactions/summary

Get transaction summary statistics (all-time and this month).

| Field | Source                                  |
| ----- | --------------------------------------- |
| File  | `app/api/transactions/summary/route.ts` |
| Auth  | Required                                |

**Success Response (200):**

```json
{
  "data": {
    "allTime": {
      "totalCount": 3,
      "totalSent": 5000,
      "totalPaid": 129,
      "remittanceCount": 2,
      "qrPaymentCount": 1
    },
    "thisMonth": { "..." }
  }
}
```

---

### POST /api/transactions/remittance

Create a remittance (international money transfer).

| Field      | Source                                     |
| ---------- | ------------------------------------------ |
| File       | `app/api/transactions/remittance/route.ts` |
| Auth       | Required                                   |
| Rate Limit | 10 req/min per IP                          |
| KYC        | Must be `approved`                         |

**Request Body:**

| Field         | Type   | Required | Validation                           |
| ------------- | ------ | -------- | ------------------------------------ |
| recipientId   | string | Yes      | Must belong to user                  |
| amount        | number | Yes      | 100-50,000 NOK, max 2 decimal places |
| currency      | string | No       | Defaults to `NOK`                    |
| bankAccountId | string | No       | Defaults to primary bank account     |

**Business Logic:**

1. Verify recipient belongs to user
2. Look up exchange rate for recipient's currency
3. Verify bank account exists and has sufficient balance
4. Fee: 0.5% of amount
5. Debit bank account (atomic transaction)
6. Create transaction record with status `processing`

**Success Response (201):**

```json
{
  "data": {
    "id": "tx_rem_...",
    "type": "remittance",
    "status": "processing",
    "sendAmount": 2000,
    "sendCurrency": "NOK",
    "receiveAmount": 23400,
    "receiveCurrency": "RSD",
    "exchangeRate": 11.7,
    "fee": 10,
    "feePercent": 0.5,
    "total": 2010,
    "recipientName": "...",
    "recipientCountry": "Serbia",
    "fromAccount": "DNB",
    "eta": "1-2 business days",
    "createdAt": "..."
  }
}
```

**Error Responses:**

| Status | Code                 | Condition                     |
| ------ | -------------------- | ----------------------------- |
| 400    | bad_request          | Missing/invalid fields        |
| 400    | no_bank_account      | No linked bank account        |
| 402    | insufficient_balance | Bank account balance too low  |
| 403    | kyc_required         | KYC not approved              |
| 404    | not_found            | Recipient not found           |
| 422    | validation_error     | Unsupported currency corridor |

---

### POST /api/transactions/qr-payment

Create a QR payment to a merchant.

| Field      | Source                                     |
| ---------- | ------------------------------------------ |
| File       | `app/api/transactions/qr-payment/route.ts` |
| Auth       | Required                                   |
| Rate Limit | 10 req/min per IP                          |

**Request Body:**

| Field      | Type   | Required | Validation                          |
| ---------- | ------ | -------- | ----------------------------------- |
| merchantId | string | Yes      | Must exist                          |
| amount     | number | Yes      | 1-100,000 NOK, max 2 decimal places |

**Business Logic:**

1. Verify merchant exists
2. Get user's primary bank account
3. Fee: 1% of amount
4. Debit bank account (atomic transaction)
5. Create transaction with status `completed` (instant)

**Success Response (201):**

```json
{
  "data": {
    "id": "tx_qr_...",
    "type": "qr_payment",
    "status": "completed",
    "amount": 129,
    "currency": "NOK",
    "fee": 1.29,
    "feePercent": 1,
    "merchantName": "Ahmetov Kebab",
    "merchantId": "mer_1",
    "fromAccount": "DNB",
    "createdAt": "..."
  }
}
```

---

## Recipients

### GET /api/recipients

List user's recipients with pagination.

| Field | Source                        |
| ----- | ----------------------------- |
| File  | `app/api/recipients/route.ts` |
| Auth  | Required                      |

**Query Parameters:** `page` (default 1), `limit` (default 20, max 50)

Bank account numbers are masked in response (e.g., `*****5678`).

**Supported Countries:** RS (Serbia), BA (Bosnia), PL (Poland), PK (Pakistan), TR (Turkey)

---

### POST /api/recipients

Add a new recipient.

| Field | Source                        |
| ----- | ----------------------------- |
| File  | `app/api/recipients/route.ts` |
| Auth  | Required                      |

**Request Body:**

| Field       | Type   | Required | Validation                |
| ----------- | ------ | -------- | ------------------------- |
| name        | string | Yes      | `validateName()`          |
| country     | string | Yes      | Must be in supported list |
| currency    | string | Yes      | -                         |
| bankAccount | string | Yes      | -                         |
| bankName    | string | No       | Sanitized to 200 chars    |

---

### DELETE /api/recipients/[id]

Delete a recipient.

| Field | Source                             |
| ----- | ---------------------------------- |
| File  | `app/api/recipients/[id]/route.ts` |
| Auth  | Required                           |

Returns 204 No Content on success. Returns 404 if recipient not found or not owned by user.

---

## Cards (FUTURE — feature-flagged, all flags default to `false`)

> **Note:** The entire Cards section is a FUTURE feature, gated behind feature flags. All card-related feature flags default to `false`. These endpoints exist in code but return 404 when flags are disabled. Cards require a card issuing partner (e.g., Stripe Issuing) before activation.

### GET /api/cards

List user's cards (excludes cancelled).

| Field | Source                   |
| ----- | ------------------------ |
| File  | `app/api/cards/route.ts` |
| Auth  | Required                 |

---

### POST /api/cards

Create a new card (virtual or physical).

| Field | Source                   |
| ----- | ------------------------ |
| File  | `app/api/cards/route.ts` |
| Auth  | Required                 |

**Request Body:**

| Field | Type   | Required | Notes                             |
| ----- | ------ | -------- | --------------------------------- |
| type  | string | No       | `virtual` (default) or `physical` |

---

### GET /api/cards/[id]

Get card details. Card number is masked (`---- ---- ---- XXXX`), CVV is hidden (`---`).

| Field | Source                        |
| ----- | ----------------------------- |
| File  | `app/api/cards/[id]/route.ts` |
| Auth  | Required                      |

PCI-DSS compliant: never exposes full card number or CVV.

---

### PATCH /api/cards/[id]

Freeze or unfreeze a card.

| Field | Source                        |
| ----- | ----------------------------- |
| File  | `app/api/cards/[id]/route.ts` |
| Auth  | Required                      |

**Request Body:** `{ "status": "active" | "frozen" }`

---

### DELETE /api/cards/[id]

Cancel a card (soft delete — sets status to `cancelled`).

| Field | Source                        |
| ----- | ----------------------------- |
| File  | `app/api/cards/[id]/route.ts` |
| Auth  | Required                      |

---

### POST /api/cards/[id]/physical

Order physical version of a virtual card.

| Field        | Source                                    |
| ------------ | ----------------------------------------- |
| File         | `app/api/cards/[id]/physical/route.ts`    |
| Auth         | Required                                  |
| Feature Flag | `physicalCards` (returns 404 if disabled) |

**Request Body:** `{ "address": "..." }` (min 10 chars)

---

### POST /api/cards/[id]/pin

Set PIN for a card.

| Field        | Source                              |
| ------------ | ----------------------------------- |
| File         | `app/api/cards/[id]/pin/route.ts`   |
| Auth         | Required                            |
| Feature Flag | `cardPin` (returns 404 if disabled) |

**Request Body:** `{ "pin": "1234" }` (exactly 4 digits)

PIN is hashed with bcrypt before storage.

---

### GET /api/cards/[id]/limits

Get spending limits for a card.

| Field        | Source                                     |
| ------------ | ------------------------------------------ |
| File         | `app/api/cards/[id]/limits/route.ts`       |
| Auth         | Required                                   |
| Feature Flag | `spendingLimits` (returns 404 if disabled) |

---

### PUT /api/cards/[id]/limits

Set a spending limit for a card.

| Field        | Source                                     |
| ------------ | ------------------------------------------ |
| File         | `app/api/cards/[id]/limits/route.ts`       |
| Auth         | Required                                   |
| Feature Flag | `spendingLimits` (returns 404 if disabled) |

**Request Body:**

| Field     | Type   | Required | Validation                                     |
| --------- | ------ | -------- | ---------------------------------------------- |
| limitType | string | Yes      | `daily`, `weekly`, `monthly`, or `transaction` |
| amount    | number | Yes      | Must be positive                               |

Replaces any existing limit of the same type.

---

## Exchange Rates

See also: [Currency Rates](CURRENCY-RATES.md) for provider source, cache/freshness behavior, fallback, and circuit-breaker verification.

### GET /api/rates

Get all persisted exchange rates from NOK. This endpoint reads `exchange_rates`; it does not call the external FX providers directly.

| Field      | Source                              |
| ---------- | ----------------------------------- |
| File       | `apps/drop-api/src/routes/rates.ts` |
| Auth       | None                                |
| Rate Limit | 120 req/min per IP                  |

**Success Response (200):**

```json
{
  "data": {
    "baseCurrency": "NOK",
    "rates": { "RSD": 10.17, "BAM": 0.17, "PLN": 0.374, "PKR": 26.5, "TRY": 3.39, "EUR": 0.087 },
    "updatedAt": "...",
    "stale": false
  }
}
```

---

### GET /api/rates/:currency

Get one persisted NOK → target-currency rate.

| Field      | Source                              |
| ---------- | ----------------------------------- |
| File       | `apps/drop-api/src/routes/rates.ts` |
| Auth       | None                                |
| Rate Limit | 120 req/min per IP                  |

Response includes `fee: 0.005` (0.5% remittance fee) and `stale` freshness status.

---

## Notifications

### GET /api/notifications

List all notifications for user.

| Field        | Source                             |
| ------------ | ---------------------------------- |
| File         | `app/api/notifications/route.ts`   |
| Auth         | Required                           |
| Feature Flag | `notifications` (default: enabled) |

---

### PATCH /api/notifications

Mark notifications as read.

| Field        | Source                           |
| ------------ | -------------------------------- |
| File         | `app/api/notifications/route.ts` |
| Auth         | Required                         |
| Feature Flag | `notifications`                  |

**Request Body:** `{ "notificationIds": ["noti_..."] }`

- Max 100 IDs per request
- IDs validated against format `^[a-z]+_[a-f0-9]{16}$`

---

## Settings

### GET /api/settings

Get user settings (creates defaults if none exist).

| Field | Source                      |
| ----- | --------------------------- |
| File  | `app/api/settings/route.ts` |
| Auth  | Required                    |

**Defaults:** currency=NOK, language=nb, pushEnabled=true, emailEnabled=true

---

### PATCH /api/settings

Update user settings.

| Field | Source                      |
| ----- | --------------------------- |
| File  | `app/api/settings/route.ts` |
| Auth  | Required                    |

**Request Body (all optional):**

| Field        | Type    | Validation                                                  |
| ------------ | ------- | ----------------------------------------------------------- |
| currency     | string  | Whitelist: EUR, USD, GBP, BAM, CHF, PLN, NOK, RSD, TRY, PKR |
| language     | string  | Whitelist: nb, en, bs, sq                                   |
| pushEnabled  | boolean | -                                                           |
| emailEnabled | boolean | -                                                           |

---

## Merchants

### POST /api/merchants/register

Register as a merchant.

| Field | Source                                |
| ----- | ------------------------------------- |
| File  | `app/api/merchants/register/route.ts` |
| Auth  | Required                              |

**Request Body:**

| Field        | Type   | Required | Validation               |
| ------------ | ------ | -------- | ------------------------ |
| businessName | string | Yes      | `validateName()`         |
| orgNumber    | string | Yes      | Exactly 9 digits, unique |
| address      | string | No       | Sanitized to 300 chars   |
| bankAccount  | string | Yes      | Payout account           |

Upgrades user role to `merchant`. Returns a QR code URI (`drop://pay/{merchantId}`).

---

### GET /api/merchants/dashboard

Get merchant dashboard stats.

| Field | Source                                 |
| ----- | -------------------------------------- |
| File  | `app/api/merchants/dashboard/route.ts` |
| Auth  | Required (merchant role)               |

**Query Parameters:** `period` — `today` (default), `week`, `month`

Returns: revenue, transactionCount, fees, netRevenue, nextPayout, payoutTime.

---

### GET /api/merchants/qr

Get merchant QR code data.

| Field | Source                          |
| ----- | ------------------------------- |
| File  | `app/api/merchants/qr/route.ts` |
| Auth  | Required (merchant role)        |

Returns: merchantId, businessName, qrValue (`drop://pay/{id}`), address.

---

### GET /api/merchants/transactions

List merchant's QR payment transactions with pagination.

| Field | Source                                    |
| ----- | ----------------------------------------- |
| File  | `app/api/merchants/transactions/route.ts` |
| Auth  | Required (merchant role)                  |

**Query Parameters:** `page`, `limit`

Customer names are partially anonymized (first name + last initial).

---

## GDPR & Compliance

### GET /api/user/data-export

Export all user data (GDPR right to data portability).

| Field | Source                              |
| ----- | ----------------------------------- |
| File  | `app/api/user/data-export/route.ts` |
| Auth  | Required                            |

Creates a `data_access_request` record with type `export` and status `completed`.

**Success Response (200):**

```json
{
  "data": {
    "user": {
      "id": "usr_...",
      "email": "...",
      "first_name": "...",
      "last_name": "...",
      "phone": "+47...",
      "date_of_birth": "1995-03-15",
      "kyc_status": "approved",
      "role": "user",
      "created_at": "..."
    },
    "transactions": [ {...}, {...} ],
    "recipients": [ {...}, {...} ],
    "bankAccounts": [ {...} ],
    "settings": { "currency": "NOK", "language": "nb", ... },
    "consents": [ {...}, {...} ]
  },
  "exportedAt": "2026-02-17T..."
}
```

---

### DELETE /api/user/account

Request account deletion (GDPR right to erasure).

| Field | Source                          |
| ----- | ------------------------------- |
| File  | `app/api/user/account/route.ts` |
| Auth  | Required                        |

**Behavior:**

- Soft-deletes user (sets `deleted_at` timestamp)
- Revokes all active sessions
- Creates `data_access_request` with type `erasure` and status `completed`
- **Important:** Data retained for 5 years per AML/KYC legal requirements (hvitvaskingsloven)

**Success Response (200):**

```json
{
  "message": "Account scheduled for deletion",
  "retentionNote": "Data retained for 5 years per AML requirements"
}
```

---

### GET /api/consents

List user's GDPR consents.

| Field | Source                      |
| ----- | --------------------------- |
| File  | `app/api/consents/route.ts` |
| Auth  | Required                    |

**Success Response (200):**

```json
{
  "data": [
    {
      "id": "con_...",
      "user_id": "usr_...",
      "consent_type": "terms",
      "granted": 1,
      "granted_at": "2026-02-17T...",
      "withdrawn_at": null,
      "ip_address": "192.0.2.1"
    }
  ]
}
```

---

### POST /api/consents

Grant or withdraw a consent.

| Field | Source                      |
| ----- | --------------------------- |
| File  | `app/api/consents/route.ts` |
| Auth  | Required                    |

**Request Body:**

| Field       | Type    | Required | Validation                                                                                |
| ----------- | ------- | -------- | ----------------------------------------------------------------------------------------- |
| consentType | string  | Yes      | Must be one of: `terms`, `privacy`, `marketing`, `cookies_analytics`, `cookies_marketing` |
| granted     | boolean | Yes      | `true` to grant, `false` to withdraw                                                      |

**Behavior:**

- If consent exists: updates `granted` field and sets either `granted_at` or `withdrawn_at`
- If consent doesn't exist: creates new consent record
- Records user's IP address with consent action

**Success Response (200 for update, 201 for new):**

```json
{
  "data": {
    "id": "con_...",
    "consent_type": "marketing",
    "granted": 1,
    "granted_at": "2026-02-17T...",
    "withdrawn_at": null,
    "ip_address": "192.0.2.1"
  }
}
```

**Error Responses:**

| Status | Code        | Condition                              |
| ------ | ----------- | -------------------------------------- |
| 400    | bad_request | Invalid consent type or missing fields |

---

### GET /api/complaints

List user's complaints.

| Field | Source                        |
| ----- | ----------------------------- |
| File  | `app/api/complaints/route.ts` |
| Auth  | Required                      |

**Query Parameters:**

| Param | Type | Default | Notes                   |
| ----- | ---- | ------- | ----------------------- |
| page  | int  | 1       | Pagination page number  |
| limit | int  | 10      | Items per page, max 100 |

**Success Response (200):**

```json
{
  "data": [
    {
      "id": "cmp_...",
      "category": "transaction",
      "subject": "Transaction delayed",
      "description": "My remittance to Serbia is delayed...",
      "status": "received",
      "resolution": null,
      "created_at": "2026-02-17T...",
      "resolved_at": null
    }
  ],
  "pagination": {
    "page": 1,
    "limit": 10,
    "total": 3,
    "totalPages": 1
  }
}
```

---

### POST /api/complaints

Submit a complaint (Finansavtaleloven §3-53 compliance).

| Field | Source                        |
| ----- | ----------------------------- |
| File  | `app/api/complaints/route.ts` |
| Auth  | Required                      |

**Request Body:**

| Field       | Type   | Required | Validation                                                                        |
| ----------- | ------ | -------- | --------------------------------------------------------------------------------- |
| category    | string | Yes      | Must be one of: `transaction`, `service`, `fees`, `privacy`, `technical`, `other` |
| subject     | string | Yes      | Max 200 chars, sanitized                                                          |
| description | string | Yes      | Max 2000 chars, sanitized                                                         |

**Success Response (201):**

```json
{
  "data": {
    "id": "cmp_...",
    "category": "fees",
    "subject": "High transfer fee",
    "description": "...",
    "status": "received",
    "created_at": "2026-02-17T..."
  },
  "commitmentNote": "We will review and respond to your complaint within 15 business days per Finansavtaleloven §3-53"
}
```

**Error Responses:**

| Status | Code        | Condition                        |
| ------ | ----------- | -------------------------------- |
| 400    | bad_request | Invalid category or empty fields |

---

### POST /api/transactions/disclosure

Get full transaction fee and exchange rate disclosure before initiating payment.

| Field | Source                                     |
| ----- | ------------------------------------------ |
| File  | `app/api/transactions/disclosure/route.ts` |
| Auth  | Required                                   |

**Request Body:**

| Field       | Type   | Required    | Notes                        |
| ----------- | ------ | ----------- | ---------------------------- |
| type        | string | Yes         | `remittance` or `qr_payment` |
| amount      | number | Yes         | Must be positive             |
| currency    | string | No          | Defaults to `NOK`            |
| recipientId | string | Conditional | Required for remittance      |

**Success Response (200):**

```json
{
  "amount": 2000,
  "fee": 10,
  "feePercentage": 0.5,
  "exchangeRate": 10.17,
  "receiveAmount": 20340,
  "receiveCurrency": "RSD",
  "estimatedDelivery": "1-2 business days",
  "totalCost": 2010
}
```

**Fee Calculation:**

- Remittance: 0.5% of amount
- QR payment: 1.0% of amount

**Delivery Time:**

- QR payment: "Instant"
- Remittance (EEA): "1-2 business days"
- Remittance (non-EEA): "2-4 business days"

---

### GET /api/transactions/[id]/receipt

Get transaction receipt with full details.

| Field | Source                                       |
| ----- | -------------------------------------------- |
| File  | `app/api/transactions/[id]/receipt/route.ts` |
| Auth  | Required                                     |

**Success Response (200):**

```json
{
  "data": {
    "transactionId": "tx_rem_1",
    "date": "2026-02-17T...",
    "type": "remittance",
    "amount": 2000,
    "currency": "NOK",
    "fee": 10,
    "exchangeRate": 10.17,
    "receiveAmount": 20340,
    "receiveCurrency": "RSD",
    "recipient": {
      "name": "Mama Jasmina",
      "country": "RS"
    },
    "reference": "tx_rem_1",
    "status": "completed",
    "estimatedCompletion": null,
    "completedAt": "2026-02-17T..."
  }
}
```

**Error Responses:**

| Status | Code      | Condition                                  |
| ------ | --------- | ------------------------------------------ |
| 404    | not_found | Transaction not found or not owned by user |

---

## Health Check

### GET /api/health

System health check (no auth required).

| Field | Source                    |
| ----- | ------------------------- |
| File  | `app/api/health/route.ts` |
| Auth  | None                      |

**Success Response (200):**

```json
{
  "status": "ok",
  "version": "0.1.0",
  "uptime": 3600,
  "db": "connected",
  "dbLatencyMs": 1,
  "timestamp": "..."
}
```

Returns 503 with `status: "error"` if database is unreachable.

# Authentication

# Drop Authentication System

> Sources: `src/drop-app/src/app/api/auth/bankid/`, `src/drop-api/src/lib/bankid.ts`, `src/drop-api/src/routes/auth.ts`

## Overview

Drop uses **BankID OIDC** as the sole authentication method. Email/password login has been removed to comply with PSD2/SCA requirements.

- **Auth method:** BankID OIDC (Norwegian eID)
- **JWT Algorithm:** HS256 (HMAC-SHA256), RS256 opt-in
- **Library:** `jose` (`SignJWT` / `jwtVerify`)
- **Token lifetime:** 24h (web cookie), 7d (mobile Bearer token)
- **Web cookie:** `drop_token` (httpOnly, secure, sameSite=strict)
- **Mobile:** Bearer token in Authorization header

### Phase 2 (planned)
- Vipps Login — same OIDC pattern, user dedup by `national_id_hash`
- Idura aggregator optional (single integration point for BankID + Vipps)

---

## Authentication Flow

### BankID Login (Web)

```
Browser                     Next.js BFF                   BankID OIDC
  |                            |                              |
  |  GET /api/auth/bankid      |                              |
  |--------------------------->|                              |
  |                            |  1. Rate limit check         |
  |                            |  2. Generate state + nonce   |
  |                            |  3. Set bankid_state cookie  |
  |  { redirectUrl }           |                              |
  |<---------------------------|                              |
  |                            |                              |
  |  Browser redirects to BankID authorize URL                |
  |---------------------------------------------------------->|
  |                            |                              |
  |  User authenticates with BankID                           |
  |                            |                              |
  |  BankID redirects to /api/auth/bankid/callback?code=&state=
  |<----------------------------------------------------------|
  |                            |                              |
  |  GET /callback?code&state  |                              |
  |--------------------------->|                              |
  |                            |  4. Verify state vs cookie   |
  |                            |  5. Exchange code for tokens |
  |                            |----------------------------->|
  |                            |  { id_token, access_token }  |
  |                            |<-----------------------------|
  |                            |  6. Verify ID token (JWKS)   |
  |                            |  7. Parse pid, verify age    |
  |                            |  8. Find/create user         |
  |                            |  9. Create session + cookie  |
  |  302 /dashboard            |                              |
  |<---------------------------|                              |
```

### BankID Login (Mobile)

```
Mobile App                  Hono API                      BankID OIDC
  |                            |                              |
  |  GET /v1/auth/bankid/initiate?platform=mobile             |
  |--------------------------->|                              |
  |  { redirectUrl, state }    |                              |
  |<---------------------------|                              |
  |                            |                              |
  |  Open BankID in secure browser (expo-web-browser)         |
  |---------------------------------------------------------->|
  |                            |                              |
  |  User authenticates with BankID                           |
  |                            |                              |
  |  Redirect to drop://auth/callback?code=&state=            |
  |<----------------------------------------------------------|
  |                            |                              |
  |  POST /v1/auth/bankid/callback                            |
  |  { code, state, platform }                                |
  |--------------------------->|                              |
  |                            |  1. Exchange code for tokens |
  |                            |----------------------------->|
  |                            |  { id_token }                |
  |                            |<-----------------------------|
  |                            |  2. Verify ID token (JWKS)   |
  |                            |  3. Parse pid, verify age    |
  |                            |  4. Find/create user         |
  |                            |  5. Create session           |
  |  { token, data }           |                              |
  |<---------------------------|                              |
  |                            |                              |
  |  Store token in AsyncStorage                              |
```

---

## User Creation

BankID login automatically creates user accounts:

1. **Parse pid** from BankID ID token (Norwegian national ID, 11 digits)
2. **Hash pid** with SHA-256 for storage (`national_id_hash` column)
3. **Check existing** user by `national_id_hash`
4. **If new:** Create user with:
   - `kyc_status = 'approved'` (BankID = verified identity)
   - `kyc_method = 'bankid'`
   - `auth_provider = 'bankid'`
   - `password_hash = 'EIDONLY'` (sentinel — no password auth)
5. **Age check:** Must be >= 18 (parsed from pid birthdate)

---

## JWT Structure

### Payload

```typescript
interface JwtPayload {
  userId: string;   // e.g., "usr_a1b2c3d4e5f6g7h8"
  email: string;    // e.g., "usr_xxx@bankid.drop.local"
  role: string;     // "user" or "merchant"
}
```

### Claims

| Claim | Value |
|-------|-------|
| `exp` | Current time + 24h (web) / 7d (mobile) |
| `iat` | Current time |
| `iss` | `drop-api` (Hono) / none (Next.js) |
| `aud` | `drop` (Hono) / none (Next.js) |

---

## Session Revocation

1. **On login:** `sessions` record created with SHA-256 hash of JWT
2. **On each request:** Verify session not revoked + not expired
3. **On logout:** All user sessions marked `revoked = 1`

---

## CSRF Protection

- **Web:** State parameter in BankID OIDC flow (stored in httpOnly cookie)
- **API:** Origin header validation against allowed origins
- **Mobile:** N/A (Bearer token, no cookies)

---

## Rate Limiting

| Endpoint | Limit |
|----------|-------|
| BankID initiate | 10/min per IP |
| BankID callback | 10/min per IP |
| Auth me/logout/refresh | No additional limit (auth required) |

---

## Authorization

### Role-Based Access

Two roles: `user` and `merchant`.

| Route | Auth | Role |
|-------|------|------|
| GET /auth/bankid/initiate | None | - |
| POST /auth/bankid/callback | None | - |
| GET /auth/me | Required | Any |
| POST /auth/logout | Required | Any |
| POST /auth/refresh | Required | Any |
| POST /merchants/register | Required | Any (upgrades to merchant) |
| GET /merchants/dashboard | Required | Merchant |

---

## Deprecated Endpoints

These endpoints return **410 Gone**:

| Endpoint | Replacement |
|----------|-------------|
| `POST /auth/login` | BankID OIDC flow |
| `POST /auth/register` | Automatic via BankID login |
| `POST /auth/verify-otp` | Not needed (BankID replaces OTP) |

---

## Environment Variables

### Required (Production)

```
BANKID_CLIENT_ID          # BankID OIDC client ID
BANKID_CLIENT_SECRET      # BankID OIDC client secret
BANKID_CALLBACK_URL       # Web callback URL (e.g., https://getdrop.no/api/auth/bankid/callback)
BANKID_CALLBACK_URL_MOBILE # Mobile deep link (e.g., drop://auth/callback)
JWT_SECRET                # JWT signing secret (min 32 chars)
```

### Optional

```
BANKID_AUTHORIZE_URL      # Default: BankID prod authorize endpoint
BANKID_TOKEN_URL          # Default: BankID prod token endpoint
BANKID_JWKS_URL           # Default: BankID prod JWKS endpoint
BANKID_ISSUER             # Default: BankID prod issuer
BANKID_MOCK=true          # Dev mode: mock OIDC flow (no real BankID needed)
JWT_ALGORITHM             # "HS256" (default) or "RS256"
JWT_EXPIRY                # Default: "24h"
```

---

## Merchant Flow

Merchants use the same BankID login as regular users. After logging in:

1. Navigate to merchant registration
2. Fill in business details (business name, org number, bank account)
3. `POST /merchants/register` with auth token
4. User role upgraded from `user` to `merchant`
5. Merchant dashboard becomes accessible

# Services & Middleware

# Services

# Drop External Services

> Source: `src/drop-app/src/lib/services/`

## Overview

Drop uses a **PSD2 pass-through model** — it never holds customer money. AISP reads bank balances via Open Banking, PISP initiates payments from the user's own bank account.

Drop integrates with external service providers. Each service has a different readiness level — see status tags below.

For backend FX-rate sourcing, see [Currency Rates](CURRENCY-RATES.md). Current `drop-api` source is configured for Norges Bank primary rates with ECB fallback; `/api/rates` reads the persisted `exchange_rates` table rather than calling providers directly.

> **Legend:** `[PRODUCTION]` = real SDK, production-ready. `[MOCK/DEV]` = mock only, NOT connected to real APIs. `[PLANNED]` = future roadmap. `[DEPRECATED]` = no longer the chosen provider.

Service mode is controlled by `NEXT_PUBLIC_SERVICE_MODE` env var (default: `mock`).

Source: `services/index.ts:21-30`

```typescript
export const config = {
  mode: (process.env.NEXT_PUBLIC_SERVICE_MODE || 'mock') as 'mock' | 'production',
  endpoints: {
    sumsub: process.env.SUMSUB_API_URL || 'https://api.sumsub.com',
  },
}
```

> **Note on Swan:** Swan was previously listed as the Open Banking provider but has been deprecated. The pass-through PSD2 model will use a different AISP/PISP provider (TBD).
>
> **Note on Stripe:** Card issuing is a future feature gated behind feature flags. No Stripe SDK is integrated — only a mock file exists.
>
> **Note on Vipps/Nets:** Sometimes mentioned in business discussions but have ZERO code in the codebase.

---

## Swan — Open Banking / PSD2 Provider [DEPRECATED]

> ⚠️ DEPRECATED: Swan is no longer the planned Open Banking provider. Mock code remains but will be removed.

**File:** `services/mock-swan.ts`
**Production docs:** https://docs.swan.io/
**Status:** DEPRECATED mock — no production integration, no contract, no API keys.

### Interfaces

| Interface         | Description                                  |
| ----------------- | -------------------------------------------- |
| `SwanAccount`     | Bank account with IBAN, BIC, balance, status |
| `SwanTransaction` | SEPA credit/debit with status tracking       |

### Functions

| Function           | Signature                                                  | Description                       |
| ------------------ | ---------------------------------------------------------- | --------------------------------- |
| `createAccount`    | `(userId) → SwanAccount`                                   | Create new bank account with IBAN |
| `getAccount`       | `(accountId) → SwanAccount \| null`                        | Retrieve account details          |
| `getBalance`       | `(accountId) → {available, pending}`                       | Get balance breakdown             |
| `initiateTransfer` | `({fromAccountId, toIban, amount, ...}) → SwanTransaction` | Initiate SEPA credit transfer     |
| `simulateIncoming` | `({toAccountId, amount, fromIban}) → SwanTransaction`      | Simulate incoming transfer        |
| `getTransactions`  | `(accountId, limit?) → SwanTransaction[]`                  | List recent transactions          |
| `onWebhook`        | `(callback) → void`                                        | Register webhook listener         |

### Mock Behavior

- 200-800ms simulated latency per call
- IBAN generated in `BA` format (Bosnia mock)
- Transfers start as `Pending`, settle to `Booked` after 2 seconds
- State persisted to `localStorage` (browser) or in-memory (server)
- `_testHelpers.reset()` clears all mock data

### Account Statuses

`Opened` | `Closing` | `Closed`

### Transaction Types

`SepaCredit` | `SepaDebit` | `CardTransaction`

### Transaction Statuses

`Pending` | `Booked` | `Rejected`

---

## Stripe — Card Issuing [MOCK/DEV]

> ⚠️ MOCK ONLY: No Stripe SDK installed. Mock file for UI development only.

**File:** `services/mock-stripe.ts`
**Production docs:** https://stripe.com/docs/issuing
**Status:** Mock implementation only — no real Stripe API calls, no SDK, no API keys.

### Interfaces

| Interface             | Description                                    |
| --------------------- | ---------------------------------------------- |
| `StripeCard`          | Card with type, brand, status, spending limits |
| `StripeCardDetails`   | Full card number, CVC, expiry (virtual only)   |
| `StripeAuthorization` | Card authorization with merchant info          |

### Functions

| Function                | Signature                                            | Description                          |
| ----------------------- | ---------------------------------------------------- | ------------------------------------ |
| `createVirtualCard`     | `({cardholderName, spendingLimit?}) → StripeCard`    | Issue virtual Visa card              |
| `orderPhysicalCard`     | `({cardholderName, shippingAddress}) → StripeCard`   | Order physical card                  |
| `getCardDetails`        | `(cardId) → StripeCardDetails`                       | Get full card details (virtual only) |
| `setCardStatus`         | `(cardId, active) → StripeCard`                      | Freeze/unfreeze card                 |
| `updateSpendingLimit`   | `(cardId, limit) → StripeCard`                       | Update spending limit                |
| `getCards`              | `() → StripeCard[]`                                  | List all cards                       |
| `simulateAuthorization` | `({cardId, amount, merchant}) → StripeAuthorization` | Simulate card purchase               |
| `getAuthorizations`     | `(cardId?) → StripeAuthorization[]`                  | List authorizations                  |

### Mock Behavior

- Virtual cards created instantly with `active` status, expire in 3 years
- Physical cards start as `pending`, activate after 5 seconds (simulating shipping)
- Default spending limit: 5,000 (virtual), 10,000 (physical)
- Authorization declined if: card not active OR spending limit exceeded
- Brand is always `Visa`
- Mock card numbers: `4242 4242 4242 {last4}`

### Card Statuses

`active` | `inactive` | `canceled` | `pending`

### Authorization Statuses

`pending` | `approved` | `declined`

---

## Sumsub — KYC/Identity Verification [PRODUCTION]

> ✅ PRODUCTION-READY: Sumsub is the only external service with real production API integration.

**File:** `services/mock-sumsub.ts`
**Production docs:** https://docs.sumsub.com/
**Status:** Production-ready — real API calls, WebSDK integration, webhook handling.

### Interfaces

| Interface                  | Description                                   |
| -------------------------- | --------------------------------------------- |
| `SumsubApplicant`          | KYC applicant with review status              |
| `SumsubDocument`           | Identity document (passport, ID card, etc.)   |
| `SumsubVerificationResult` | Verification outcome with per-check breakdown |

### Functions

| Function                | Signature                                              | Description                  |
| ----------------------- | ------------------------------------------------------ | ---------------------------- |
| `createApplicant`       | `({externalUserId, email?, phone?}) → SumsubApplicant` | Create KYC applicant         |
| `getAccessToken`        | `(applicantId) → {token, expiresAt}`                   | Get WebSDK token (30min)     |
| `submitDocument`        | `(applicantId, document) → void`                       | Submit ID document           |
| `submitSelfie`          | `(applicantId, selfieData) → void`                     | Submit selfie for liveness   |
| `getApplicantStatus`    | `(applicantId) → SumsubApplicant`                      | Check applicant status       |
| `getVerificationResult` | `(applicantId) → SumsubVerificationResult`             | Get verification details     |
| `forceApprove`          | `(applicantId) → void`                                 | Force approve (testing only) |
| `onWebhook`             | `(callback) → void`                                    | Register webhook listener    |

### Mock Behavior

- Verification completes after 3-second delay
- 90% approval rate in mock mode
- Risk score: 15 (approved) or 85 (rejected)
- Rejected with label `DOCUMENT_UNREADABLE`, type `RETRY`

### Applicant Statuses

`init` | `pending` | `queued` | `completed` | `onHold`

### Review Answers

`GREEN` (approved) | `RED` (rejected) | `RETRY`

### Verification Checks

| Check                | Description                      |
| -------------------- | -------------------------------- |
| documentAuthenticity | Document is genuine              |
| livenessCheck        | Selfie is a real person          |
| facematch            | Selfie matches document photo    |
| sanctionsCheck       | Not on sanctions lists           |
| pepCheck             | Not a politically exposed person |

### Document Types

`PASSPORT` | `ID_CARD` | `DRIVERS` | `RESIDENCE_PERMIT`

---

## Service Initialization

Source: `services/index.ts:36-48`

```typescript
// Call on app startup
await initializeServices()

// Reset all mocks (testing)
resetMockServices()
```

---

## Service Status Summary

| Service      | Status               | Description                                                                                                                                     |
| ------------ | -------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- |
| **Sumsub**   | `[PRODUCTION]`       | Real API integration, WebSDK, webhook handling — READY                                                                                          |
| **FX rates** | `[IMPLEMENTED/RISK]` | `drop-api` uses Norges Bank primary + ECB fallback for cron refresh; see verified parser/conversion risk in [Currency Rates](CURRENCY-RATES.md) |
| **Stripe**   | `[MOCK/DEV]`         | Mock file only for UI development — NO SDK, NO API keys                                                                                         |
| **Swan**     | `[DEPRECATED]`       | No longer the planned Open Banking provider — mock will be removed                                                                              |
| **Vipps**    | `[PLANNED]`          | Future consideration — ZERO code currently                                                                                                      |
| **Nets**     | `[PLANNED]`          | Future consideration — ZERO code currently                                                                                                      |

## Important Notes

1. **Sumsub is the ONLY production-ready service** — all others are mocks or deprecated.
2. **Console warnings** are emitted on module load for mock services to make usage visible.
3. **Mock state** uses `localStorage` in browser, in-memory on server — resets on server restart.
4. **Production API endpoints** are configurable via environment variables.
5. **The current backend API routes do NOT call these service modules directly** — they use the database layer (`db.ts`) for all operations. The services are available for future integration when real providers are connected.

# Middleware Design Document

# Middleware Design Document

> **Project:** Drop
> **Version:** 0.1.0
> **Date:** 2026-02-23
> **Author:** Platform Architect (AI)
> **Status:** In Review
> **Reviewers:** Alem Bašić (CEO)

## Document History
| Version | Date | Author | Changes |
|---------|------|--------|---------|
| 0.1     | 2026-02-23 | Platform Architect (AI) | Initial draft from source code analysis |

---

## 1. Overview

Drop has two middleware layers:

1. **`src/lib/middleware.ts`** — The active middleware used by all API routes. Provides `requireAuth`, `requireMerchant`, `rateLimit`, `getClientIp`, `jsonError`, CSRF protection, and session revocation.

2. **`src/lib/middleware/`** — A modular middleware library with `auth-middleware.ts` (Bearer token for mobile), `error-handler.ts` (AppError class), and `validation.ts` (input sanitization functions).

Both layers are used in production. Routes import from `@/lib/middleware` (auth, rate limiting) and `@/lib/middleware/validation` (input validation).

---

## 2. Active Middleware (`lib/middleware.ts`)

### 2.1 `requireAuth(request?)`

**Source:** `middleware.ts:42–80`

Authenticates the current request via cookie-based JWT.

**Returns:** `{ user: User, error: null } | { user: null, error: NextResponse }`

**Steps:**
1. **CSRF origin check** — if `Origin` header present, must match allowed origins (`NEXT_PUBLIC_APP_URL`, `http://localhost:3000`, `http://localhost:3001`)
2. **Cookie extraction** — reads `drop_token` from request cookies
3. **JWT verification** — validates HS256 signature and expiry using `jose` library
4. **User lookup** — loads user from `users` table by `userId` from JWT payload
5. **Session revocation check** — verifies at least one non-revoked session exists for this user

**Usage:**
```typescript
const { user, error } = await requireAuth(request);
if (error) return error;  // Returns NextResponse with JSON error
// user is guaranteed non-null here
```

**Error responses:**
- 401 `unauthorized` — missing cookie, invalid JWT, expired token, user not found, all sessions revoked

---

### 2.2 `requireMerchant(request?)`

**Source:** `middleware.ts:101–108`

Extends `requireAuth` with a merchant role check.

```typescript
const { user, error } = await requireMerchant(request);
if (error) return error;  // 401 if not authenticated, 403 if not merchant
```

**Returns 403 `forbidden`** if user exists but `role !== 'merchant'`.

**Applied to:** `GET /api/merchants/dashboard`, `GET /api/merchants/qr`, `GET /api/merchants/transactions`

---

### 2.3 `rateLimit(ip, limit, windowMs?)`

**Source:** `middleware.ts:7–31`

Persistent IP-based rate limiter using the `rate_limits` database table.

| Parameter | Default | Description |
|-----------|---------|-------------|
| `ip` | — | Client IP address |
| `limit` | — | Max requests per window |
| `windowMs` | 60,000ms | Window size in milliseconds |

**Returns:** `boolean` — `true` if request is allowed, `false` if rate limited.

**Implementation:**
- Uses `runUpsert` for atomic counter creation/update
- Cleans expired entries on each call (removes rows where `expires_at < now`)
- Counter stored in `rate_limits` table: `(key, count, expires_at)`

**Rate limit table schema:**
```sql
CREATE TABLE rate_limits (
  key TEXT PRIMARY KEY,      -- IP address
  count INTEGER DEFAULT 1,
  expires_at INTEGER         -- Unix timestamp (ms)
);
```

**Usage:**
```typescript
const ip = getClientIp(request);
if (!(await rateLimit(ip, 10))) {  // 10 req/min
  return jsonError("rate_limited", "Too many requests", 429);
}
```

**Applied limits:**

| Endpoint | Limit | Window |
|----------|-------|--------|
| `/api/auth/bankid/initiate` | 10/min | 60s |
| `/api/auth/bankid/callback` | 10/min | 60s |
| `/api/auth/register` (deprecated) | 10/min | 60s |
| `/api/auth/login` (deprecated) | 10/min | 60s |
| `/api/transactions/remittance` | 10/min | 60s |
| `/api/transactions/qr-payment` | 10/min | 60s |
| `/api/rates` | 120/min | 60s |
| `/api/rates/[currency]` | 120/min | 60s |

---

### 2.4 `getClientIp(request)`

**Source:** `middleware.ts:33–35`

Extracts the client's real IP address from the `x-forwarded-for` header (first IP in the chain — the originating client). Falls back to `'127.0.0.1'` if header not present.

**Note:** When behind App Runner (AWS managed proxy), `x-forwarded-for` is set automatically with the real client IP.

---

### 2.5 `jsonError(error, message, status, details?)`

**Source:** `middleware.ts:37–39`

Creates a standardized JSON error `NextResponse`.

```typescript
return jsonError("validation_error", "Validation failed", 422, ["Email required"]);
// Response body: { "error": "validation_error", "message": "Validation failed", "details": ["Email required"] }
```

---

### 2.6 `revokeAllSessions(userId)`

**Source:** `middleware.ts:83–85`

Sets `revoked=1` on all sessions for a user. Called by `POST /api/auth/logout`.

```sql
UPDATE sessions SET revoked = 1 WHERE user_id = $1;
```

---

### 2.7 `generateCsrfToken() / validateCsrf(request, token)`

**Source:** `middleware.ts:88–99`

CSRF token generation (32 random bytes hex-encoded) and validation via `x-csrf-token` header.

**Status:** Implemented but not actively required on any route. CSRF protection is handled via:
- BankID OIDC state parameter (login flow)
- Origin header validation (in `requireAuth`)

---

## 3. Middleware Library (`lib/middleware/`)

### 3.1 Error Handler (`middleware/error-handler.ts`)

**AppError class:**
```typescript
class AppError extends Error {
  constructor(
    public code: string,
    message: string,
    public status: number = 500,
    public details?: unknown
  ) {}
}
```

**Predefined error constructors:**

| Constructor | Code | HTTP Status |
|-------------|------|-------------|
| `Errors.unauthorized(msg?)` | `UNAUTHORIZED` | 401 |
| `Errors.forbidden(msg?)` | `FORBIDDEN` | 403 |
| `Errors.notFound(resource)` | `NOT_FOUND` | 404 |
| `Errors.badRequest(msg, details?)` | `BAD_REQUEST` | 400 |
| `Errors.conflict(msg)` | `CONFLICT` | 409 |
| `Errors.tooManyRequests(msg?)` | `RATE_LIMIT_EXCEEDED` | 429 |
| `Errors.internal(msg?)` | `INTERNAL_ERROR` | 500 |

**Error response format:**
```json
{
  "error": {
    "code": "BAD_REQUEST",
    "message": "Amount must be between 100 and 50000 NOK",
    "details": "validation_error"
  }
}
```

**Production masking:** `createErrorResponse()` masks internal error messages in production — only returns `"An unexpected error occurred"` for 500 errors.

---

### 3.2 Auth Middleware (`middleware/auth-middleware.ts`)

Alternative auth middleware for **mobile clients** using Bearer token pattern.

**`requireAuth(request)`:**
- Extracts JWT from `Authorization: Bearer <token>` header
- Verifies JWT signature + expiry
- Returns `userId` from payload

**In-memory rate limiter** (for Bearer token routes):
- `DEFAULT_RATE_LIMIT`: 100 req/min
- `STRICT_RATE_LIMIT`: 10 req/min
- Auto-cleanup every 5 minutes
- Rate limit headers: `X-RateLimit-Limit`, `X-RateLimit-Remaining`, `X-RateLimit-Reset`

**`getClientIP(request)`:**
Checks `X-Forwarded-For` → `X-Real-IP` → falls back to `'unknown'`.

---

### 3.3 Validation (`middleware/validation.ts`)

Input validation functions — no external dependencies, all custom implementations.

| Function | Description | Rules |
|----------|-------------|-------|
| `validatePhone(phone)` | International phone | Starts with `+`, 8–15 digits |
| `validateAmount(amount)` | Positive monetary amount | `> 0`, max 2 decimal places |
| `validateIBAN(iban)` | European IBAN | Country code + alphanumeric, mod-97 checksum |
| `validatePIN(pin)` | Card PIN | Exactly 4 digits |
| `validateEmail(email)` | Email address | Basic `x@y.z` pattern |
| `validateCurrency(currency)` | ISO 4217 code | Whitelist: EUR, USD, GBP, BAM, CHF, PLN, NOK, RSD, TRY, PKR |
| `validateDateISO(date)` | ISO 8601 date | Parseable by `Date.parse()` |
| `validateName(name)` | Name field | 1–100 chars, at least one letter, XSS-safe |
| `validateLanguage(lang)` | Language code | Whitelist: nb, en, bs, sq |
| `sanitizeText(text, maxLength?)` | Text sanitization | Strips HTML tags + control chars, trims, enforces max length (default 500) |
| `validate(condition, msg)` | Assert helper | Throws `AppError` (400) if false |
| `required(value, name)` | Required field check | Throws `AppError` (400) if null/undefined |

**Security notes:**
- `validateName` checks for: `<script`, `javascript:`, `onerror=`, `onclick=` — blocks XSS injection in name fields
- `sanitizeText` removes HTML tags via regex, strips control characters
- `validateIBAN` implements full mod-97 checksum algorithm
- `validateAmount` rejects `NaN`, `Infinity`, negative values

---

## 4. Security Headers (Next.js Config)

Applied to all responses via `next.config.ts`:

| Header | Production Value | Development Value | Purpose |
|--------|-----------------|-------------------|---------|
| `Content-Security-Policy` | `default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; connect-src 'self'; frame-ancestors 'none'` | Adds `'unsafe-eval'` + `'unsafe-inline'` for HMR | XSS protection |
| `X-Frame-Options` | `DENY` | `DENY` | Clickjacking prevention |
| `X-Content-Type-Options` | `nosniff` | `nosniff` | MIME sniffing prevention |
| `Referrer-Policy` | `strict-origin-when-cross-origin` | Same | Referrer leakage prevention |
| `Permissions-Policy` | `camera=(self), microphone=(), geolocation=(self)` | Same | Feature restriction |
| `Strict-Transport-Security` | `max-age=63072000; includeSubDomains; preload` | Same | Force HTTPS (2-year HSTS) |

---

## 5. Middleware Usage Matrix

| Route | Rate Limit | `requireAuth` | `requireMerchant` | Feature Flag | Validation Functions |
|-------|------------|--------------|-------------------|--------------|---------------------|
| GET `/api/auth/bankid` | 10/min | No | No | No | — |
| GET `/api/auth/bankid/callback` | 10/min | No | No | No | state cookie |
| GET `/api/auth/me` | No | Yes | No | No | — |
| POST `/api/auth/logout` | No | Yes | No | No | — |
| POST `/api/auth/refresh` | No | Yes | No | No | — |
| GET `/api/transactions` | No | Yes | No | No | — |
| POST `/api/transactions/remittance` | 10/min | Yes | No | No | `validateAmount` |
| POST `/api/transactions/qr-payment` | 10/min | Yes | No | No | `validateAmount` |
| GET `/api/rates` | 120/min | No | No | No | — |
| POST `/api/recipients` | No | Yes | No | No | `validateName`, country whitelist |
| POST `/api/merchants/register` | No | Yes | No | No | `validateName`, orgNumber |
| GET `/api/merchants/dashboard` | No | Yes | Yes | No | period whitelist |
| GET `/api/notifications` | No | Yes | No | `notifications` | — |
| PATCH `/api/notifications` | No | Yes | No | `notifications` | ID format, max 100 |
| PATCH `/api/settings` | No | Yes | No | No | currency/language whitelist |
| POST `/api/cards/[id]/physical` | No | Yes | No | `physicalCards` | address min 10 chars |
| POST `/api/cards/[id]/pin` | No | Yes | No | `cardPin` | `validatePIN` |
| GET/PUT `/api/cards/[id]/limits` | No | Yes | No | `spendingLimits` | limitType whitelist |

---

## 6. Error Spike Detection

Implemented in `src/lib/alerts.ts` as a middleware-adjacent concern:

- Every HTTP 5xx response triggers `trackError()` (called in `jsonError()` middleware for 500 errors)
- Rolling 1-minute window of error timestamps maintained in-memory
- When count > 5 in 60 seconds → sends critical Slack alert to `#drop-ops`
- 10-minute cooldown per alert title prevents spam

**Limitation:** Error counter is in-memory only — resets on application restart. Redis-backed counter planned for v1.0.

---

## Related Documents

- [Backend Architecture](./backend-architecture.md)
- [API Reference](./api-reference.md)
- [Source: MIDDLEWARE.md](../backend/MIDDLEWARE.md)

---

## Approval
| Role | Name | Date | Signature |
|------|------|------|-----------|
| Author | Platform Architect (AI) | 2026-02-23 | |
| Reviewer | | | |
| Approver | Alem Bašić | | |

# Feature Flags

# Drop Feature Flags

> Sources: `src/drop-app/src/lib/feature-flags.ts`, `src/drop-app/src/lib/features.ts`

## Feature Flag System

Source: `feature-flags.ts`

### Architecture

Feature flags are controlled via **environment variables** with the pattern:

```
NEXT_PUBLIC_FF_<SCREAMING_SNAKE_CASE>=true|false
```

The `NEXT_PUBLIC_` prefix ensures flags are available on both server and client (inlined at build time by Next.js).

**Conversion example:** `physicalCards` → `NEXT_PUBLIC_FF_PHYSICAL_CARDS`

Source: `feature-flags.ts:42-45`

---

### Available Flags

| Flag Name | Env Var | Default | Description |
|-----------|---------|---------|-------------|
| virtualCards | NEXT_PUBLIC_FF_VIRTUAL_CARDS | false | Virtual card issuance |
| physicalCards | NEXT_PUBLIC_FF_PHYSICAL_CARDS | false | Physical card ordering |
| cardDetails | NEXT_PUBLIC_FF_CARD_DETAILS | false | View full card details |
| cardFreeze | NEXT_PUBLIC_FF_CARD_FREEZE | false | Card freeze/unfreeze |
| cardPin | NEXT_PUBLIC_FF_CARD_PIN | false | Card PIN management |
| spendingLimits | NEXT_PUBLIC_FF_SPENDING_LIMITS | false | Card spending limits |
| notifications | NEXT_PUBLIC_FF_NOTIFICATIONS | true | Push notifications |
| merchantDashboard | NEXT_PUBLIC_FF_MERCHANT_DASHBOARD | true | Merchant dashboard |

Source: `feature-flags.ts:27-36`

---

### Server-Side API

| Function | Return Type | Description |
|----------|-------------|-------------|
| `isEnabled(flag)` | `boolean` | Check if a flag is enabled |
| `getAllFlags()` | `FeatureFlags` | Get all flags with current values |
| `featureGate(flag)` | `NextResponse \| null` | API middleware: returns 404 response if disabled, null if enabled |

**`featureGate` usage in routes:**

```typescript
// In any route handler:
const gate = featureGate("physicalCards");
if (gate) return gate;  // Returns 404 with "Feature not available"
```

Source: `feature-flags.ts:80-88`

**Routes using `featureGate`:**

| Route | Flag |
|-------|------|
| POST /api/cards/[id]/physical | `physicalCards` |
| POST /api/cards/[id]/pin | `cardPin` |
| GET /api/cards/[id]/limits | `spendingLimits` |
| PUT /api/cards/[id]/limits | `spendingLimits` |
| GET /api/notifications | `notifications` |
| PATCH /api/notifications | `notifications` |

---

### Client-Side API

| Function | Return Type | Description |
|----------|-------------|-------------|
| `useFeatureFlag(flag)` | `boolean` | React hook for a single flag |
| `useFeatureFlags()` | `FeatureFlags` | React hook for all flags |

These work because `NEXT_PUBLIC_*` env vars are inlined at build time — no server roundtrip needed.

Source: `feature-flags.ts:94-114`

---

## Feature Tracking System

Source: `features.ts`

A separate system for tracking **implementation progress** of Drop features. Not runtime flags — this is a development tracking tool.

### Feature Interface

```typescript
interface Feature {
  id: string;              // e.g., "auth-001"
  category: string;        // e.g., "Authentication"
  name: string;            // e.g., "User Registration"
  description: string;
  status: "pending" | "in_progress" | "passing" | "failing";
  priority: number;        // 1 = highest
  dependencies: string[];  // IDs of prerequisite features
  acceptanceCriteria: string[];
  implementedAt?: string;  // ISO date
  testedAt?: string;       // ISO date
}
```

### Feature Categories and Status

| Category | Total | Passing | Pending | Notes |
|----------|-------|---------|---------|-------|
| Authentication | 4 | 3 | 1 (Biometric Login) | |
| KYC | 1 | 1 | 0 | |
| Banking | 6 | 5 | 1 | bank-006 (Top-up via Card) is FUTURE — incompatible with pass-through model |
| Cards | 4 | 4 | 0 | FUTURE — all card features are gated behind feature flags (default: false) |
| Notifications | 1 | 0 | 1 (Push Notifications) | |

### All Features

| ID | Name | Status | Priority | Dependencies | Notes |
|----|------|--------|----------|--------------|-------|
| auth-001 | User Registration | passing | 1 | - | |
| auth-002 | PIN Login | passing | 1 | auth-001 | |
| auth-003 | Logout | passing | 2 | auth-002 | |
| auth-004 | Biometric Login | pending | 3 | auth-002 | |
| kyc-001 | Identity Verification | passing | 1 | auth-001 | |
| bank-001 | IBAN Generation | passing | 1 | kyc-001 | |
| bank-002 | Balance Display | passing | 1 | bank-001 | AISP read-only |
| bank-003 | Send Money | passing | 1 | bank-002 | PISP from user's bank |
| bank-004 | Receive Money | passing | 1 | bank-001 | |
| bank-005 | Transaction History | passing | 2 | bank-003, bank-004 | |
| bank-006 | Top-up via Card | passing | 2 | bank-001 | **FUTURE** — no wallet in pass-through model |
| card-001 | Virtual Card Issuance | passing | 1 | kyc-001 | **FUTURE** — feature-flagged |
| card-002 | Card Freeze/Unfreeze | passing | 2 | card-001 | **FUTURE** — feature-flagged |
| card-003 | Card Transactions | passing | 1 | card-001 | **FUTURE** — feature-flagged |
| card-004 | Physical Card Order | passing | 3 | card-001 | **FUTURE** — feature-flagged |
| notif-001 | Push Notifications | pending | 3 | auth-001 | |

### Helper Functions

| Function | Description |
|----------|-------------|
| `getFeaturesByStatus(status)` | Filter features by status |
| `getFeaturesByCategory(category)` | Filter features by category |
| `getFeatureStats()` | Get counts: total, passing, pending, inProgress, failing, percentComplete |
| `getReadyFeatures()` | Features whose dependencies are all `passing` |
| `printFeatureReport()` | Formatted text report |

Source: `features.ts:284-357`

---

## Environment Variable Summary

| Variable | Purpose | Default |
|----------|---------|---------|
| NEXT_PUBLIC_FF_VIRTUAL_CARDS | Enable virtual cards | false |
| NEXT_PUBLIC_FF_PHYSICAL_CARDS | Enable physical cards | false |
| NEXT_PUBLIC_FF_CARD_DETAILS | Enable card detail view | false |
| NEXT_PUBLIC_FF_CARD_FREEZE | Enable card freeze | false |
| NEXT_PUBLIC_FF_CARD_PIN | Enable card PIN | false |
| NEXT_PUBLIC_FF_SPENDING_LIMITS | Enable spending limits | false |
| NEXT_PUBLIC_FF_NOTIFICATIONS | Enable notifications | true |
| NEXT_PUBLIC_FF_MERCHANT_DASHBOARD | Enable merchant dashboard | true |
| NEXT_PUBLIC_SERVICE_MODE | mock or production | mock |
| DATABASE_URL | PostgreSQL 16 connection string | Required (no SQLite fallback) |
| JWT_SECRET | JWT signing secret | dev-only fallback |
| NEXT_PUBLIC_APP_URL | App URL for CSRF | - |
| SEED_DEMO | Enable demo data in staging | - |

# Middleware

# Drop Middleware

> Sources: `src/drop-app/src/lib/middleware.ts`, `src/drop-app/src/lib/middleware/`

## Overview

Drop has two middleware layers:

1. **`lib/middleware.ts`** — The active middleware used by all API routes. Provides `requireAuth`, `requireMerchant`, `rateLimit`, `getClientIp`, `jsonError`, CSRF, and session revocation.

2. **`lib/middleware/`** directory — A modular middleware library with `auth-middleware.ts`, `error-handler.ts`, and `validation.ts`. Exported via barrel file `middleware/index.ts`.

The API routes import from both: `@/lib/middleware` (auth, rate limiting) and `@/lib/middleware/validation` (input validation).

---

## Active Middleware (`middleware.ts`)

### requireAuth(request?)

Source: `middleware.ts:42-80`

Authenticates the current request via cookie-based JWT. Returns `{ user, error }`.

**Steps:**
1. **CSRF origin check** — If `Origin` header present, must match allowed origins
2. **Cookie extraction** — Reads `drop_token` from cookies
3. **JWT verification** — Validates signature and expiry
4. **User lookup** — Loads user from `users` table
5. **Session revocation check** — Verifies at least one non-revoked session exists

**Allowed origins:** `NEXT_PUBLIC_APP_URL`, `http://localhost:3000`, `http://localhost:3001`

```typescript
const { user, error } = await requireAuth(request);
if (error) return error;  // Returns NextResponse with error JSON
```

---

### requireMerchant(request?)

Source: `middleware.ts:101-108`

Extends `requireAuth` with a role check: user must have `role === 'merchant'`. Returns 403 if not.

```typescript
const { user, error } = await requireMerchant(request);
if (error) return error;
```

---

### rateLimit(ip, limit, windowMs?)

Source: `middleware.ts:7-31`

Persistent IP-based rate limiter using the `rate_limits` database table.

```typescript
if (!(await rateLimit(ip, 10))) {           // 10 requests per 60s window
  return jsonError("rate_limited", "Too many requests", 429);
}
```

- Default window: 60,000ms (1 minute)
- Cleans expired entries on each call
- Uses `runUpsert` for atomic counter creation/update

---

### getClientIp(request)

Source: `middleware.ts:33-35`

Extracts client IP from `x-forwarded-for` header (first IP in chain), falls back to `127.0.0.1`.

---

### jsonError(error, message, status, details?)

Source: `middleware.ts:37-39`

Creates a standardized JSON error response.

```typescript
return jsonError("validation_error", "Validation failed", 422, ["Email required"]);
// → { "error": "validation_error", "message": "Validation failed", "details": ["Email required"] }
```

---

### revokeAllSessions(userId)

Source: `middleware.ts:83-85`

Sets `revoked=1` on all sessions for a user. Called during logout.

---

### generateCsrfToken() / validateCsrf(request, token)

Source: `middleware.ts:88-99`

CSRF token generation (32 random bytes hex-encoded) and validation via `x-csrf-token` header. Available but not actively required on any route.

---

## Middleware Library (`middleware/`)

### Error Handler

Source: `middleware/error-handler.ts`

**AppError class:**
```typescript
class AppError extends Error {
  constructor(code: string, message: string, status: number = 500, details?: unknown)
}
```

**Predefined error constructors (`Errors.*`):**

| Constructor | Code | Status |
|-------------|------|--------|
| `Errors.unauthorized(msg?)` | UNAUTHORIZED | 401 |
| `Errors.forbidden(msg?)` | FORBIDDEN | 403 |
| `Errors.notFound(resource)` | NOT_FOUND | 404 |
| `Errors.badRequest(msg, details?)` | BAD_REQUEST | 400 |
| `Errors.conflict(msg)` | CONFLICT | 409 |
| `Errors.tooManyRequests(msg?)` | RATE_LIMIT_EXCEEDED | 429 |
| `Errors.internal(msg?)` | INTERNAL_ERROR | 500 |

**Error response format:**
```json
{
  "error": {
    "code": "BAD_REQUEST",
    "message": "...",
    "details": "..."
  }
}
```

`createErrorResponse(error)` handles `AppError`, standard `Error`, and unknown errors. In development, includes original error messages; in production, masks internal errors.

---

### Auth Middleware

Source: `middleware/auth-middleware.ts`

Alternative auth middleware using Bearer token pattern (vs. cookie pattern in `middleware.ts`).

**`requireAuth(request)`** — Extracts JWT from `Authorization: Bearer <token>` header, verifies, returns userId.

**In-memory rate limiter** with:
- `DEFAULT_RATE_LIMIT`: 100 req/min
- `STRICT_RATE_LIMIT`: 10 req/min
- Auto-cleanup every 5 minutes
- Rate limit response headers (`X-RateLimit-*`)

**`getClientIP(request)`** — Checks `X-Forwarded-For`, then `X-Real-IP`, then falls back to `'unknown'`.

---

### Validation

Source: `middleware/validation.ts`

Input validation functions (no external dependencies):

| Function | Description | Rules |
|----------|-------------|-------|
| `validatePhone(phone)` | International phone format | Starts with `+`, 8-15 digits |
| `validateAmount(amount)` | Positive number | > 0, max 2 decimal places |
| `validateIBAN(iban)` | European IBAN format | Country code + digits + alphanumeric, mod-97 checksum |
| `validatePIN(pin)` | Card PIN | Exactly 4 digits |
| `validateEmail(email)` | Email address | Basic `x@y.z` pattern |
| `validateCurrency(currency)` | ISO 4217 code | Whitelist: EUR, USD, GBP, BAM, CHF, PLN, NOK, RSD, TRY, PKR |
| `validateDateISO(date)` | ISO 8601 date | Parseable by `Date.parse()` |
| `validateName(name)` | Name field | 1-100 chars, at least one letter, no script/HTML injection |
| `validateLanguage(lang)` | Language code | Whitelist: nb, en, bs, sq |
| `sanitizeText(text, maxLength?)` | Text sanitization | Strips HTML tags, control chars, trims, enforces max length (default 500) |
| `validate(condition, msg)` | Assert helper | Throws `AppError` (400) if false |
| `required(value, name)` | Required check | Throws `AppError` (400) if null/undefined |

**Security notes:**
- `validateName` checks for dangerous patterns: `<script`, `javascript:`, `onerror=`, `onclick=`
- `sanitizeText` removes HTML tags via regex, strips control characters
- IBAN validation implements the full mod-97 checksum algorithm

---

## Middleware Usage by Route

| Route | Rate Limit | Auth | Merchant | Feature Flag | Validation |
|-------|------------|------|----------|--------------|------------|
| POST /auth/register | 10/min | - | - | - | email, name, phone, age |
| POST /auth/login | 10/min | - | - | - | - |
| GET /auth/me | - | Yes | - | - | - |
| POST /auth/logout | - | Yes | - | - | - |
| POST /auth/refresh | - | Yes | - | - | - |
| GET /transactions | - | Yes | - | - | - |
| POST /transactions/remittance | 10/min | Yes | - | - | amount range, decimal |
| POST /transactions/qr-payment | 10/min | Yes | - | - | amount range, decimal |
| GET /rates | 120/min | - | - | - | - |
| GET /rates/[currency] | 120/min | - | - | - | - |
| POST /cards/[id]/physical | - | Yes | - | physicalCards | address min 10 chars |
| POST /cards/[id]/pin | - | Yes | - | cardPin | 4-digit PIN |
| GET /cards/[id]/limits | - | Yes | - | spendingLimits | - |
| PUT /cards/[id]/limits | - | Yes | - | spendingLimits | limitType whitelist |
| GET /notifications | - | Yes | - | notifications | - |
| PATCH /notifications | - | Yes | - | notifications | ID format, max 100 |
| PATCH /settings | - | Yes | - | - | currency/language whitelist |
| POST /recipients | - | Yes | - | - | name, country whitelist |
| POST /merchants/register | - | Yes | - | - | orgNumber 9 digits |
| GET /merchants/dashboard | - | Yes | Merchant | - | period whitelist |
| GET /merchants/qr | - | Yes | Merchant | - | - |
| GET /merchants/transactions | - | Yes | Merchant | - | - |

# Backend Architecture Document

# Backend Architecture Document

> **Project:** Drop
> **Version:** 0.1.0
> **Date:** 2026-02-23
> **Author:** Platform Architect (AI)
> **Status:** In Review
> **Reviewers:** Alem Bašić (CEO)

## Document History
| Version | Date | Author | Changes |
|---------|------|--------|---------|
| 0.1     | 2026-02-23 | Platform Architect (AI) | Initial draft from source code analysis |

---

## 1. Architecture Pattern

**Pattern:** Modular Monolith — Next.js App Router with co-located API routes

| Pattern Considered | Pros | Cons | Decision |
|-------------------|------|------|----------|
| Monolith (Next.js all-in-one) | Simple deploy, single codebase, lowest latency | Scaling bottleneck, frontend/backend coupled | Selected |
| Modular Monolith | Module isolation within single deploy | Extra abstraction overhead for small team | Partially adopted (lib/ modules) |
| Microservices | Independent scaling per service | Operational complexity, too expensive for MVP | Rejected |

**Rationale:**
Drop is a two-person MVP (Alem + AI). The Next.js App Router pattern co-locates API routes (`app/api/`) with the frontend, enabling full-stack development in a single TypeScript codebase with zero additional runtime complexity. The `src/lib/` directory provides module isolation (db, middleware, services, features) without microservice overhead. App Runner handles scaling concerns at the infrastructure level.

**Pass-Through Model (Critical Architecture Constraint):**
Drop NEVER holds customer money. All payments are pass-through via PSD2:
- **AISP** (Account Information): reads bank balance from user's real bank account
- **PISP** (Payment Initiation): initiates transfers directly from user's bank account
- `bank_accounts.balance` = last AISP-read value from external bank (cached for UI display, NOT a Drop-held balance)

---

## 2. Technology Stack

| Layer | Technology | Version | Notes |
|-------|-----------|---------|-------|
| Runtime | Node.js | 22 (Alpine) | LTS, Dockerfile base image |
| Framework | Next.js (App Router) | 16.1.6 | Standalone output for Docker |
| Frontend | React | 19.2.3 | |
| Language | TypeScript | ^5 | Strict mode |
| Database (production) | PostgreSQL (via `pg`) | 16 (RDS) | `pg ^8.18.0` |
| Database (MVP/staging) | SQLite (via `better-sqlite3`) | ^12.6.2 | Auto-detected when no `DATABASE_URL` |
| Auth | JWT (via `jose`) | ^6.1.3 | HS256, httpOnly cookie |
| Password hashing | bcryptjs | ^3.0.3 | Legacy — BankID replaces email/password |
| Identity (eID) | BankID OIDC | Norwegian eID | Mandatory for all users |
| KYC | Sumsub WebSDK + API | Production-ready | Only connected external service |
| Open Banking | TBD (Swan deprecated) | — | AISP/PISP provider selection pending |
| Styling | Tailwind CSS | ^4 | |
| UI Components | Radix UI | ^1.4.3 | Accessible, unstyled primitives |
| Icons | Lucide React | ^0.563.0 | |
| Theme | next-themes | ^0.4.6 | Dark/light mode |
| Toasts | Sonner | ^2.0.7 | |
| Testing (unit) | Vitest | ^4.0.18 | |
| Testing (E2E) | Playwright | ^1.58.2 | |
| Linting | ESLint | ^9 | |

---

## 3. Application Structure

```
src/drop-app/
├── src/
│   ├── app/                    # Next.js App Router
│   │   ├── api/                # API route handlers (REST endpoints)
│   │   │   ├── auth/           # BankID OIDC + session management
│   │   │   │   ├── bankid/     # initiate + callback endpoints
│   │   │   │   ├── me/         # current user + bank accounts
│   │   │   │   ├── logout/     # session revocation
│   │   │   │   └── refresh/    # token refresh
│   │   │   ├── transactions/   # remittance, qr-payment, history, disclosure, receipt
│   │   │   ├── recipients/     # recipient CRUD
│   │   │   ├── rates/          # exchange rates (public)
│   │   │   ├── merchants/      # merchant registration + dashboard
│   │   │   ├── notifications/  # push notification management
│   │   │   ├── settings/       # user preferences
│   │   │   ├── consents/       # GDPR consent management
│   │   │   ├── complaints/     # Finansavtaleloven §3-53 complaints
│   │   │   ├── cards/          # [FUTURE] feature-flagged card management
│   │   │   ├── user/           # GDPR data export + account deletion
│   │   │   └── health/         # health check endpoint
│   │   └── (frontend pages)    # Next.js pages
│   └── lib/                    # Shared application library
│       ├── db.ts               # Database abstraction (PostgreSQL + SQLite)
│       ├── middleware.ts        # Auth, rate limiting, CSRF, session revocation
│       ├── middleware/          # Modular middleware library
│       │   ├── auth-middleware.ts  # Bearer token auth (mobile)
│       │   ├── error-handler.ts   # AppError class + error response formatting
│       │   └── validation.ts      # Input validation functions
│       ├── alerts.ts           # Slack alerting + error spike detection
│       ├── secrets.ts          # Pluggable secrets provider (env / Doppler / AWS SM)
│       ├── feature-flags.ts    # Environment-variable-based feature flags
│       ├── features.ts         # Feature tracking system (dev tool)
│       └── services/           # External service integrations
│           ├── index.ts        # Service initialization
│           ├── mock-sumsub.ts  # Sumsub KYC (production-ready)
│           ├── mock-swan.ts    # Swan Open Banking (DEPRECATED)
│           └── mock-stripe.ts  # Stripe Issuing (mock only, FUTURE)
├── tests/                      # Test suite
│   ├── setup.ts                # Vitest setup (NODE_ENV=test, in-memory DB)
│   ├── *.test.ts               # Unit + integration tests
│   └── e2e/                    # Playwright E2E tests
│       ├── user-flows.spec.ts
│       ├── full-flows.spec.ts
│       └── input-chaos.spec.ts
└── scripts/
    ├── backup.sh               # SQLite backup script
    └── qa-report.js            # QA metrics generator
```

---

## 4. Database Layer

### 4.1 Dual-Database Architecture

Drop auto-detects the database driver at startup:
- `DATABASE_URL` set → PostgreSQL (`pg` driver)
- `DATABASE_URL` not set → SQLite (`better-sqlite3`)

**Source:** `src/lib/db.ts`

### 4.2 Key Database Tables

| Table | Purpose | Notes |
|-------|---------|-------|
| `users` | User accounts, KYC status, BankID linkage | `kyc_status`: pending/approved/rejected; `national_id_hash`: SHA-256 of BankID pid |
| `sessions` | JWT session tracking + revocation | SHA-256 hash of JWT, `revoked` flag |
| `bank_accounts` | Linked bank accounts (AISP data) | `balance` = last AISP read (NOT Drop-held funds) |
| `transactions` | All payments (remittance + QR) | `type`: remittance/qr_payment; `status`: processing/completed/failed |
| `recipients` | Saved international recipients | Bank account masked in API responses |
| `merchants` | Merchant profiles, QR data | `org_number` unique (9 digits, Norwegian) |
| `notifications` | User notifications | Feature-flagged |
| `rate_limits` | IP-based rate limiting (persistent) | `key`: IP address, window-based counter |
| `audit_log` | Security + compliance audit trail | `action`, `resource_type`, `resource_id`, `details` |
| `aml_alerts` | AML/financial crime alerts | `status`: open/closed/filed |
| `str_reports` | Suspicious Transaction Reports | Filed with Finanstilsynet |
| `consents` | GDPR consent records | `consent_type`: terms/privacy/marketing/cookies_analytics/cookies_marketing |
| `data_access_requests` | GDPR export/erasure requests | `type`: export/erasure |
| `complaints` | User complaints (Finansavtaleloven §3-53) | 15-business-day response SLA |
| `exchange_rates` | NOK → destination currency rates | Updated externally |
| `feature_flags` | Runtime feature flag overrides | Complement to env-var flags |
| `cards` | [FUTURE] Virtual/physical cards | Feature-flagged, all flags default false |

### 4.3 Data Auto-Detection (db.ts)

```typescript
// Auto-detects driver based on DATABASE_URL env var
const driver = process.env.DATABASE_URL ? 'pg' : 'sqlite';
```

---

## 5. Authentication Architecture

### 5.1 BankID OIDC Flow (Primary Auth)

**Auth method:** Norwegian BankID — mandatory for all users. Email/password auth deprecated (returns 410 Gone).

**Token:** JWT (HS256), stored in `drop_token` httpOnly cookie (web) or Authorization Bearer header (mobile).

**Token lifetime:** 24h (web), 7d (mobile)

**BankID Web Flow:**
1. `GET /api/auth/bankid` → generate state + nonce, set `bankid_state` cookie, return redirect URL
2. User authenticates with BankID at provider
3. `GET /api/auth/bankid/callback?code=&state=` → verify state, exchange code for tokens, verify JWKS signature, parse `pid`, hash pid → SHA-256, find/create user, issue JWT cookie

**User creation on first BankID login:**
- Parse pid (Norwegian national ID, 11 digits) from ID token
- Hash pid with SHA-256 → `national_id_hash` column
- KYC status automatically `approved` (BankID = verified identity)
- Password set to sentinel `'EIDONLY'` — no password login possible

**Age verification:** pid encodes date of birth — must be >= 18 years old.

### 5.2 Session Management

```
Login  → Create session record (SHA-256 of JWT) in sessions table
Request → requireAuth() checks: cookie present + JWT valid + session not revoked
Logout → revokeAllSessions(userId) — sets revoked=1 on all user sessions
```

### 5.3 CSRF Protection

- **Web:** State parameter in BankID OIDC flow (httpOnly cookie)
- **API:** Origin header validation in `requireAuth()` against allowed origins
- **Mobile:** N/A (Bearer token, no cookies)

---

## 6. Middleware Stack

| Middleware | Function | Applied To |
|------------|----------|------------|
| `requireAuth()` | CSRF check → cookie extraction → JWT verify → user lookup → session revocation check | All protected routes |
| `requireMerchant()` | `requireAuth()` + role check (`role === 'merchant'`) | Merchant-only routes |
| `rateLimit(ip, limit)` | Persistent IP-based counter via `rate_limits` DB table, 60s window | Auth endpoints (10/min), public rates (120/min) |
| `getClientIp()` | Extract IP from `x-forwarded-for` | All rate-limited routes |
| `jsonError()` | Standardized JSON error response | All routes |
| `featureGate(flag)` | Returns 404 if feature flag disabled | Cards, spending limits, notifications |
| Input validation | `validateEmail`, `validatePhone`, `validateAmount`, `validateName`, `sanitizeText`, etc. | All mutation endpoints |
| Error handler | `AppError` class with predefined constructors | All routes via `createErrorResponse()` |

---

## 7. API Design Principles

1. **Consistent response envelope:**
   - Success: `{ "data": { ... } }` or `{ "data": [...], "pagination": { ... } }`
   - Error: `{ "error": "code", "message": "...", "details": [...] }`

2. **No wallet model:** Drop never holds funds. `bank_accounts.balance` is AISP-read cache only.

3. **KYC gate:** Remittance requires `kyc_status === 'approved'` — enforced in route handler.

4. **Atomic transactions:** Balance deduction and transaction creation in a single DB transaction.

5. **Data masking:** Bank account numbers masked in responses (`*****5678`), card numbers PCI-masked.

6. **GDPR by design:** Data export, account deletion (soft delete), consent records all implemented.

7. **Compliance-first:** STR reports, AML alerts, audit log, complaint system (Finansavtaleloven §3-53), PITR retention (5 years per hvitvaskingsloven).

---

## 8. Security Architecture

| Control | Implementation |
|---------|----------------|
| Auth | BankID OIDC (Norwegian eID) — mandatory |
| Session tokens | httpOnly, secure, sameSite=strict JWT cookies |
| Rate limiting | Persistent DB-backed per-IP (10/min auth, 120/min public) |
| Input validation | Custom validators (no external dep) — email, phone, amount, IBAN, name (XSS-resistant) |
| SQL injection | Parameterized queries via `pg` / `better-sqlite3` |
| XSS | CSP headers (strict production — no unsafe-eval) + HTML sanitization in `sanitizeText()` |
| CSRF | Origin header validation + BankID state parameter |
| Secrets | AWS Secrets Manager / Fly.io secrets — never in code or .env |
| Error masking | `createErrorResponse()` masks internal errors in production |
| Password hashing | bcryptjs (legacy users) |
| Card data | PCI-masked (never expose full card number or CVV) |
| Audit trail | `audit_log` table — all sensitive actions logged |
| AML | `aml_alerts` + `str_reports` tables — compliance framework |

---

## 9. External Service Integrations

| Service | Status | Purpose |
|---------|--------|---------|
| **Sumsub** | PRODUCTION (only connected external service) | KYC/identity verification — WebSDK + webhook |
| **BankID OIDC** | PRODUCTION | Norwegian eID authentication |
| **Open Banking (AISP/PISP)** | TBD — provider selection pending | Bank balance read + payment initiation |
| Swan Open Banking | DEPRECATED | Was planned, no longer selected |
| Stripe Issuing | MOCK (future) | Card issuance — no SDK, no API keys |
| Slack | PRODUCTION | Operational alerting via webhook |
| BetterStack | PRODUCTION | External uptime monitoring |

---

## 10. Related Documents

- [API Reference](./api-reference.md)
- [Middleware Design](./middleware-design.md)
- [Service Design](./service-design.md)
- [External Services Integration](./external-services-integration.md)
- [Deployment Architecture](../templates-infra/deployment-architecture.md)
- [Authentication Source](../backend/AUTHENTICATION.md)

---

## Approval
| Role | Name | Date | Signature |
|------|------|------|-----------|
| Author | Platform Architect (AI) | 2026-02-23 | |
| Reviewer | | | |
| Approver | Alem Bašić | | |

# Service Design Document — Payment Service

# Service Design Document — Payment Service

> **Project:** Drop
> **Service:** Payment Service (Remittance + QR Payments)
> **Version:** 0.1.0
> **Date:** 2026-02-23
> **Author:** Platform Architect (AI)
> **Status:** In Review
> **Reviewers:** Alem Bašić (CEO)

## Document History
| Version | Date | Author | Changes |
|---------|------|--------|---------|
| 0.1     | 2026-02-23 | Platform Architect (AI) | Initial draft from source code analysis |

---

## 1. Service Overview

The Payment Service is Drop's core business logic module, responsible for:
1. **Remittance** — international money transfers from Norway to 5 corridors (Serbia, Bosnia, Poland, Pakistan, Turkey)
2. **QR Payments** — instant in-store payments to registered merchants

Drop uses a **PSD2 pass-through model** — it never holds customer money. Payments are PISP-initiated directly from the user's bank account. The service orchestrates: balance verification → fee calculation → atomic debit + transaction record creation.

**Source files:**
- `src/drop-app/src/app/api/transactions/remittance/route.ts`
- `src/drop-app/src/app/api/transactions/qr-payment/route.ts`
- `src/drop-app/src/app/api/transactions/disclosure/route.ts`
- `src/drop-app/src/lib/db.ts` (transaction + bank account operations)

---

## 2. Domain Model

### 2.1 Core Entities

```
User
  ├── has many BankAccount (AISP-read from user's real bank)
  ├── has many Recipient (saved international recipients)
  ├── has many Transaction
  └── has one Merchant (optional — if registered as merchant)

Transaction
  ├── type: "remittance" | "qr_payment"
  ├── status: "processing" | "completed" | "failed"
  ├── sendAmount + sendCurrency (NOK)
  ├── receiveAmount + receiveCurrency (destination)
  ├── exchangeRate
  ├── fee (NOK)
  └── links to: Recipient (remittance) OR Merchant (QR)

BankAccount
  ├── balance (AISP-read cache — NOT Drop-held funds)
  ├── isPrimary
  └── bankName, accountNumber, currency
```

### 2.2 Supported Currency Corridors

| Destination | Currency | Exchange Rate (illustrative) | Fee |
|-------------|----------|------------------------------|-----|
| Serbia | RSD | 11.7 NOK/RSD | 0.5% |
| Bosnia | BAM | 1.04 NOK/BAM | 0.5% |
| Poland | PLN | 0.41 NOK/PLN | 0.5% |
| Pakistan | PKR | 26.8 NOK/PKR | 0.5% |
| Turkey | TRY | 3.45 NOK/TRY | 0.5% |

**QR Payments:** NOK only, fee 1%, instant settlement.

---

## 3. Remittance Service Design

### 3.1 Flow

```
POST /api/transactions/remittance
│
├── 1. requireAuth() — verify JWT cookie + session not revoked
├── 2. Rate limit check (10/min per IP)
├── 3. KYC gate — verify user.kyc_status === 'approved'
├── 4. Validate request body
│      ├── amount: 100–50,000 NOK, max 2 decimal places
│      └── recipientId: must belong to current user
├── 5. Load recipient → extract currency (e.g., RSD)
├── 6. Look up exchange rate for currency
├── 7. Calculate fee (0.5% of amount, rounded to 2 decimals)
├── 8. Load bank account (bankAccountId or primary)
├── 9. Verify balance >= (amount + fee)
├── 10. ATOMIC DATABASE TRANSACTION:
│      ├── Debit bank_accounts.balance by (amount + fee)
│      └── INSERT transaction record (status: 'processing')
└── 11. Return 201 with transaction details
```

### 3.2 Fee Calculation

```typescript
const fee = Math.round(amount * 0.005 * 100) / 100;  // 0.5%, 2 decimal places
const total = amount + fee;
const receiveAmount = Math.round(amount * exchangeRate * 100) / 100;
```

### 3.3 ETA Logic

| Recipient country | ETA |
|-------------------|-----|
| EEA countries | "1-2 business days" |
| Non-EEA countries | "2-4 business days" |

Note: Serbia, Bosnia are non-EEA. Poland is EEA. Pakistan, Turkey are non-EEA.

### 3.4 Transaction Status Flow

```
processing → completed (when PISP provider confirms settlement)
processing → failed (on PISP rejection or bank rejection)
```

**Current implementation:** Status starts as `processing`. Settlement tracking (webhooks from PISP provider) is pending — requires Open Banking provider integration.

---

## 4. QR Payment Service Design

### 4.1 Flow

```
POST /api/transactions/qr-payment
│
├── 1. requireAuth() — verify JWT + session
├── 2. Rate limit check (10/min per IP)
├── 3. Validate request body
│      ├── merchantId: must exist
│      └── amount: 1–100,000 NOK, max 2 decimal places
├── 4. Load merchant
├── 5. Get user's primary bank account
├── 6. Calculate fee (1% of amount)
├── 7. Verify balance >= (amount + fee)
├── 8. ATOMIC DATABASE TRANSACTION:
│      ├── Debit bank_accounts.balance by (amount + fee)
│      └── INSERT transaction record (status: 'completed')
└── 9. Return 201 with transaction details
```

### 4.2 Fee Calculation

```typescript
const fee = Math.round(amount * 0.01 * 100) / 100;  // 1%, 2 decimal places
```

### 4.3 QR Code Format

Merchant QR codes encode: `drop://pay/{merchantId}`

The mobile app scans this URI, extracts `merchantId`, and pre-fills the QR payment form.

---

## 5. Pre-Payment Disclosure

**Endpoint:** `POST /api/transactions/disclosure`

The disclosure endpoint provides full fee transparency BEFORE a payment is initiated, complying with Finansavtaleloven requirements (users must see costs before confirming).

**Response includes:**
- `amount` — send amount
- `fee` — Drop fee (0.5% remittance / 1.0% QR)
- `feePercentage` — percentage
- `exchangeRate` — NOK to destination currency
- `receiveAmount` — amount recipient receives
- `receiveCurrency` — destination currency
- `estimatedDelivery` — ETA string
- `totalCost` — amount + fee

---

## 6. Database Operations

### 6.1 Atomic Transaction Pattern

All payment operations use database transactions to ensure atomicity:

```sql
BEGIN;
  UPDATE bank_accounts
    SET balance = balance - $1
    WHERE id = $2 AND user_id = $3 AND balance >= $1;

  INSERT INTO transactions (id, user_id, type, status, send_amount, ...)
    VALUES ($1, $2, 'remittance', 'processing', ...);
COMMIT;
```

If either operation fails, the entire transaction rolls back — preventing partial state (debit without record, or record without debit).

### 6.2 Balance Check

Balance is checked atomically in the UPDATE statement (`WHERE balance >= required_amount`). If the UPDATE affects 0 rows, the transaction fails with `insufficient_balance` error.

### 6.3 Key Queries

```sql
-- Get transaction with exchange rate detail
SELECT t.*, r.name as recipient_name, r.country as recipient_country,
       er.rate as exchange_rate
FROM transactions t
  LEFT JOIN recipients r ON t.recipient_id = r.id
  LEFT JOIN exchange_rates er ON er.currency = r.currency
WHERE t.id = $1 AND t.user_id = $2;
```

---

## 7. Validation Rules

| Field | Validation | Rule |
|-------|------------|------|
| `amount` (remittance) | `validateAmount()` | 100–50,000 NOK, max 2 decimal places |
| `amount` (QR payment) | `validateAmount()` | 1–100,000 NOK, max 2 decimal places |
| `recipientId` | ownership check | Must exist in `recipients` table for current user |
| `merchantId` | existence check | Must exist in `merchants` table |
| `bankAccountId` | ownership check | Must exist in `bank_accounts` for current user |

---

## 8. Error Handling

| Error | HTTP Status | Code | Trigger |
|-------|------------|------|---------|
| Missing required fields | 400 | `bad_request` | null/undefined required field |
| No bank account | 400 | `no_bank_account` | User has no linked bank account |
| Insufficient balance | 402 | `insufficient_balance` | `balance < (amount + fee)` |
| KYC not approved | 403 | `kyc_required` | `kyc_status !== 'approved'` |
| Recipient not found | 404 | `not_found` | Recipient doesn't belong to user |
| Unsupported currency | 422 | `validation_error` | No exchange rate for currency |
| Rate limited | 429 | `rate_limited` | > 10 req/min per IP |

---

## 9. Audit Trail

Every transaction creates an audit log entry:

```sql
INSERT INTO audit_log (action, user_id, resource_type, resource_id, details)
VALUES ('transaction_created', $userId, 'transaction', $txId, $detailsJson);
```

AML monitoring: `aml_alerts` table is checked for high-value transactions (> NOK 100,000 equivalent per day, per regulatory requirements).

---

## 10. Future: Open Banking Integration (PISP)

Current implementation: balance is tracked in Drop's own database (`bank_accounts.balance`), debited atomically.

**Target architecture (requires Open Banking provider):**
1. Initiate PISP payment at provider API
2. User's actual bank account is debited (not Drop's DB record)
3. Provider webhook confirms settlement
4. Drop updates transaction status from `processing` → `completed`/`failed`

AISP balance refresh: balance in `bank_accounts` should be refreshed via AISP API on each login or dashboard load.

---

## Related Documents

- [API Reference](./api-reference.md)
- [Backend Architecture](./backend-architecture.md)
- [External Services Integration](./external-services-integration.md)
- [Source: SERVICES.md](../backend/SERVICES.md)

---

## Approval
| Role | Name | Date | Signature |
|------|------|------|-----------|
| Author | Platform Architect (AI) | 2026-02-23 | |
| Reviewer | | | |
| Approver | Alem Bašić | | |

# Middleware Design

# Middleware Design Document

> **Project:** {{PROJECT_NAME}}
> **Version:** {{VERSION}}
> **Date:** {{DATE}}
> **Author:** {{AUTHOR}}
> **Status:** Draft | In Review | Approved
> **Reviewers:** {{REVIEWERS}}

## Document History
| Version | Date | Author | Changes |
|---------|------|--------|---------|
| 0.1     | {{DATE}} | {{AUTHOR}} | Initial draft |

---

## 1. Middleware Pipeline Overview

<!-- GUIDANCE: Show the complete ordered middleware stack that every request passes through. -->

```mermaid
sequenceDiagram
    participant Client
    participant CORS as 1. CORS
    participant Security as 2. Security Headers
    participant RequestID as 3. Request ID
    participant RateLimit as 4. Rate Limiter
    participant Logger as 5. Request Logger
    participant Auth as 6. Authentication
    participant Authz as 7. Authorization (RBAC)
    participant Validate as 8. Validation
    participant AuditLog as 9. Audit Logger
    participant Handler as Route Handler

    Client->>CORS: HTTP Request
    CORS->>Security: (CORS headers set)
    Security->>RequestID: (Security headers set)
    RequestID->>RateLimit: (X-Request-ID injected)
    RateLimit->>Logger: (Rate check passed)
    Logger->>Auth: (Request logged)
    Auth->>Authz: (JWT validated, user attached)
    Authz->>Validate: (Permissions verified)
    Validate->>AuditLog: (Input validated & sanitized)
    AuditLog->>Handler: (Audit record written)
    Handler-->>Client: Response
```

**Framework:** `{{NestJS / Express / Fastify / Hono}}`
**Execution order is strict** — changing order may break security guarantees.

---

## 2. Request Lifecycle

### 2.1 CORS Middleware

<!-- GUIDANCE: Define the CORS policy. Overly permissive CORS is a security vulnerability. -->

**Library:** `{{cors / @fastify/cors}}`

**Configuration:**

```ts
// config/cors.config.ts
export const corsConfig = {
  origin: (origin: string, callback: Function) => {
    const allowedOrigins = [
      'https://app.{{domain.com}}',
      'https://admin.{{domain.com}}',
      ...(process.env.NODE_ENV !== 'production'
        ? ['http://localhost:3000', 'http://localhost:3001']
        : []),
    ];

    if (!origin || allowedOrigins.includes(origin)) {
      callback(null, true);
    } else {
      callback(new Error(`Origin ${origin} not allowed by CORS`));
    }
  },
  methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
  allowedHeaders: ['Content-Type', 'Authorization', 'X-Request-ID'],
  exposedHeaders: ['X-Request-ID', 'X-RateLimit-Limit', 'X-RateLimit-Remaining'],
  credentials: true,
  maxAge: 86400, // 24h preflight cache
};
```

**Performance impact:** < 0.1ms per request (header injection only)

---

### 2.2 Security Headers Middleware

**Library:** `helmet`

```ts
app.use(helmet({
  // Content Security Policy
  contentSecurityPolicy: {
    directives: {
      defaultSrc: ["'self'"],
      scriptSrc: ["'self'"],
      styleSrc: ["'self'", "'unsafe-inline'"],
      imgSrc: ["'self'", "data:", "https://cdn.{{domain.com}}"],
      connectSrc: ["'self'", "https://api.{{domain.com}}"],
      frameSrc: ["'none'"],
      objectSrc: ["'none'"],
    },
  },
  // HTTP Strict Transport Security
  hsts: {
    maxAge: 31536000,       // 1 year
    includeSubDomains: true,
    preload: true,
  },
  // Other headers
  referrerPolicy: { policy: 'same-origin' },
  frameguard: { action: 'deny' },
  noSniff: true,            // X-Content-Type-Options: nosniff
  xssFilter: true,          // X-XSS-Protection (legacy browsers)
  hidePoweredBy: true,      // Remove X-Powered-By
}));
```

**Headers set:**

| Header | Value | Purpose |
|--------|-------|---------|
| `Strict-Transport-Security` | `max-age=31536000; includeSubDomains; preload` | Force HTTPS |
| `Content-Security-Policy` | (see above) | XSS prevention |
| `X-Frame-Options` | `DENY` | Clickjacking prevention |
| `X-Content-Type-Options` | `nosniff` | MIME sniffing prevention |
| `Referrer-Policy` | `same-origin` | Referrer privacy |

**Performance impact:** < 0.2ms per request

---

### 2.3 Request ID Middleware

**Purpose:** Correlate logs across services for distributed tracing.

```ts
// middleware/request-id.middleware.ts
export function requestIdMiddleware(req: Request, res: Response, next: NextFunction) {
  const requestId = req.headers['x-request-id'] as string
    || `req_${ulid()}`;

  req.requestId = requestId;
  res.setHeader('X-Request-ID', requestId);

  // Bind to AsyncLocalStorage for log correlation
  requestContext.run({ requestId }, next);
}
```

**Format:** `req_{ulid}` — e.g., `req_01HX7M2K5N3P4Q5R6S7T8V9W0`

---

### 2.4 Authentication Middleware

<!-- GUIDANCE: Define JWT validation logic. This is a security-critical component — document carefully. -->

**Strategy:** JWT Bearer token validation

```ts
// guards/jwt.guard.ts
@Injectable()
export class JwtGuard implements CanActivate {
  async canActivate(context: ExecutionContext): Promise<boolean> {
    const request = context.switchToHttp().getRequest();
    const token = this.extractToken(request);

    if (!token) throw new UnauthorizedException('No token provided');

    try {
      const payload = await this.jwtService.verifyAsync(token, {
        secret: this.configService.get('JWT_SECRET'),
        algorithms: ['HS256'],
        clockTolerance: 10, // 10 second clock skew tolerance
      });

      // Attach to request for downstream use
      request.user = {
        id: payload.sub,
        email: payload.email,
        role: payload.role,
      };

      return true;
    } catch (error) {
      if (error instanceof TokenExpiredError) {
        throw new UnauthorizedException('TOKEN_EXPIRED');
      }
      throw new UnauthorizedException('INVALID_TOKEN');
    }
  }

  private extractToken(request: Request): string | null {
    const [type, token] = request.headers.authorization?.split(' ') ?? [];
    return type === 'Bearer' ? token : null;
  }
}
```

**Performance impact:** ~2-5ms (crypto operation + optional DB lookup for token revocation)

**Token revocation check:** `{{Check Redis blocklist on each request | Check on logout only | Use short TTL — no revocation check}}`

---

### 2.5 Authorization Middleware (RBAC/ABAC)

<!-- GUIDANCE: Define role and permission enforcement. -->

**Model:** `{{RBAC (Role-Based) | ABAC (Attribute-Based) | Hybrid}}`

```ts
// decorators/roles.decorator.ts
export const Roles = (...roles: Role[]) => SetMetadata('roles', roles);
export const RequirePermission = (permission: string) =>
  SetMetadata('permission', permission);

// guards/roles.guard.ts
@Injectable()
export class RolesGuard implements CanActivate {
  canActivate(context: ExecutionContext): boolean {
    const requiredRoles = this.reflector.get<Role[]>('roles', context.getHandler());
    const requiredPermission = this.reflector.get<string>('permission', context.getHandler());

    if (!requiredRoles && !requiredPermission) return true; // Public route

    const { user } = context.switchToHttp().getRequest();

    if (requiredRoles && !requiredRoles.includes(user.role)) {
      throw new ForbiddenException(`Requires role: ${requiredRoles.join(' or ')}`);
    }

    if (requiredPermission && !this.hasPermission(user, requiredPermission)) {
      throw new ForbiddenException(`Requires permission: ${requiredPermission}`);
    }

    return true;
  }
}

// Usage on controller
@Get('users')
@Roles(Role.ADMIN)
@RequirePermission('users:read')
async listUsers() { ... }
```

**Role hierarchy:**

```
admin > manager > user > viewer > public
```

| Role | Capabilities |
|------|-------------|
| `admin` | Full access |
| `manager` | Read/write own org resources |
| `user` | Read/write own resources |
| `viewer` | Read-only |

---

### 2.6 Validation Middleware

<!-- GUIDANCE: Define input validation and sanitization approach. -->

**Library:** `class-validator + class-transformer` OR `zod`

```ts
// Global validation pipe (NestJS)
app.useGlobalPipes(new ValidationPipe({
  whitelist: true,              // Strip unknown properties
  forbidNonWhitelisted: true,  // Throw if unknown properties present
  transform: true,              // Auto-transform to DTO types
  transformOptions: {
    enableImplicitConversion: true,
  },
}));
```

**Sanitization rules:**
- All string inputs: trim whitespace
- HTML content: sanitize with `DOMPurify` / `sanitize-html` (strip dangerous tags)
- SQL parameters: always use parameterized queries (ORM handles this)
- File uploads: validate MIME type by magic bytes (not just extension)

**Validation error format:**
```json
{
  "error": {
    "code": "VALIDATION_ERROR",
    "message": "Request validation failed",
    "details": [
      { "field": "email", "message": "must be an email" },
      { "field": "name", "message": "must be longer than 2 characters" }
    ]
  }
}
```

**Performance impact:** < 1ms for typical DTOs

---

### 2.7 Rate Limiting Middleware

<!-- GUIDANCE: Define the rate limiting algorithm, storage, and per-endpoint configuration. -->

**Library:** `{{@nestjs/throttler | express-rate-limit | rate-limiter-flexible}}`
**Storage:** `{{Redis}}` (shared across all replicas)

**Algorithms:**

| Algorithm | Library | Best For |
|-----------|---------|----------|
| Fixed window | `express-rate-limit` | Simple, low overhead |
| Sliding window | `rate-limiter-flexible` | Accurate, no burst at window edge |
| Token bucket | `rate-limiter-flexible` | Bursty traffic patterns |

**Selected algorithm:** `{{Sliding window}}`

**Configuration:**

```ts
const rateLimiter = new RateLimiterRedis({
  storeClient: redisClient,
  keyPrefix: 'rl',
  points: 1000,         // Number of points
  duration: 60,         // Per 60 seconds
  blockDuration: 60,    // Block for 60s after exceeded
});

// Per-route overrides
const loginLimiter = new RateLimiterRedis({
  points: 5,
  duration: 900,        // 15 minutes
  blockDuration: 900,
});
```

**Key strategy:** `{{IP address | User ID (if authenticated) | IP + User ID}}`

**Performance impact:** ~1-2ms (Redis round-trip)

---

### 2.8 Audit Logging Middleware

<!-- GUIDANCE: Define what is audit logged and PII handling rules. -->

```ts
// interceptors/audit.interceptor.ts
@Injectable()
export class AuditInterceptor implements NestInterceptor {
  intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
    const request = context.switchToHttp().getRequest();
    const { method, path, user, requestId } = request;

    // Only audit mutating operations
    if (['POST', 'PUT', 'PATCH', 'DELETE'].includes(method)) {
      this.auditService.log({
        requestId,
        userId: user?.id,
        method,
        path,
        body: this.sanitizeBody(request.body), // Strip PII
        timestamp: new Date().toISOString(),
      });
    }

    return next.handle();
  }

  private sanitizeBody(body: Record<string, unknown>) {
    const REDACTED_FIELDS = ['password', 'token', 'creditCard', 'ssn'];
    return Object.fromEntries(
      Object.entries(body).map(([key, value]) =>
        REDACTED_FIELDS.includes(key) ? [key, '[REDACTED]'] : [key, value]
      )
    );
  }
}
```

**What IS logged:**
- User ID, request ID, timestamp, method, path
- Response status code, duration
- Mutation summaries (what changed, not full values)

**What is NEVER logged:**
- Passwords, tokens, API keys
- Payment card data
- Full PII fields (log field names but not values for sensitive fields)

**Audit log retention:** `{{1 year}}` (compliance requirement: `{{GDPR / SOC2 / internal}}`)

---

### 2.9 Error Handling Middleware

<!-- GUIDANCE: Define the global exception handler. All errors must be normalized before reaching the client. -->

```ts
// filters/global-exception.filter.ts
@Catch()
export class GlobalExceptionFilter implements ExceptionFilter {
  catch(exception: unknown, host: ArgumentsHost) {
    const ctx = host.switchToHttp();
    const response = ctx.getResponse<Response>();
    const request = ctx.getRequest<Request>();

    let status = 500;
    let code = 'INTERNAL_ERROR';
    let message = 'An unexpected error occurred';
    let details: unknown[] = [];

    if (exception instanceof HttpException) {
      status = exception.getStatus();
      const exceptionResponse = exception.getResponse() as any;
      code = exceptionResponse.code ?? 'HTTP_ERROR';
      message = exceptionResponse.message ?? exception.message;
      details = exceptionResponse.details ?? [];
    }

    // Log 5xx errors (not 4xx — those are client errors)
    if (status >= 500) {
      this.logger.error('Unhandled exception', { exception, requestId: request.requestId });
      this.sentryService.captureException(exception);
    }

    response.status(status).json({
      error: {
        code,
        message,
        details,
        requestId: request.requestId,
        timestamp: new Date().toISOString(),
      },
    });
  }
}
```

---

## 3. Custom Middleware Development Guide

<!-- GUIDANCE: Define the standard for writing new middleware. -->

**Template for new middleware:**

```ts
// middleware/{{name}}.middleware.ts
import { Injectable, NestMiddleware } from '@nestjs/common';
import { Request, Response, NextFunction } from 'express';

@Injectable()
export class {{Name}}Middleware implements NestMiddleware {
  use(req: Request, res: Response, next: NextFunction): void {
    // 1. Extract needed data from request
    // 2. Perform validation/enrichment/logging
    // 3. Set data on request object if needed
    // 4. Call next() or throw HttpException

    next();
  }
}
```

**Requirements for new middleware:**
- [ ] Handles errors without crashing the process
- [ ] Calls `next()` exactly once (or throws)
- [ ] Does not block async operations without async/await
- [ ] Performance impact documented
- [ ] Unit tests covering happy path + error path

---

## 4. Middleware Ordering & Dependencies

```
Request → [1] → [2] → [3] → [4] → [5] → [6] → [7] → [8] → [9] → Handler

[1] CORS          — No dependencies
[2] Security      — No dependencies
[3] Request ID    — Must be before Logger (Logger reads requestId)
[4] Rate Limiter  — Must be after Request ID (uses requestId for key)
[5] Logger        — Must be after Request ID
[6] Auth          — Must be after Logger (Logger should log auth failures)
[7] Authorization — MUST be after Auth (requires user on request)
[8] Validation    — MUST be after Auth (DTOs may reference user context)
[9] Audit Logger  — MUST be after Auth (logs user ID)
```

**NEVER reorder middleware without reviewing this dependency chain.**

---

## 5. Performance Impact Per Middleware

| Middleware | Avg Latency Added | P99 Latency Added | Notes |
|-----------|------------------|------------------|-------|
| CORS | 0.05ms | 0.1ms | Header injection only |
| Security Headers (Helmet) | 0.1ms | 0.2ms | Header injection only |
| Request ID | 0.1ms | 0.2ms | ID generation |
| Rate Limiter | 1.5ms | 5ms | Redis round-trip |
| Request Logger | 0.5ms | 1ms | Async log write |
| Authentication (JWT) | 3ms | 8ms | Crypto + optional Redis |
| Authorization | 0.5ms | 1ms | In-memory role check |
| Validation | 0.8ms | 2ms | Schema parsing |
| Audit Logger | 0.5ms | 1ms | Async DB write |
| **Total** | **~7ms** | **~18ms** | Middleware overhead |

**Target: middleware overhead < 10ms P50, < 25ms P99.**

---

## 6. Testing Strategy for Middleware

```ts
// Example unit test for Auth middleware
describe('JwtGuard', () => {
  it('should attach user to request on valid token', async () => {
    const token = generateTestToken({ sub: 'usr_123', role: 'user' });
    const mockRequest = { headers: { authorization: `Bearer ${token}` } };
    const result = await guard.canActivate(createMockContext(mockRequest));
    expect(result).toBe(true);
    expect(mockRequest.user).toMatchObject({ id: 'usr_123', role: 'user' });
  });

  it('should throw UnauthorizedException on expired token', async () => {
    const expiredToken = generateExpiredToken();
    const mockRequest = { headers: { authorization: `Bearer ${expiredToken}` } };
    await expect(guard.canActivate(createMockContext(mockRequest)))
      .rejects.toThrow('TOKEN_EXPIRED');
  });
});
```

**Test coverage requirements:**
- Each middleware: ≥ 90% line coverage
- Security middleware (Auth, AuthZ, Validation): 100% branch coverage

---

## 7. Configuration Options Per Middleware

| Middleware | Environment Variable | Default | Description |
|-----------|---------------------|---------|-------------|
| CORS | `CORS_ORIGINS` | `localhost:3000` | Comma-separated allowed origins |
| Rate Limit | `RATE_LIMIT_POINTS` | `1000` | Requests per window |
| Rate Limit | `RATE_LIMIT_DURATION` | `60` | Window size in seconds |
| Rate Limit (auth) | `AUTH_RATE_LIMIT_POINTS` | `5` | Login attempts per window |
| Audit Log | `AUDIT_LOG_RETENTION_DAYS` | `365` | How long to keep audit records |
| Request Body | `MAX_REQUEST_BODY_SIZE` | `1mb` | Max request body size |

---

## Approval
| Role | Name | Date | Signature |
|------|------|------|-----------|
| Author | | | |
| Backend Lead | | | |
| Security Lead | | | |
| Tech Lead | | | |

# External Services Integration

# External Services Integration

> **Project:** {{PROJECT_NAME}}
> **Version:** {{VERSION}}
> **Date:** {{DATE}}
> **Author:** {{AUTHOR}}
> **Status:** Draft | In Review | Approved
> **Reviewers:** {{REVIEWERS}}

## Document History
| Version | Date | Author | Changes |
|---------|------|--------|---------|
| 0.1     | {{DATE}} | {{AUTHOR}} | Initial draft |

---

## 1. Integration Inventory

<!-- GUIDANCE: List every external service dependency. This is the single source of truth for external integrations. -->

| Service | Category | Criticality | SLA | Owner Team | Status |
|---------|----------|-------------|-----|------------|--------|
| `{{Stripe}}` | Payments | Critical | 99.99% | `{{Backend}}` | `{{Active}}` |
| `{{SendGrid}}` | Email delivery | High | 99.95% | `{{Backend}}` | `{{Active}}` |
| `{{Twilio}}` | SMS | Medium | 99.95% | `{{Backend}}` | `{{Active}}` |
| `{{AWS S3}}` | File storage | High | 99.99% | `{{Infrastructure}}` | `{{Active}}` |
| `{{Sentry}}` | Error tracking | Low | — | `{{DevOps}}` | `{{Active}}` |
| `{{Google Maps API}}` | Geocoding | Medium | 99.9% | `{{Backend}}` | `{{Active}}` |
| `{{NAME}}` | `{{Category}}` | `{{Critical/High/Medium/Low}}` | `{{X.XX%}}` | `{{Team}}` | `{{Status}}` |

**Criticality definitions:**
- **Critical:** Service outage causes complete feature failure for end users
- **High:** Service outage degrades core functionality
- **Medium:** Service outage affects non-critical features
- **Low:** Monitoring/internal tools — no user impact

---

## 2. Per-Service Integration

<!-- GUIDANCE: Complete one section per service from the inventory above. -->

---

### 2.1 Stripe (Payments)

| Property | Value |
|----------|-------|
| **Purpose** | Payment processing, subscription billing |
| **API docs** | `https://stripe.com/docs/api` |
| **Auth method** | Secret key (Bearer token) |
| **Credentials** | Vault: `stripe/secret-key-{{env}}` |
| **Webhook secret** | Vault: `stripe/webhook-secret-{{env}}` |
| **SDK** | `stripe` npm package v14.x |
| **API version** | `2023-10-16` (pinned) |

**Key endpoints used:**

| Operation | Stripe API | Notes |
|-----------|-----------|-------|
| Create customer | `POST /v1/customers` | On user registration |
| Create payment intent | `POST /v1/payment_intents` | Checkout flow |
| Confirm payment | `POST /v1/payment_intents/:id/confirm` | After client confirms |
| Create subscription | `POST /v1/subscriptions` | Subscription plans |
| Cancel subscription | `DELETE /v1/subscriptions/:id` | User-initiated cancel |

**Request example:**
```ts
const paymentIntent = await stripe.paymentIntents.create({
  amount: 1000, // in cents
  currency: 'nok',
  customer: customer.stripeId,
  metadata: { orderId, userId },
  automatic_payment_methods: { enabled: true },
});
```

**Error handling:**
```ts
try {
  await stripe.paymentIntents.create(params);
} catch (err) {
  if (err instanceof Stripe.errors.StripeCardError) {
    throw new PaymentDeclinedException(err.message);
  }
  if (err instanceof Stripe.errors.StripeRateLimitError) {
    throw new ServiceTemporarilyUnavailableException('Payment service rate limited');
  }
  // Log unexpected errors to Sentry
  this.sentry.captureException(err);
  throw new PaymentServiceException('Unexpected payment error');
}
```

**Retry policy:** Stripe SDK handles retries on network errors automatically. Business-level failures (card declined) are NOT retried.

**Circuit breaker:** `{{Yes — breaker trips after 5 consecutive failures, opens for 30s}}`

**Fallback:** No fallback for payments — fail clearly with user-facing error message.

**Webhooks consumed:**

| Event | Handler | Action |
|-------|---------|--------|
| `payment_intent.succeeded` | `PaymentSucceededHandler` | Mark order paid |
| `payment_intent.payment_failed` | `PaymentFailedHandler` | Notify user, release hold |
| `customer.subscription.deleted` | `SubscriptionCancelledHandler` | Downgrade user |

**Rate limits:** 100 read requests/s, 100 write requests/s per secret key.
**Cost:** Per transaction (see Finance: Stripe billing dashboard).

---

### 2.2 SendGrid (Email)

| Property | Value |
|----------|-------|
| **Purpose** | Transactional email delivery |
| **API docs** | `https://docs.sendgrid.com/api-reference` |
| **Auth method** | API key (Authorization: Bearer) |
| **Credentials** | Vault: `sendgrid/api-key-{{env}}` |
| **SDK** | `@sendgrid/mail` npm package v8.x |
| **From email** | `{{noreply@domain.com}}` (verified sender) |

**Key operations:**

| Operation | Template | Trigger |
|-----------|----------|---------|
| Welcome email | `d-XXXX` | User registration |
| Password reset | `d-XXXX` | Forgot password flow |
| Order confirmation | `d-XXXX` | Order placed |
| Invoice | `d-XXXX` | Invoice generated |

**Request example:**
```ts
await sgMail.send({
  to: user.email,
  from: { email: 'noreply@domain.com', name: '{{APP_NAME}}' },
  templateId: 'd-XXXXXXXXXXXXXXXXXXXXXX',
  dynamicTemplateData: {
    firstName: user.name.split(' ')[0],
    orderNumber: order.number,
    orderTotal: formatCurrency(order.total),
  },
});
```

**Error handling:**
```ts
try {
  await sgMail.send(message);
} catch (err) {
  if (err.code === 429) {
    // Queue for retry
    await this.emailQueue.add('retry_email', message, { delay: 60000 });
  } else {
    this.logger.error('SendGrid error', { code: err.code, message: err.message });
    // Don't throw — email failure is non-critical for most flows
  }
}
```

**Retry policy:** 3 retries via BullMQ queue with 60s, 300s, 900s backoff.
**Fallback:** `{{Postmark as backup SMTP | Log and alert team — no fallback}}`
**Rate limits:** 100 emails/s on Pro plan.

---

### 2.3 AWS S3 (File Storage)

| Property | Value |
|----------|-------|
| **Purpose** | User file uploads, generated reports, media |
| **Auth method** | IAM Role (EC2/ECS) or AWS Access Key |
| **Credentials** | IAM role (preferred) / Vault: `aws/s3-access-key-{{env}}` |
| **SDK** | `@aws-sdk/client-s3` v3.x |
| **Buckets** | See table below |

**Bucket configuration:**

| Bucket | Access | Lifecycle | Purpose |
|--------|--------|-----------|---------|
| `{{company}}-uploads-{{env}}` | Private | 90 day expiry for tmp | User uploads |
| `{{company}}-exports-{{env}}` | Private | 7 day expiry | Generated exports/reports |
| `{{company}}-public-{{env}}` | Public (CDN) | None | Marketing assets, public images |

**Pre-signed URL pattern:**
```ts
const command = new PutObjectCommand({
  Bucket: process.env.S3_UPLOADS_BUCKET,
  Key: `${userId}/${ulid()}.${extension}`,
  ContentType: mimeType,
  ContentLength: fileSize,
});

const presignedUrl = await getSignedUrl(s3Client, command, { expiresIn: 900 });
```

**Retry policy:** AWS SDK retries with exponential backoff by default (max 3 retries).
**Circuit breaker:** Breaker trips after 10 consecutive failures.
**Fallback:** `{{Cloudflare R2 as fallback storage | Abort upload with user error}}`

---

### 2.4 {{SERVICE_NAME}}

<!-- GUIDANCE: Copy this section for each additional external service. -->

| Property | Value |
|----------|-------|
| **Purpose** | `{{PURPOSE}}` |
| **API docs** | `{{URL}}` |
| **Auth method** | `{{API Key / OAuth2 / Basic Auth}}` |
| **Credentials** | Vault: `{{vault/path}}` |
| **SDK** | `{{package@version or "Direct HTTP"}}` |

**Key endpoints used:**

| Operation | Endpoint | Notes |
|-----------|----------|-------|
| `{{Operation}}` | `{{Method}} {{/path}}` | `{{Notes}}` |

**Request example:**
```ts
// TODO: Add representative request example
```

**Error handling:**
```ts
// TODO: Define error handling strategy
```

**Retry policy:** `{{Exponential backoff: 1s, 2s, 4s, max 3 retries}}`
**Circuit breaker:** `{{Yes/No — threshold: X failures in Y seconds}}`
**Fallback / degradation:** `{{Define fallback behavior}}`
**Rate limits:** `{{X requests per Y}}`
**Cost:** `{{Pricing model reference}}`
**Monitoring:** `{{Alert name and dashboard link}}`

---

## 3. SDK vs Direct API Call Decisions

<!-- GUIDANCE: Document the rationale for each integration approach. -->

| Service | Approach | Rationale |
|---------|----------|-----------|
| Stripe | SDK | SDK handles retry logic, type safety, webhook verification |
| SendGrid | SDK | SDK simplifies template rendering, attachment handling |
| AWS S3 | SDK v3 | Modular SDK reduces bundle size; handles signing |
| `{{Service}}` | Direct HTTP | No official SDK, lightweight wrapper sufficient |
| `{{Service}}` | SDK | `{{Reason}}` |

**Wrapper pattern** — all integrations are wrapped in a service class:

```ts
// services/stripe.service.ts — abstraction over Stripe SDK
@Injectable()
export class StripeService {
  // Exposes only operations the app actually needs
  // Hides Stripe-specific implementation details
  // Makes testing easier (injectable, mockable)
  async createPaymentIntent(amount: number, currency: string): Promise<PaymentIntent> { ... }
}
```

---

## 4. Mock / Stub Strategy for Development & Testing

<!-- GUIDANCE: Define how external services are mocked in development and test environments. -->

| Environment | Strategy |
|-------------|----------|
| Unit tests | Jest manual mocks (`__mocks__/stripe.ts`) |
| Integration tests | Nock HTTP interceptors OR test-mode credentials |
| Local development | Test API keys (Stripe test mode, SendGrid sandbox) |
| E2E / staging | Live test-mode credentials — real API calls to sandbox |
| Production | Live production credentials |

**Mock setup example:**
```ts
// __mocks__/@sendgrid/mail.ts
const sendMock = jest.fn().mockResolvedValue([{ statusCode: 202 }]);
export default { send: sendMock, setApiKey: jest.fn() };

// In tests
import sgMail from '@sendgrid/mail';
expect(sgMail.send).toHaveBeenCalledWith(expect.objectContaining({
  templateId: 'd-XXXXX',
}));
```

**Test mode credentials location:** `.env.test` (gitignored) — see onboarding guide.

---

## 5. Vendor Lock-In Assessment

<!-- GUIDANCE: Assess the switching cost for each integration. -->

| Service | Lock-in Level | Switching Cost | Migration Complexity |
|---------|--------------|----------------|---------------------|
| Stripe | Medium | High (webhook events, customer IDs) | `{{2-4 weeks}}` |
| SendGrid | Low | Low (standard SMTP + template export) | `{{1-2 days}}` |
| AWS S3 | Medium | Medium (URL changes, S3-compatible APIs) | `{{1 week}}` |

**Mitigation strategy:** All integrations wrapped in service classes with defined interfaces. Swapping provider = rewrite service class, not application logic.

---

## 6. Migration Plan (Switching Providers)

<!-- GUIDANCE: For each critical service, outline the migration path if a provider switch becomes necessary. -->

### Stripe → Alternative Payment Provider

**Trigger conditions:** Pricing increase > 30%, reliability < 99.9%, compliance issues.

**Migration steps:**
1. Select alternative (Adyen, Braintree, etc.) and obtain test credentials
2. Implement new `PaymentService` adapter behind feature flag
3. Test in staging with full payment flow
4. Migrate new customers to new provider
5. Migrate existing subscription customers (requires customer consent in some jurisdictions)
6. Deprecate Stripe integration (keep webhooks active until all subscriptions migrated)

**Data to migrate:** Customer IDs (map old → new), subscription IDs.

---

## Approval
| Role | Name | Date | Signature |
|------|------|------|-----------|
| Author | | | |
| Backend Lead | | | |
| Finance / Legal (payment integrations) | | | |
| Security Reviewer | | | |

# Event Schema Documentation

# Event Schema Documentation

> **Project:** {{PROJECT_NAME}}
> **Version:** {{VERSION}}
> **Date:** {{DATE}}
> **Author:** {{AUTHOR}}
> **Status:** Draft | In Review | Approved
> **Reviewers:** {{REVIEWERS}}

## Document History
| Version | Date | Author | Changes |
|---------|------|--------|---------|
| 0.1     | {{DATE}} | {{AUTHOR}} | Initial draft |

---

## 1. Event-Driven Architecture Overview

<!-- GUIDANCE: Describe the event-driven topology. Show which services publish and which consume. -->

```mermaid
graph LR
    subgraph "Publishers"
        UserService["user-service"]
        OrderService["order-service"]
        PaymentService["payment-service"]
    end

    subgraph "Message Broker"
        Broker["{{Kafka / RabbitMQ / AWS SQS + SNS}}"]
    end

    subgraph "Consumers"
        NotifService["notification-service"]
        AnalyticsService["analytics-service"]
        SearchService["search-service"]
        AuditService["audit-service"]
    end

    UserService -->|user.* events| Broker
    OrderService -->|order.* events| Broker
    PaymentService -->|payment.* events| Broker

    Broker -->|filtered| NotifService
    Broker -->|all events| AnalyticsService
    Broker -->|entity events| SearchService
    Broker -->|all events| AuditService
```

**Event-driven use cases in this system:**
- Decoupled notifications (user.created → send welcome email)
- Search index updates (entity.updated → reindex)
- Audit trail (all mutations → audit log)
- Cross-service data sync (order.created → update inventory)

---

## 2. Message Broker Configuration

<!-- GUIDANCE: Document the broker technology, topic/queue naming, and infrastructure setup. -->

**Broker:** `{{Apache Kafka | RabbitMQ | AWS SQS/SNS | NATS | Upstash Kafka}}`
**Version:** `{{3.x}}`
**Hosting:** `{{Confluent Cloud / self-hosted / AWS MSK}}`

### Topic / Queue Naming Convention

```
{{DOMAIN}}.{{ENTITY}}.{{ACTION}}

Examples:
  user.user.created
  order.order.status_changed
  payment.invoice.generated
  notification.email.sent
```

**Pattern rules:**
- All lowercase, dot-separated
- Domain prefix = service name (without `-service`)
- Entity = singular noun
- Action = past tense verb (created, updated, deleted, completed)

### Topic Configuration

| Topic | Partitions | Replication | Retention | Compaction |
|-------|-----------|-------------|-----------|------------|
| `user.user.*` | 6 | 3 | 7 days | No |
| `order.order.*` | 12 | 3 | 30 days | No |
| `payment.invoice.*` | 6 | 3 | 90 days | No |
| `*.*.deleted` | 6 | 3 | 30 days | Log compaction |

---

## 3. Event Naming Conventions

| Component | Rule | Examples |
|-----------|------|---------|
| Full event type | `{domain}.{entity}.{action}` | `user.user.created` |
| Domain | Lowercase, matches service prefix | `user`, `order`, `payment` |
| Entity | Singular noun, lowercase with underscores | `user`, `order_item`, `invoice` |
| Action | Past-tense verb, lowercase with underscores | `created`, `updated`, `status_changed`, `payment_failed` |

**Do NOT use:**
- Present tense (`user.user.create` — wrong)
- Generic names (`user.user.changed` — too vague)
- Abbreviations (`usr.usr.crtd` — unreadable)

---

## 4. Event Envelope Format (CloudEvents 1.0)

<!-- GUIDANCE: ALL events must follow this envelope. The `data` field contains the domain payload. -->

```json
{
  "specversion": "1.0",
  "type": "{{DOMAIN}}.{{ENTITY}}.{{ACTION}}",
  "source": "{{SERVICE_NAME}}",
  "id": "evt_01HX7M2K5N3P4Q5R6S7T8V9W0",
  "time": "2024-01-15T10:30:00.000Z",
  "datacontenttype": "application/json",
  "subject": "{{optional: entity ID}}",
  "data": {
    "{{field}}": "{{value}}"
  }
}
```

**Envelope fields:**

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `specversion` | string | Yes | Always `"1.0"` |
| `type` | string | Yes | Event type (see naming convention) |
| `source` | string | Yes | Emitting service name |
| `id` | string | Yes | Unique event ID (ULID format) |
| `time` | string | Yes | ISO 8601 timestamp (UTC) |
| `datacontenttype` | string | Yes | Always `"application/json"` |
| `subject` | string | No | Primary entity ID (for routing) |
| `data` | object | Yes | Domain-specific event payload |

**TypeScript interface:**
```ts
interface CloudEvent<T = Record<string, unknown>> {
  specversion: '1.0';
  type: string;
  source: string;
  id: string;
  time: string;
  datacontenttype: 'application/json';
  subject?: string;
  data: T;
}
```

---

## 5. Per-Event Documentation

<!-- GUIDANCE: Add one subsection per event type. Group by publisher service. -->

---

### 5.1 User Service Events

#### user.user.created

Published when a new user account is created.

| Property | Value |
|----------|-------|
| **Publisher** | `user-service` |
| **Consumers** | `notification-service`, `analytics-service`, `audit-service` |
| **Topic** | `user.user.created` |
| **Ordering guarantee** | Per user ID (partitioned by subject) |
| **Idempotency key** | `id` (event ID) — consumers must deduplicate |
| **Retry behavior** | Consumer retries up to 5x before DLQ |

**Payload schema (JSON Schema):**
```json
{
  "$schema": "http://json-schema.org/draft-07/schema#",
  "type": "object",
  "required": ["userId", "email", "name", "role", "createdAt"],
  "properties": {
    "userId": { "type": "string", "format": "uuid" },
    "email": { "type": "string", "format": "email" },
    "name": { "type": "string", "minLength": 1 },
    "role": { "type": "string", "enum": ["admin", "user", "viewer"] },
    "createdAt": { "type": "string", "format": "date-time" }
  }
}
```

**Example event:**
```json
{
  "specversion": "1.0",
  "type": "user.user.created",
  "source": "user-service",
  "id": "evt_01HX7M2K5N3P4Q5R6S7T8V9W0",
  "time": "2024-01-15T10:30:00.000Z",
  "datacontenttype": "application/json",
  "subject": "usr_01HX7...",
  "data": {
    "userId": "usr_01HX7...",
    "email": "newuser@example.com",
    "name": "Jane Doe",
    "role": "user",
    "createdAt": "2024-01-15T10:30:00.000Z"
  }
}
```

---

#### user.user.updated

Published when user profile data changes.

| Property | Value |
|----------|-------|
| **Publisher** | `user-service` |
| **Consumers** | `search-service`, `notification-service`, `analytics-service` |
| **Topic** | `user.user.updated` |
| **Ordering guarantee** | Per user ID |
| **Idempotency key** | `id` (event ID) |

**Payload schema:**
```json
{
  "type": "object",
  "required": ["userId", "updatedFields", "updatedAt"],
  "properties": {
    "userId": { "type": "string" },
    "updatedFields": {
      "type": "array",
      "items": { "type": "string" },
      "description": "List of field names that changed"
    },
    "before": { "type": "object", "description": "Previous values (only changed fields)" },
    "after": { "type": "object", "description": "New values (only changed fields)" },
    "updatedAt": { "type": "string", "format": "date-time" }
  }
}
```

**Example event:**
```json
{
  "specversion": "1.0",
  "type": "user.user.updated",
  "source": "user-service",
  "id": "evt_01HX8...",
  "time": "2024-01-16T08:00:00.000Z",
  "datacontenttype": "application/json",
  "subject": "usr_01HX7...",
  "data": {
    "userId": "usr_01HX7...",
    "updatedFields": ["name"],
    "before": { "name": "Jane Doe" },
    "after": { "name": "Jane Smith" },
    "updatedAt": "2024-01-16T08:00:00.000Z"
  }
}
```

---

#### user.user.deleted

Published when a user account is soft-deleted.

| Property | Value |
|----------|-------|
| **Publisher** | `user-service` |
| **Consumers** | `order-service`, `notification-service`, `analytics-service` |
| **Payload** | `{ userId, deletedAt, reason }` |
| **Ordering guarantee** | Per user ID |

**Example event:**
```json
{
  "specversion": "1.0",
  "type": "user.user.deleted",
  "source": "user-service",
  "id": "evt_01HX9...",
  "time": "2024-01-17T12:00:00.000Z",
  "datacontenttype": "application/json",
  "subject": "usr_01HX7...",
  "data": {
    "userId": "usr_01HX7...",
    "deletedAt": "2024-01-17T12:00:00.000Z",
    "reason": "user_requested"
  }
}
```

---

### 5.2 Order Service Events

#### order.order.created

| Property | Value |
|----------|-------|
| **Publisher** | `order-service` |
| **Consumers** | `payment-service`, `notification-service`, `inventory-service` |
| **Topic** | `order.order.created` |
| **Ordering guarantee** | Per order ID |

**Payload schema:**
```json
{
  "type": "object",
  "required": ["orderId", "userId", "items", "total", "currency", "createdAt"],
  "properties": {
    "orderId": { "type": "string" },
    "userId": { "type": "string" },
    "items": {
      "type": "array",
      "items": {
        "type": "object",
        "properties": {
          "productId": { "type": "string" },
          "quantity": { "type": "integer" },
          "unitPrice": { "type": "number" }
        }
      }
    },
    "total": { "type": "number" },
    "currency": { "type": "string", "pattern": "^[A-Z]{3}$" },
    "createdAt": { "type": "string", "format": "date-time" }
  }
}
```

---

### 5.3 {{DOMAIN}} Service Events

<!-- GUIDANCE: Add domain sections following the same pattern for each publishing service. -->

#### {{domain}}.{{entity}}.{{action}}

| Property | Value |
|----------|-------|
| **Publisher** | `{{service-name}}` |
| **Consumers** | `{{consumer-a, consumer-b}}` |
| **Topic** | `{{domain.entity.action}}` |
| **Ordering guarantee** | `{{Per entity ID | No guarantee}}` |
| **Idempotency key** | `{{id}}` |

**Payload schema:** `TODO: Define JSON Schema`

**Example event:** `TODO: Add example`

---

## 6. Dead Letter Queue Handling

<!-- GUIDANCE: Define how failed messages are handled after max retries. -->

**DLQ naming:** `{{topic}}.dlq` — e.g., `user.user.created.dlq`

**DLQ workflow:**
```
Event Published
    ↓
Consumer processes
    ↓ Fails
Retry (exp. backoff: 1s, 2s, 4s, 8s, 16s — max 5 retries)
    ↓ All retries exhausted
Move to DLQ
    ↓
Alert fires: PagerDuty P3
    ↓
On-call investigates
    ↓
Option A: Fix consumer bug → replay from DLQ
Option B: Skip message (data was invalid) → log + discard
```

**DLQ message format:**
```json
{
  "originalEvent": { /* original CloudEvent */ },
  "failureReason": "Consumer threw: Cannot read property 'id' of undefined",
  "attemptCount": 5,
  "firstFailedAt": "2024-01-15T10:30:00Z",
  "lastFailedAt": "2024-01-15T10:32:00Z",
  "consumerGroup": "notification-service-consumer"
}
```

**DLQ retention:** 14 days.
**DLQ alert threshold:** > 10 messages in DLQ within 5 minutes.

---

## 7. Event Versioning Strategy

<!-- GUIDANCE: Define how event schemas evolve without breaking consumers. -->

**Strategy:** Backward-compatible field addition + major version in event type.

**Rules:**
1. Adding optional fields: allowed without version bump
2. Removing fields: NOT allowed (use deprecation first, remove after all consumers updated)
3. Changing field types: NOT allowed (breaking change)
4. Adding required fields: requires version bump
5. Major breaking change: new event type `user.user.created.v2`

**Deprecation process:**
```
1. Mark field as deprecated in schema docs
2. Notify all consumer teams
3. Wait 2 sprint cycles (4 weeks minimum)
4. Remove field from schema
5. Update documentation
```

**Schema registry:** `{{Confluent Schema Registry | AWS Glue Schema Registry | Manual docs}}`
**Validation:** Consumer validates incoming events against pinned schema version.

---

## 8. Event Replay Capability

<!-- GUIDANCE: Define whether and how events can be replayed. -->

**Replay supported:** `{{Yes — Kafka log retention | No — events are ephemeral}}`

**Replay scenarios:**
- Bug in consumer → fix bug → replay affected time window
- New consumer onboarded → replay historical events to build initial state
- Data migration → replay events to new storage

**Replay procedure:**
1. Identify topic and time range to replay
2. Coordinate with all consumer teams (replay may cause duplicate side effects)
3. Ensure consumers are idempotent before replay
4. Set consumer offset to target timestamp: `kafka-consumer-groups --reset-offsets --to-datetime`
5. Restart consumer with temporary consumer group to avoid affecting production offset
6. Verify replayed state is correct
7. Switch production consumer to new state

**Retention periods by topic:** See topic configuration table in Section 2.

---

## 9. Monitoring & Observability

<!-- GUIDANCE: Define what is monitored in the event pipeline. -->

| Metric | Alert Threshold | Severity | Channel |
|--------|----------------|----------|---------|
| Consumer lag (per topic) | > 10,000 messages | P2 | Slack `#alerts` |
| DLQ depth | > 10 messages / 5min | P3 | Slack `#alerts` |
| Producer error rate | > 1% / 5min | P1 | PagerDuty |
| Consumer error rate | > 5% / 5min | P2 | PagerDuty |
| Event processing latency P99 | > 5 seconds | P3 | Slack `#alerts` |

**Dashboard:** `{{https://monitoring.domain.com/dashboards/events}}`
**Distributed tracing:** All events carry `traceparent` header (OpenTelemetry W3C Trace Context).

---

## 10. Testing Event-Driven Flows

<!-- GUIDANCE: Define the testing approach for event producers and consumers. -->

### Unit Tests

```ts
// Test producer: verify event shape
it('should publish user.created event with correct schema', async () => {
  await userService.create(createUserDto);

  expect(eventBus.publish).toHaveBeenCalledWith(
    expect.objectContaining({
      type: 'user.user.created',
      source: 'user-service',
      data: expect.objectContaining({
        userId: expect.any(String),
        email: createUserDto.email,
      }),
    })
  );
});

// Test consumer: verify handler idempotency
it('should not send welcome email twice for duplicate event', async () => {
  const event = buildUserCreatedEvent();
  await handler.handle(event);
  await handler.handle(event); // duplicate
  expect(emailService.send).toHaveBeenCalledTimes(1);
});
```

### Integration Tests

```ts
// Use real broker in integration tests (testcontainers)
const kafka = await new KafkaContainer('confluentinc/cp-kafka:7.5.0').start();
```

### E2E Tests

Test full event chain: API action → event published → consumer processes → side effect visible.

```
POST /users → poll for welcome email (SendGrid sandbox) → assert received within 5s
```

---

## Approval
| Role | Name | Date | Signature |
|------|------|------|-----------|
| Author | | | |
| Backend Lead | | | |
| Platform / Infrastructure Lead | | | |
| Architect | | | |

# Backend Architecture

# Backend Architecture Document

> **Project:** {{PROJECT_NAME}}
> **Version:** {{VERSION}}
> **Date:** {{DATE}}
> **Author:** {{AUTHOR}}
> **Status:** Draft | In Review | Approved
> **Reviewers:** {{REVIEWERS}}

## Document History
| Version | Date | Author | Changes |
|---------|------|--------|---------|
| 0.1     | {{DATE}} | {{AUTHOR}} | Initial draft |

---

## 1. Architecture Pattern

<!-- GUIDANCE: Define and justify the chosen architecture pattern. -->

**Pattern:** `{{Modular Monolith | Microservices | Monolith | Event-Driven Microservices}}`

| Pattern Considered | Pros | Cons | Decision |
|-------------------|------|------|----------|
| Monolith | Simple deploy, low latency | Scaling bottleneck, team coupling | `{{Selected/Rejected}}` |
| Modular Monolith | Organized, single deploy, module isolation | Shared DB risk | `{{Selected/Rejected}}` |
| Microservices | Independent scaling, team autonomy | Operational complexity | `{{Selected/Rejected}}` |

**Rationale:**
> TODO: 3-5 sentences explaining the decision considering team size, scale requirements, and operational maturity.

---

## 2. Technology Stack

<!-- GUIDANCE: Document every layer of the technology stack with version pins. -->

| Layer | Technology | Version | Notes |
|-------|-----------|---------|-------|
| Runtime | `{{Node.js}}` | `{{20.x LTS}}` | |
| Framework | `{{NestJS / Express / Fastify / Hono}}` | `{{10.x}}` | |
| ORM | `{{Prisma / TypeORM / Drizzle}}` | `{{5.x}}` | |
| Primary DB | `{{PostgreSQL}}` | `{{16.x}}` | Managed: `{{RDS / Supabase}}` |
| Cache | `{{Redis}}` | `{{7.x}}` | Managed: `{{ElastiCache / Upstash}}` |
| Queue | `{{BullMQ / SQS / RabbitMQ}}` | `{{5.x}}` | |
| Search | `{{Elasticsearch / MeiliSearch / Typesense}}` | `{{8.x}}` | Optional |
| File storage | `{{AWS S3 / Cloudflare R2}}` | API | |
| Auth | `{{Custom JWT / Auth0 / Supabase Auth}}` | | |
| Logging | `{{Pino / Winston}}` | `{{8.x}}` | → `{{Datadog / Loki}}` |
| APM | `{{Datadog / Sentry / Elastic APM}}` | | |
| API docs | `{{Swagger / OpenAPI 3.1}}` | `{{3.1}}` | |

---

## 3. Project Structure

<!-- GUIDANCE: Define the folder layout. For NestJS, show module-per-feature. For Express, show layer-per-concern. -->

```
src/
├── modules/                # Feature modules (NestJS) / route handlers (Express)
│   ├── users/
│   │   ├── users.module.ts
│   │   ├── users.controller.ts
│   │   ├── users.service.ts
│   │   ├── users.repository.ts
│   │   ├── dto/
│   │   │   ├── create-user.dto.ts
│   │   │   └── update-user.dto.ts
│   │   └── entities/
│   │       └── user.entity.ts
│   ├── auth/
│   ├── notifications/
│   └── {{FEATURE}}/
├── common/
│   ├── decorators/         # Custom decorators
│   ├── filters/            # Exception filters
│   ├── guards/             # Auth / role guards
│   ├── interceptors/       # Logging, transform interceptors
│   ├── pipes/              # Validation pipes
│   └── middleware/         # Request middleware
├── database/
│   ├── migrations/
│   └── seeds/
├── config/
│   ├── app.config.ts
│   ├── database.config.ts
│   └── redis.config.ts
├── jobs/                   # Background job definitions
└── main.ts                 # Application entry point

test/
├── unit/
├── integration/
└── e2e/
```

---

## 4. Request Processing Pipeline

<!-- GUIDANCE: Show the full lifecycle of an HTTP request through the system. -->

```mermaid
sequenceDiagram
    participant Client
    participant Gateway as API Gateway / LB
    participant Middleware as Middleware Stack
    participant Guard as Guards
    participant Pipe as Validation Pipe
    participant Controller
    participant Service
    participant Repository
    participant DB as Database

    Client->>Gateway: HTTP Request
    Gateway->>Middleware: Forward (with tracing headers)
    Middleware->>Middleware: Request ID, CORS, Security Headers, Rate Limit
    Middleware->>Guard: Authenticated request
    Guard->>Guard: JWT verification, Role check
    Guard->>Pipe: Authorized request
    Pipe->>Pipe: Schema validation (Zod/class-validator)
    Pipe->>Controller: Validated DTO
    Controller->>Service: Business method call
    Service->>Repository: Data access call
    Repository->>DB: Query
    DB-->>Repository: Result
    Repository-->>Service: Domain entity
    Service-->>Controller: Response data
    Controller-->>Client: HTTP Response (transformed)
```

---

## 5. Middleware Stack Configuration

<!-- GUIDANCE: Define every middleware applied globally and the execution order. -->

**Execution order (applied left-to-right):**

| Order | Middleware | Purpose | Global? |
|-------|-----------|---------|---------|
| 1 | `helmet` | Security headers (CSP, HSTS, etc.) | Yes |
| 2 | `cors` | CORS policy enforcement | Yes |
| 3 | `request-id` | Inject `X-Request-ID` header | Yes |
| 4 | `compression` | gzip response compression | Yes |
| 5 | `body-parser` | Parse JSON/urlencoded bodies | Yes |
| 6 | `rate-limiter` | IP-based rate limiting (Redis) | Yes |
| 7 | `request-logger` | Structured request logging | Yes |
| 8 | Route-specific middleware | Auth, validation per route | No |

**Security headers configured via Helmet:**

```ts
app.use(helmet({
  contentSecurityPolicy: { /* ... */ },
  hsts: { maxAge: 31536000, includeSubDomains: true, preload: true },
  referrerPolicy: { policy: 'same-origin' },
}));
```

---

## 6. Authentication & Authorization Flow

<!-- GUIDANCE: Define the full auth lifecycle — login, token issuance, validation, refresh, logout. -->

```mermaid
flowchart TD
    Login["POST /auth/login\n{email, password}"] --> ValidateCreds["Validate credentials\n(bcrypt compare)"]
    ValidateCreds -->|Invalid| Reject["401 Unauthorized"]
    ValidateCreds -->|Valid| IssuePair["Issue token pair\naccess (15m) + refresh (30d)"]
    IssuePair --> StoreRefresh["Store refresh token\n(hashed, Redis or DB)"]
    IssuePair --> ReturnTokens["Return tokens to client"]

    AuthReq["Authenticated Request"] --> ExtractJWT["Extract Bearer token"]
    ExtractJWT --> VerifyJWT["Verify signature + expiry"]
    VerifyJWT -->|Invalid/Expired| RefreshFlow["POST /auth/refresh"]
    VerifyJWT -->|Valid| CheckRoles["Role/permission check"]
    CheckRoles -->|Unauthorized| Forbidden["403 Forbidden"]
    CheckRoles -->|Authorized| Handler["Route Handler"]

    RefreshFlow --> VerifyRefresh["Verify refresh token\n(hash match, not revoked)"]
    VerifyRefresh -->|Invalid| Logout["Force logout → 401"]
    VerifyRefresh -->|Valid| RotateToken["Rotate tokens\n(old token revoked)"]
```

**RBAC / ABAC:**
- Roles: `{{admin | manager | user | viewer}}`
- Permissions: `{{resource:action}}` e.g. `users:delete`
- Role-permission mapping: `{{database table | config file | code}}`

---

## 7. Database Access Patterns

<!-- GUIDANCE: Define the patterns used for database interaction — repository pattern, unit of work, direct queries. -->

**Pattern:** `{{Repository Pattern}}`

```ts
// Repository interface — decouples business logic from storage
interface UserRepository {
  findById(id: string): Promise<User | null>;
  findByEmail(email: string): Promise<User | null>;
  findMany(filters: UserFilters): Promise<PaginatedResult<User>>;
  create(data: CreateUserData): Promise<User>;
  update(id: string, data: UpdateUserData): Promise<User>;
  delete(id: string): Promise<void>;
}
```

**Query performance rules:**
- All queries accessing > 1000 rows must have paginator
- All filterable fields must have DB indexes (document in migration)
- N+1 queries forbidden — use `include`/JOIN or `dataloader` pattern
- Raw SQL allowed only when ORM cannot express the query efficiently

---

## 8. Caching Architecture

<!-- GUIDANCE: Define the caching layers and what is cached at each level. -->

```mermaid
graph LR
    Request --> L1["L1: In-Memory\n(node-cache)"]
    L1 -->|Cache miss| L2["L2: Redis\n(shared, distributed)"]
    L2 -->|Cache miss| DB["Database"]

    DB --> L2
    L2 --> L1
    L1 --> Response
```

| Layer | Technology | TTL | What's Cached |
|-------|-----------|-----|--------------|
| L1 (in-process) | `node-cache` / Map | 30 sec | Config, feature flags |
| L2 (distributed) | Redis | Per resource | User sessions, API responses |
| L3 (CDN edge) | Cloudflare / CloudFront | Per route | Public API responses |

**Cache-aside pattern (L2):**

```ts
async function getCachedUser(id: string): Promise<User> {
  const cacheKey = `user:${id}`;
  const cached = await redis.get(cacheKey);
  if (cached) return JSON.parse(cached);

  const user = await userRepository.findById(id);
  await redis.setex(cacheKey, 300, JSON.stringify(user)); // 5 min TTL
  return user;
}
```

**Cache invalidation strategy:**
- On update/delete: `await redis.del(cacheKey)`
- Pattern-based: `await redis.del('user:*')` (use sparingly — expensive)
- Tag-based: `{{ioredis-tag | custom tagging}}`

---

## 9. Background Job Processing

<!-- GUIDANCE: Define how async/background work is queued and processed. -->

**Library:** `{{BullMQ | Agenda | pg-boss}}`
**Queue storage:** `{{Redis | PostgreSQL}}`

| Queue | Job Types | Concurrency | Retry Policy |
|-------|-----------|-------------|-------------|
| `emails` | Welcome, reset password, notifications | 10 | 3 retries, exp. backoff |
| `uploads` | Image processing, file conversion | 5 | 2 retries |
| `sync` | External API sync, data aggregation | 3 | 5 retries |
| `reports` | PDF generation, exports | 2 | 1 retry |

**Job schema:**
```ts
interface EmailJob {
  type: 'welcome' | 'password_reset' | 'notification';
  to: string;
  templateId: string;
  data: Record<string, unknown>;
}
```

**Monitoring:** `{{Bull Board | BullMQ Metrics API}}` — admin UI at `{{/admin/queues}}`

---

## 10. File Storage & Media Handling

<!-- GUIDANCE: Define how files are uploaded, stored, processed, and served. -->

**Storage provider:** `{{AWS S3 | Cloudflare R2 | MinIO}}`
**Bucket naming:** `{{company-project-env}}` (e.g., `alai-app-production`)

**Upload flow:**
1. Client requests pre-signed URL from API (`POST /uploads/presigned`)
2. API validates file type, size, generates pre-signed URL (expiry: 15 min)
3. Client uploads directly to storage (bypasses API server)
4. Client notifies API of upload completion (`POST /uploads/confirm`)
5. API validates file exists, creates database record, triggers processing job

**File size limits:**
| Type | Max Size |
|------|----------|
| Profile images | 5 MB |
| Documents | 25 MB |
| Videos | 500 MB |

---

## 11. Logging & Observability

<!-- GUIDANCE: Define the logging strategy, log levels, and what is always logged. -->

**Logger:** `{{Pino}}` — structured JSON logs
**Log aggregation:** `{{Datadog / Loki / CloudWatch}}`

**Log levels policy:**
| Level | When to use |
|-------|------------|
| `error` | Exceptions, failures requiring attention |
| `warn` | Unexpected but handled situations |
| `info` | Significant business events (user created, order placed) |
| `debug` | Detailed diagnostic info — dev/staging only |
| `trace` | Verbose request tracing — never in production |

**Always log:**
- Request: method, path, request ID, user ID (hashed), status code, duration
- Errors: full stack trace, request context
- Background jobs: job ID, queue, start/end, duration, outcome

**Never log:**
- Passwords, tokens, API keys
- Full request/response bodies with PII
- Payment card data

---

## 12. Configuration Management

<!-- GUIDANCE: Define how configuration is loaded, validated, and accessed. -->

**Pattern:** Typed config module with validation on startup

```ts
// config/app.config.ts
const schema = z.object({
  NODE_ENV: z.enum(['development', 'staging', 'production']),
  PORT: z.coerce.number().default(4000),
  DATABASE_URL: z.string().url(),
  REDIS_URL: z.string().url(),
  JWT_SECRET: z.string().min(32),
  // ...
});

export const config = schema.parse(process.env);
// App fails FAST at startup if any required variable is missing/invalid
```

**Secrets management:** `{{HashiCorp Vault | AWS Secrets Manager | Doppler}}`
**NO secrets in environment files committed to git.**

---

## 13. Health Check Endpoints

<!-- GUIDANCE: Define health check endpoints for load balancer and Kubernetes probes. -->

| Endpoint | Type | Checks |
|----------|------|--------|
| `GET /health/live` | Liveness | Process is running |
| `GET /health/ready` | Readiness | DB connected, Redis connected, app ready |
| `GET /health/startup` | Startup | Migrations run, config valid |

**Readiness check response:**
```json
{
  "status": "ok",
  "checks": {
    "database": { "status": "ok", "latency": 3 },
    "redis": { "status": "ok", "latency": 1 },
    "queue": { "status": "ok", "pendingJobs": 12 }
  },
  "version": "1.2.3",
  "uptime": 3600
}
```

---

## 14. Architecture Diagram

```mermaid
graph TB
    subgraph "Clients"
        Web["Web App"]
        Mobile["Mobile App"]
    end

    subgraph "Infrastructure"
        LB["Load Balancer\n(Nginx / ALB)"]
        API["API Server\n({{FRAMEWORK}})"]
        Workers["Background Workers\n(BullMQ)"]
    end

    subgraph "Data"
        DB["PostgreSQL\n(Primary + Replicas)"]
        Cache["Redis\n(Cache + Queue)"]
        Storage["Object Storage\n(S3 / R2)"]
    end

    subgraph "Observability"
        Logs["Log Aggregation\n(Datadog / Loki)"]
        APM["APM / Tracing"]
    end

    Web --> LB
    Mobile --> LB
    LB --> API
    API --> DB
    API --> Cache
    API --> Storage
    API --> Cache
    Workers --> Cache
    Workers --> DB
    API --> Logs
    Workers --> Logs
    API --> APM
```

---

## Approval
| Role | Name | Date | Signature |
|------|------|------|-----------|
| Author | | | |
| Backend Lead | | | |
| Tech Lead / Architect | | | |
| Security Reviewer | | | |

# Service Design

# Service Design Document

> **Project:** {{PROJECT_NAME}}
> **Service:** {{SERVICE_NAME}}
> **Version:** {{VERSION}}
> **Date:** {{DATE}}
> **Author:** {{AUTHOR}}
> **Status:** Draft | In Review | Approved
> **Reviewers:** {{REVIEWERS}}

## Document History
| Version | Date | Author | Changes |
|---------|------|--------|---------|
| 0.1     | {{DATE}} | {{AUTHOR}} | Initial draft |

---

## 1. Service Overview

<!-- GUIDANCE: Define the service's purpose, bounded context, and ownership clearly. This is the "elevator pitch" for the service. -->

| Property | Value |
|----------|-------|
| **Service name** | `{{service-name}}` |
| **Bounded context** | `{{Domain / bounded context name}}` |
| **Repository** | `{{https://github.com/org/service-name}}` |
| **Owner team** | `{{Team Name}}` |
| **On-call** | `{{PagerDuty rotation / team contact}}` |
| **Runbook** | `{{https://wiki.domain.com/runbooks/service-name}}` |
| **Tech stack** | `{{Node.js 20 + NestJS + PostgreSQL + Redis}}` |

**Purpose:**
> TODO: 2-3 sentences. What does this service do? What business capability does it own? What is explicitly OUT of scope?

**Bounded context:**
This service owns the **{{DOMAIN}}** domain. It is the single source of truth for `{{entities owned}}`. Other services must NOT directly access this service's database — they must call its API or subscribe to its events.

---

## 2. Service Responsibility & Ownership

<!-- GUIDANCE: Define what this service IS and IS NOT responsible for. Prevent scope creep. -->

**This service IS responsible for:**
- `{{Primary responsibility 1}}`
- `{{Primary responsibility 2}}`
- `{{Primary responsibility 3}}`

**This service is NOT responsible for:**
- `{{Out-of-scope concern 1 — handled by service X}}`
- `{{Out-of-scope concern 2}}`

**Data ownership:**
- Owns: `{{users, user_profiles, user_preferences tables}}`
- Does NOT own: `{{orders (belongs to order-service)}}`

---

## 3. Interface Definition

<!-- GUIDANCE: Define every way this service communicates with the outside world. -->

### 3.1 REST API Endpoints

| Method | Path | Description | Auth |
|--------|------|-------------|------|
| `GET` | `/{{service}}/health` | Health check | None |
| `GET` | `/{{service}}/{{resource}}` | List `{{resources}}` | Bearer JWT |
| `GET` | `/{{service}}/{{resource}}/:id` | Get by ID | Bearer JWT |
| `POST` | `/{{service}}/{{resource}}` | Create | Bearer JWT |
| `PATCH` | `/{{service}}/{{resource}}/:id` | Update | Bearer JWT |
| `DELETE` | `/{{service}}/{{resource}}/:id` | Delete | Bearer JWT |

**Internal endpoints** (service-to-service only, no external access):

| Method | Path | Description | Auth |
|--------|------|-------------|------|
| `GET` | `/internal/{{resource}}/:id` | Bulk lookup by IDs | Service API key |

**Full API reference:** See `api-reference.md` or `{{OpenAPI URL}}`

---

### 3.2 gRPC Service Definition (if applicable)

```protobuf
// proto/{{service_name}}.proto
syntax = "proto3";

package {{service_name}};

service {{ServiceName}}Service {
  rpc Get{{Resource}} (Get{{Resource}}Request) returns ({{Resource}});
  rpc List{{Resources}} (List{{Resources}}Request) returns (List{{Resources}}Response);
  rpc Create{{Resource}} (Create{{Resource}}Request) returns ({{Resource}});
}

message {{Resource}} {
  string id = 1;
  string name = 2;
  string created_at = 3;
}

message Get{{Resource}}Request {
  string id = 1;
}
```

**TODO:** Remove or populate gRPC section based on actual communication protocol.

---

### 3.3 Events Published

| Event Type | Trigger | Topic / Queue | Consumer(s) |
|-----------|---------|--------------|-------------|
| `{{domain}}.{{entity}}.created` | Entity created | `{{topic-name}}` | `{{service-a, service-b}}` |
| `{{domain}}.{{entity}}.updated` | Entity updated | `{{topic-name}}` | `{{service-a}}` |
| `{{domain}}.{{entity}}.deleted` | Soft delete | `{{topic-name}}` | `{{service-b}}` |

**Example published event:**
```json
{
  "specversion": "1.0",
  "type": "{{domain}}.{{entity}}.created",
  "source": "{{service-name}}",
  "id": "evt_01HX7...",
  "time": "2024-01-15T10:30:00Z",
  "datacontenttype": "application/json",
  "data": {
    "id": "{{UUID}}",
    "{{field}}": "{{value}}"
  }
}
```

**Full event schemas:** See `event-schema-documentation.md`

---

### 3.4 Events Consumed

| Event Type | Source Service | Handler Action |
|-----------|---------------|----------------|
| `{{domain}}.{{entity}}.created` | `{{source-service}}` | `{{Action this service takes}}` |
| `{{domain}}.{{entity}}.deleted` | `{{source-service}}` | `{{Action — e.g., cascade delete}}` |

**Consumer group:** `{{service-name}}-consumer`
**Idempotency:** All handlers are idempotent (duplicate events produce same result).

---

## 4. Database

### 4.1 Technology & Rationale

| Property | Value |
|----------|-------|
| Database | `{{PostgreSQL 16}}` |
| ORM | `{{Prisma 5}}` |
| Rationale | `{{Why this DB was chosen}}` |
| Hosting | `{{AWS RDS / Supabase / Self-hosted}}` |
| Replication | `{{1 primary + 2 read replicas}}` |
| Backup | `{{Daily snapshot + WAL archiving}}` |
| Encryption | At rest and in transit |

---

### 4.2 Schema Overview

```mermaid
erDiagram
    USERS {
        uuid id PK
        string email UK
        string name
        string role
        string status
        timestamp created_at
        timestamp updated_at
        timestamp deleted_at
    }

    USER_PROFILES {
        uuid id PK
        uuid user_id FK
        string avatar_url
        string bio
        jsonb settings
        timestamp updated_at
    }

    USER_SESSIONS {
        uuid id PK
        uuid user_id FK
        string refresh_token_hash
        string ip_address
        timestamp expires_at
        timestamp created_at
    }

    USERS ||--o| USER_PROFILES : has
    USERS ||--o{ USER_SESSIONS : has
```

**TODO:** Update schema to reflect actual tables. Add missing tables.

---

### 4.3 Data Ownership Boundaries

- **Read access:** Any service may query via this service's API
- **Write access:** ONLY this service writes to its tables
- **Direct DB access:** FORBIDDEN for all other services

**Cross-service data pattern:**
```
Service A needs user name:
  → GET /users/:id via HTTP (NOT direct DB query)
  → Or subscribe to user.updated events and cache locally
```

---

## 5. Dependencies

### 5.1 Upstream Services (Services This Depends On)

| Service | Purpose | Criticality | Fallback |
|---------|---------|-------------|---------|
| `{{auth-service}}` | JWT validation | Critical | Cache valid tokens 5 min |
| `{{notification-service}}` | Send emails | Non-critical | Queue for retry |
| `{{{{EXTERNAL_API}}}}` | `{{Purpose}}` | `{{Critical/Non-critical}}` | `{{Fallback strategy}}` |

---

### 5.2 Downstream Services (Services That Depend On This)

| Service | How it uses this service | Impact if this service is down |
|---------|--------------------------|-------------------------------|
| `{{order-service}}` | Validate user exists before creating order | Cannot create orders |
| `{{notification-service}}` | Resolve user email for delivery | Cannot send user notifications |

---

### 5.3 External APIs & Third-Party

| Service | Purpose | Rate Limit | Credentials |
|---------|---------|-----------|-------------|
| `{{SendGrid}}` | Transactional email | 100 req/s | Vault: `sendgrid/api-key` |
| `{{Stripe}}` | Payment processing | — | Vault: `stripe/secret-key` |

---

### 5.4 Dependency Diagram

```mermaid
graph LR
    ThisService["{{service-name}}"]

    subgraph "Upstream (depends on)"
        AuthService["auth-service"]
        ExternalAPI["external-api"]
    end

    subgraph "Downstream (depended on by)"
        OrderService["order-service"]
        NotifService["notification-service"]
    end

    AuthService --> ThisService
    ExternalAPI --> ThisService
    ThisService --> OrderService
    ThisService --> NotifService
```

---

## 6. Deployment Configuration

<!-- GUIDANCE: Define the Kubernetes/Docker deployment parameters. -->

| Property | Dev | Staging | Production |
|----------|-----|---------|-----------|
| Replicas | 1 | 2 | `{{min: 3, max: 10}}` |
| CPU request | 100m | 250m | 500m |
| CPU limit | 500m | 1000m | 2000m |
| Memory request | 128Mi | 256Mi | 512Mi |
| Memory limit | 512Mi | 1Gi | 2Gi |
| Port | 4000 | 4000 | 4000 |

**Kubernetes manifest location:** `{{k8s/{{service-name}}/}}`
**Helm chart:** `{{charts/{{service-name}}/}}`
**Docker image:** `{{registry.domain.com/service-name}}`

---

## 7. Scaling Strategy

<!-- GUIDANCE: Define both horizontal and vertical scaling approach. -->

| Dimension | Strategy | Trigger |
|-----------|----------|---------|
| Horizontal (replicas) | HPA: CPU > 70% OR RPS > 1000 | Automatic |
| Vertical (resources) | VPA recommendations reviewed monthly | Manual |
| Database | Read replicas for SELECT queries | Manual |
| Cache | Redis Cluster when > 10GB RAM | Manual |

**Stateless confirmation:** This service stores NO session state in memory — safe to scale horizontally.

---

## 8. Health Check & Readiness Probes

```yaml
livenessProbe:
  httpGet:
    path: /health/live
    port: 4000
  initialDelaySeconds: 30
  periodSeconds: 10
  failureThreshold: 3

readinessProbe:
  httpGet:
    path: /health/ready
    port: 4000
  initialDelaySeconds: 10
  periodSeconds: 5
  failureThreshold: 3

startupProbe:
  httpGet:
    path: /health/startup
    port: 4000
  failureThreshold: 30
  periodSeconds: 10
```

---

## 9. SLA Commitments

<!-- GUIDANCE: Define the service level agreement for consumers of this service. -->

| Metric | Target | Measurement Window |
|--------|--------|-------------------|
| Availability | 99.9% (8.7h downtime/year) | Rolling 30 days |
| P50 response time | < 50ms | 1 hour |
| P95 response time | < 200ms | 1 hour |
| P99 response time | < 500ms | 1 hour |
| Error rate (5xx) | < 0.1% | 1 hour |

**SLA breach escalation:** Alert → PagerDuty `{{on-call rotation}}` → Incident declared at SLA breach risk.

---

## 10. Monitoring & Alerting Rules

<!-- GUIDANCE: Define what is monitored and alert thresholds. -->

| Metric | Threshold | Alert Severity | Channel |
|--------|-----------|---------------|---------|
| Error rate (5xx) | > 1% for 5 min | P1 | PagerDuty |
| P99 latency | > 1s for 5 min | P2 | Slack `#alerts` |
| CPU utilization | > 85% for 10 min | P3 | Slack `#alerts` |
| Memory utilization | > 80% | P3 | Slack `#alerts` |
| DB connection pool | > 80% | P2 | PagerDuty |
| Queue depth | > 10,000 items | P2 | Slack `#alerts` |

**Dashboard:** `{{https://monitoring.domain.com/dashboards/service-name}}`

---

## 11. Runbook Reference

**Runbook location:** `{{https://wiki.domain.com/runbooks/{{service-name}}}}`

Quick reference for common incidents:

| Incident | Initial Response |
|----------|-----------------|
| High error rate | Check logs → identify error pattern → scale up if OOM |
| High latency | Check DB slow query log → check Redis hit rate → check upstream dependency |
| Pod crash loop | Check OOMKilled → check logs → check health probe thresholds |
| DB connection exhaustion | Check pool config → check idle connections → force disconnect |

---

## Approval
| Role | Name | Date | Signature |
|------|------|------|-----------|
| Author | | | |
| Service Owner | | | |
| Architect | | | |
| SRE Lead | | | |

# Database Schema

# Drop Database Schema

> Source: `src/shared/db/schema.ts` (Drizzle ORM schema — single source of truth for all environments)

## Overview

Drop uses **PostgreSQL 16** as the sole database engine in all environments (development, CI, staging, production).
Database access is via **Drizzle ORM**. There is no SQLite dependency and no dual-driver abstraction.

- **Local dev:** PostgreSQL 16 in Docker (`docker compose up -d`), port 5433
- **CI:** PostgreSQL 16 service container in GitHub Actions
- **Production:** PostgreSQL 16 on AWS RDS (`db.t3.small`)
- **Schema definition:** `src/shared/db/schema.ts` (Drizzle schema, TypeScript, PostgreSQL-native)
- **Migrations:** managed by `drizzle-kit`

See [ADR-014](../architecture/adr/ADR-014-postgresql-only.md) for the full rationale.

**Total tables:** 19 (12 core + 7 compliance)

---

## Tables

### users

Primary user accounts.

| Column        | Type | Constraints                            | Default           |
| ------------- | ---- | -------------------------------------- | ----------------- |
| id            | TEXT | PRIMARY KEY                            | -                 |
| email         | TEXT | UNIQUE NOT NULL                        | -                 |
| password_hash | TEXT | NOT NULL                               | -                 |
| first_name    | TEXT | NOT NULL                               | -                 |
| last_name     | TEXT | NOT NULL                               | -                 |
| phone         | TEXT | -                                      | NULL              |
| date_of_birth | TEXT | -                                      | NULL              |
| kyc_status    | TEXT | CHECK('pending','approved','rejected') | 'pending'         |
| role          | TEXT | CHECK('user','merchant')               | 'user'            |
| created_at    | TEXT | -                                      | CURRENT_TIMESTAMP |

**ID format:** `usr_` + 16 hex chars (generated by `randomId("usr")` in `utils-server.ts:4`)

**Password hashing:** bcrypt with 12 rounds (`utils-server.ts:8-11`)

---

### recipients

Saved remittance recipients per user.

| Column       | Type | Constraints              | Default           |
| ------------ | ---- | ------------------------ | ----------------- |
| id           | TEXT | PRIMARY KEY              | -                 |
| user_id      | TEXT | NOT NULL, FK → users(id) | -                 |
| name         | TEXT | NOT NULL                 | -                 |
| country      | TEXT | NOT NULL                 | -                 |
| currency     | TEXT | NOT NULL                 | -                 |
| bank_account | TEXT | NOT NULL                 | -                 |
| bank_name    | TEXT | -                        | NULL              |
| created_at   | TEXT | -                        | CURRENT_TIMESTAMP |

**Supported countries:** RS, BA, PL, PK, TR (enforced at API level, not DB level)

**Index:** `idx_recipients_user` on `user_id`

---

### merchants

Registered merchant profiles for QR payments.

| Column        | Type | Constraints              | Default           |
| ------------- | ---- | ------------------------ | ----------------- |
| id            | TEXT | PRIMARY KEY              | -                 |
| user_id       | TEXT | NOT NULL, FK → users(id) | -                 |
| business_name | TEXT | NOT NULL                 | -                 |
| org_number    | TEXT | UNIQUE NOT NULL          | -                 |
| address       | TEXT | -                        | NULL              |
| bank_account  | TEXT | NOT NULL                 | -                 |
| fee_rate      | REAL | -                        | 0.01              |
| status        | TEXT | -                        | 'active'          |
| created_at    | TEXT | -                        | CURRENT_TIMESTAMP |

**Index:** `idx_merchants_org` on `org_number`

---

### transactions

All financial transactions (remittances and QR payments).

| Column           | Type | Constraints                                | Default           |
| ---------------- | ---- | ------------------------------------------ | ----------------- |
| id               | TEXT | PRIMARY KEY                                | -                 |
| user_id          | TEXT | NOT NULL, FK → users(id)                   | -                 |
| type             | TEXT | NOT NULL, CHECK('remittance','qr_payment') | -                 |
| status           | TEXT | CHECK('processing','completed','failed')   | 'processing'      |
| amount           | REAL | NOT NULL                                   | -                 |
| currency         | TEXT | -                                          | 'NOK'             |
| fee              | REAL | -                                          | 0                 |
| recipient_id     | TEXT | FK → recipients(id)                        | NULL              |
| merchant_id      | TEXT | FK → merchants(id)                         | NULL              |
| send_amount      | REAL | -                                          | NULL              |
| send_currency    | TEXT | -                                          | NULL              |
| receive_amount   | REAL | -                                          | NULL              |
| receive_currency | TEXT | -                                          | NULL              |
| exchange_rate    | REAL | -                                          | NULL              |
| created_at       | TEXT | -                                          | CURRENT_TIMESTAMP |
| completed_at     | TEXT | -                                          | NULL              |

**Indexes:** `idx_transactions_user` on `user_id`, `idx_transactions_merchant` on `merchant_id`

**Notes:**

- Remittances have `recipient_id` set, `merchant_id` NULL
- QR payments have `merchant_id` set, `recipient_id` NULL
- `send_*` / `receive_*` / `exchange_rate` fields are populated for remittances only

---

### exchange_rates

Currency exchange rates (from NOK).

| Column        | Type   | Constraints | Default           |
| ------------- | ------ | ----------- | ----------------- |
| id            | SERIAL | PRIMARY KEY | AUTOINCREMENT     |
| from_currency | TEXT   | -           | 'NOK'             |
| to_currency   | TEXT   | NOT NULL    | -                 |
| rate          | REAL   | NOT NULL    | -                 |
| updated_at    | TEXT   | -           | CURRENT_TIMESTAMP |

**Seed data (`apps/drop-api/src/lib/db.ts`):**

| Corridor  | Rate  |
| --------- | ----- |
| NOK → RSD | 10.17 |
| NOK → BAM | 0.17  |
| NOK → PLN | 0.374 |
| NOK → PKR | 26.5  |
| NOK → TRY | 3.39  |
| NOK → EUR | 0.087 |

Live/provider refresh details are documented in [Currency Rates](CURRENCY-RATES.md).

---

### bank_accounts

Linked bank accounts (AISP — Open Banking read in production, mock balances in dev).

> **Pass-through model:** Drop NEVER holds customer money. The `balance` column stores the last AISP-read balance from the user's real bank account — it is a cached read-only value, not a Drop-held balance. In dev/demo mode, mock balances are seeded for testing.

| Column         | Type    | Constraints              | Default           |
| -------------- | ------- | ------------------------ | ----------------- |
| id             | TEXT    | PRIMARY KEY              | -                 |
| user_id        | TEXT    | NOT NULL, FK → users(id) | -                 |
| bank_name      | TEXT    | NOT NULL                 | -                 |
| account_number | TEXT    | NOT NULL                 | -                 |
| iban           | TEXT    | -                        | NULL              |
| balance        | REAL    | -                        | 0                 |
| currency       | TEXT    | -                        | 'NOK'             |
| is_primary     | INTEGER | -                        | 0                 |
| connected_at   | TEXT    | -                        | CURRENT_TIMESTAMP |

**Index:** `idx_bank_accounts_user` on `user_id`

---

### cards (FUTURE — feature-flagged)

> **Note:** Cards are a FUTURE feature, gated behind feature flags (all default to `false`). This table exists in the schema but is not actively used until a card issuing partner is integrated.

Virtual and physical payment cards.

| Column           | Type | Constraints                          | Default                                                               |
| ---------------- | ---- | ------------------------------------ | --------------------------------------------------------------------- |
| id               | TEXT | PRIMARY KEY                          | -                                                                     |
| user_id          | TEXT | NOT NULL, FK → users(id)             | -                                                                     |
| type             | TEXT | CHECK('virtual','physical')          | 'virtual'                                                             |
| last_four        | TEXT | NOT NULL                             | -                                                                     |
| token_ref        | TEXT | -                                    | NULL                                                                  |
| expiry           | TEXT | NOT NULL                             | -                                                                     |
| status           | TEXT | CHECK('active','frozen','cancelled') | 'active'                                                              |
| shipping_address | TEXT | -                                    | NULL                                                                  |
| created_at       | TEXT | -                                    | CURRENT_TIMESTAMP                                                     |
| pin_hash         | TEXT | -                                    | NULL (added via runtime migration in `cards/[id]/pin/route.ts:51-53`) |

**Index:** `idx_cards_user` on `user_id`

---

### sessions

JWT session tracking for revocation support.

| Column     | Type    | Constraints              | Default           |
| ---------- | ------- | ------------------------ | ----------------- |
| id         | TEXT    | PRIMARY KEY              | -                 |
| user_id    | TEXT    | NOT NULL, FK → users(id) | -                 |
| token_hash | TEXT    | NOT NULL                 | -                 |
| created_at | TEXT    | -                        | CURRENT_TIMESTAMP |
| expires_at | TEXT    | NOT NULL                 | -                 |
| revoked    | INTEGER | -                        | 0                 |

**Indexes:** `idx_sessions_user` on `user_id`, `idx_sessions_token` on `token_hash`

**Token hash:** SHA-256 of the JWT string (`auth.ts:59`)

---

### notifications

In-app notifications.

| Column     | Type    | Constraints              | Default           |
| ---------- | ------- | ------------------------ | ----------------- |
| id         | TEXT    | PRIMARY KEY              | -                 |
| user_id    | TEXT    | NOT NULL, FK → users(id) | -                 |
| type       | TEXT    | NOT NULL                 | -                 |
| title      | TEXT    | NOT NULL                 | -                 |
| body       | TEXT    | NOT NULL                 | -                 |
| read       | INTEGER | -                        | 0                 |
| created_at | TEXT    | -                        | CURRENT_TIMESTAMP |

**Index:** `idx_notifications_user` on `user_id`

---

### settings

Per-user preferences.

| Column        | Type    | Constraints                 | Default           |
| ------------- | ------- | --------------------------- | ----------------- |
| user_id       | TEXT    | PRIMARY KEY, FK → users(id) | -                 |
| currency      | TEXT    | -                           | 'NOK'             |
| language      | TEXT    | -                           | 'nb'              |
| push_enabled  | INTEGER | -                           | 1                 |
| email_enabled | INTEGER | -                           | 1                 |
| updated_at    | TEXT    | -                           | CURRENT_TIMESTAMP |

---

### spending_limits (FUTURE — feature-flagged)

> **Note:** Tied to the cards feature. Only active when card feature flags are enabled.

Card spending limits.

| Column     | Type | Constraints              | Default           |
| ---------- | ---- | ------------------------ | ----------------- |
| id         | TEXT | PRIMARY KEY              | -                 |
| user_id    | TEXT | NOT NULL, FK → users(id) | -                 |
| card_id    | TEXT | FK → cards(id)           | NULL              |
| limit_type | TEXT | NOT NULL                 | -                 |
| amount     | REAL | NOT NULL                 | -                 |
| currency   | TEXT | -                        | 'NOK'             |
| created_at | TEXT | -                        | CURRENT_TIMESTAMP |

**Indexes:** `idx_spending_limits_user` on `user_id`, `idx_spending_limits_card` on `card_id`

**Limit types (API-enforced):** `daily`, `weekly`, `monthly`, `transaction`

---

### rate_limits

Persistent rate limiting store.

| Column   | Type    | Constraints | Default |
| -------- | ------- | ----------- | ------- |
| key      | TEXT    | PRIMARY KEY | -       |
| count    | INTEGER | NOT NULL    | -       |
| reset_at | INTEGER | NOT NULL    | -       |

Used by `middleware.ts:rateLimit()` for IP-based rate limiting. Expired entries are cleaned on each call (`middleware.ts:11`).

---

## Compliance & GDPR Tables

> Added: 2026-02-16 (compliance infrastructure)
>
> These tables support Drop's compliance requirements for Norwegian financial services regulation, GDPR, and AML/KYC requirements per hvitvaskingsloven.

### audit_log

User action audit trail for compliance and security monitoring.

| Column        | Type | Constraints    | Default           |
| ------------- | ---- | -------------- | ----------------- |
| id            | TEXT | PRIMARY KEY    | -                 |
| timestamp     | TEXT | -              | CURRENT_TIMESTAMP |
| user_id       | TEXT | FK → users(id) | NULL              |
| action        | TEXT | NOT NULL       | -                 |
| resource_type | TEXT | -              | NULL              |
| resource_id   | TEXT | -              | NULL              |
| details       | TEXT | -              | NULL              |
| ip_address    | TEXT | -              | NULL              |
| user_agent    | TEXT | -              | NULL              |

**Indexes:** `idx_audit_log_user` on `user_id`, `idx_audit_log_timestamp` on `timestamp`, `idx_audit_log_action` on `action`

**Purpose:** Tracks all significant user actions (login, transaction, settings change, etc.) for audit purposes.

---

### aml_alerts

AML (Anti-Money Laundering) transaction monitoring alerts.

| Column         | Type | Constraints                                                  | Default           |
| -------------- | ---- | ------------------------------------------------------------ | ----------------- |
| id             | TEXT | PRIMARY KEY                                                  | -                 |
| user_id        | TEXT | NOT NULL, FK → users(id)                                     | -                 |
| alert_type     | TEXT | NOT NULL                                                     | -                 |
| severity       | TEXT | NOT NULL, CHECK('low','medium','high','critical')            | -                 |
| transaction_id | TEXT | FK → transactions(id)                                        | NULL              |
| details        | TEXT | -                                                            | NULL              |
| status         | TEXT | CHECK('open','investigating','resolved','escalated','filed') | 'open'            |
| reviewed_by    | TEXT | -                                                            | NULL              |
| reviewed_at    | TEXT | -                                                            | NULL              |
| created_at     | TEXT | -                                                            | CURRENT_TIMESTAMP |

**Indexes:** `idx_aml_alerts_user` on `user_id`, `idx_aml_alerts_status` on `status`

**Purpose:** Records suspicious transaction patterns flagged by AML monitoring rules (e.g., structuring, velocity, high-risk corridors).

---

### str_reports

STR (Suspicious Transaction Reports) filed with financial authorities.

| Column           | Type | Constraints                               | Default           |
| ---------------- | ---- | ----------------------------------------- | ----------------- |
| id               | TEXT | PRIMARY KEY                               | -                 |
| user_id          | TEXT | NOT NULL, FK → users(id)                  | -                 |
| alert_id         | TEXT | FK → aml_alerts(id)                       | NULL              |
| report_type      | TEXT | NOT NULL                                  | -                 |
| status           | TEXT | CHECK('draft','submitted','acknowledged') | 'draft'           |
| filed_at         | TEXT | -                                         | NULL              |
| reference_number | TEXT | -                                         | NULL              |
| details          | TEXT | -                                         | NULL              |
| created_at       | TEXT | -                                         | CURRENT_TIMESTAMP |

**Purpose:** Tracks STRs filed with Økokrim/EFE (Norwegian financial intelligence unit) per hvitvaskingsloven requirements.

---

### screening_results

Results from sanctions/PEP (Politically Exposed Persons) screening.

| Column         | Type | Constraints                                                | Default           |
| -------------- | ---- | ---------------------------------------------------------- | ----------------- |
| id             | TEXT | PRIMARY KEY                                                | -                 |
| user_id        | TEXT | NOT NULL, FK → users(id)                                   | -                 |
| screening_type | TEXT | NOT NULL, CHECK('pep','sanctions','adverse_media')         | -                 |
| provider       | TEXT | -                                                          | NULL              |
| result         | TEXT | NOT NULL, CHECK('clear','match','potential_match','error') | -                 |
| match_details  | TEXT | -                                                          | NULL              |
| screened_at    | TEXT | -                                                          | CURRENT_TIMESTAMP |

**Indexes:** `idx_screening_user` on `user_id`

**Purpose:** Stores results from automated screening against PEP lists, sanctions lists (OFAC, UN, EU), and adverse media databases.

---

### consents

GDPR consent tracking for user data processing.

| Column       | Type    | Constraints              | Default           |
| ------------ | ------- | ------------------------ | ----------------- |
| id           | TEXT    | PRIMARY KEY              | -                 |
| user_id      | TEXT    | NOT NULL, FK → users(id) | -                 |
| consent_type | TEXT    | NOT NULL                 | -                 |
| granted      | INTEGER | NOT NULL                 | 1                 |
| granted_at   | TEXT    | -                        | CURRENT_TIMESTAMP |
| withdrawn_at | TEXT    | -                        | NULL              |
| ip_address   | TEXT    | -                        | NULL              |

**Indexes:** `idx_consents_user` on `user_id`

**Consent Types (API-enforced):** `terms`, `privacy`, `marketing`, `cookies_analytics`, `cookies_marketing`

**Purpose:** Tracks when users grant or withdraw consent for different types of data processing, with IP address as proof of consent action.

---

### data_access_requests

GDPR data access/erasure/rectification requests (Art. 15-17).

| Column       | Type | Constraints                                                       | Default           |
| ------------ | ---- | ----------------------------------------------------------------- | ----------------- |
| id           | TEXT | PRIMARY KEY                                                       | -                 |
| user_id      | TEXT | NOT NULL, FK → users(id)                                          | -                 |
| request_type | TEXT | NOT NULL, CHECK('export','erasure','rectification','restriction') | -                 |
| status       | TEXT | CHECK('pending','processing','completed','rejected')              | 'pending'         |
| requested_at | TEXT | -                                                                 | CURRENT_TIMESTAMP |
| completed_at | TEXT | -                                                                 | NULL              |
| download_url | TEXT | -                                                                 | NULL              |
| notes        | TEXT | -                                                                 | NULL              |

**Indexes:** `idx_data_requests_user` on `user_id`

**Purpose:** Tracks GDPR data subject access requests. `export` requests generate full data export, `erasure` triggers account deletion.

---

### complaints

Customer complaints per Finansavtaleloven §3-53 (15-day response requirement).

| Column      | Type | Constraints                                              | Default           |
| ----------- | ---- | -------------------------------------------------------- | ----------------- |
| id          | TEXT | PRIMARY KEY                                              | -                 |
| user_id     | TEXT | NOT NULL, FK → users(id)                                 | -                 |
| category    | TEXT | NOT NULL                                                 | -                 |
| subject     | TEXT | NOT NULL                                                 | -                 |
| description | TEXT | NOT NULL                                                 | -                 |
| status      | TEXT | CHECK('received','investigating','resolved','escalated') | 'received'        |
| resolution  | TEXT | -                                                        | NULL              |
| created_at  | TEXT | -                                                        | CURRENT_TIMESTAMP |
| resolved_at | TEXT | -                                                        | NULL              |

**Indexes:** `idx_complaints_user` on `user_id`, `idx_complaints_status` on `status`

**Categories (API-enforced):** `transaction`, `service`, `fees`, `privacy`, `technical`, `other`

**Purpose:** Formal complaint logging system to ensure compliance with Norwegian financial services law requiring 15 business day response time.

---

## Database Access Layer

Source: `db.ts`

### Data Access Layer

The database access layer is **Drizzle ORM** (`src/shared/db/schema.ts`). The old `db.ts`
dual-driver abstraction has been removed (see ADR-014).

Use Drizzle query builder or the `sql` template tag for raw queries:

```typescript
import { db } from '@drop/shared/db'
import { users } from '@drop/shared/db/schema'
import { eq } from 'drizzle-orm'

// Type-safe query
const user = await db.select().from(users).where(eq(users.id, userId)).limit(1)

// Raw SQL escape hatch (PostgreSQL syntax, $1 params not needed — Drizzle handles binding)
import { sql } from 'drizzle-orm'
const result = await db.execute(sql`SELECT id FROM users WHERE email = ${email}`)
```

Migrations are managed by `drizzle-kit`:

```bash
cd src/shared && npx drizzle-kit generate  # Generate migration file
cd src/shared && npx drizzle-kit push      # Push schema to dev database
make db-push                               # Shortcut (from repo root)
```

---

## Seed Data

When `exchange_rates` table is empty, `seedData()` (`db.ts:530`) populates:

- 6 exchange rate corridors (NOK → RSD, BAM, PLN, PKR, TRY, EUR)
- Demo data (when `NODE_ENV !== "production"` or `SEED_DEMO=true`):
  - 1 demo user (`usr_demo1`, amir@example.com, role: merchant)
  - 3 recipients (Serbia, Bosnia, Turkey)
  - 1 merchant (Ahmetov Kebab)
  - 3 transactions (2 remittances, 1 QR payment)
  - 2 bank accounts (DNB primary with 45,230 NOK, SpareBank 1 with 12,800 NOK)

# Error Codes Catalog

# Bilko Error Codes Catalog

> **Project:** Bilko
> **Version:** 1.0
> **Date:** 2026-02-24
> **Status:** Specification
> **Applies to:** `apps/api/` — all modules

---

## Overview

All Bilko API errors follow a consistent JSON structure. Every error response includes:
- An HTTP status code
- A machine-readable `BILKO-XXXX` error code
- A human-readable message (in the organization's configured language)
- Optional field-level details for validation errors

**Error code ranges by module:**

| Range | Module |
|-------|--------|
| `BILKO-1xxx` | Authentication |
| `BILKO-2xxx` | Organizations |
| `BILKO-3xxx` | Invoices |
| `BILKO-4xxx` | Expenses |
| `BILKO-5xxx` | Banking |
| `BILKO-6xxx` | Reports |
| `BILKO-7xxx` | Contacts |
| `BILKO-8xxx` | Settings & Accounts |
| `BILKO-9xxx` | General / Cross-cutting |

---

## Error Response Schema

All error responses use this structure:

```typescript
interface ErrorResponse {
  error: {
    code: string                          // e.g., "BILKO-1001"
    message: string                       // Human-readable, localized
    details?: Record<string, string[]>    // Field-level validation errors (422 only)
    requestId?: string                    // Trace ID for support (production)
  }
}
```

**Examples:**

```json
// Authentication error
{
  "error": {
    "code": "BILKO-1001",
    "message": "Pogrešan email ili lozinka.",
    "requestId": "req_01H9X3K5P2M7N8Q4R6S9T0V1W2"
  }
}

// Validation error (422) with field details
{
  "error": {
    "code": "BILKO-9003",
    "message": "Validacija nije uspjela.",
    "details": {
      "email": ["Email adresa nije ispravna."],
      "password": ["Lozinka mora imati najmanje 8 znakova.", "Lozinka mora sadržavati barem jedan broj."]
    },
    "requestId": "req_01H9X3K5P2M7N8Q4R6S9T0V1W2"
  }
}
```

---

## HTTP Status Codes

| HTTP Code | Meaning | When Used |
|-----------|---------|-----------|
| `200 OK` | Successful read or update | GET, PUT, PATCH |
| `201 Created` | Resource successfully created | POST (creates new record) |
| `204 No Content` | Successful deletion | DELETE, POST /logout |
| `400 Bad Request` | Request is well-formed but semantically invalid | Business rule violations (not validation) |
| `401 Unauthorized` | Missing, expired, or invalid authentication | No token, expired JWT, wrong credentials |
| `403 Forbidden` | Authenticated but insufficient permissions | Role lacks access to endpoint or action |
| `404 Not Found` | Resource does not exist within organization scope | Record not found or belongs to different org |
| `409 Conflict` | Resource already exists | Duplicate email, duplicate invoice number |
| `413 Payload Too Large` | Upload exceeds size limit | File uploads over 10MB (receipt) or 5MB (CSV) |
| `422 Unprocessable Entity` | Zod schema validation failed | Invalid field types, missing required fields |
| `429 Too Many Requests` | Rate limit exceeded | Auth: 5/min; writes: 10–50/min; reads: 100/min |
| `500 Internal Server Error` | Unexpected server-side error | Unhandled exception, DB error |
| `503 Service Unavailable` | External dependency unavailable | SendGrid, ECB API, Cloudflare R2 down |

---

## Retry Guidance

| Error Code | HTTP | Retry? | Strategy |
|------------|------|--------|----------|
| `BILKO-1003` | 401 | Yes | Refresh access token via `POST /auth/refresh`, then retry |
| `BILKO-9005` | 429 | Yes | Wait until `Retry-After` header value (seconds), then retry |
| `BILKO-9006` | 500 | Yes | Exponential backoff: 1s, 2s, 4s — max 3 retries |
| `BILKO-9007` | 503 | Yes | Exponential backoff: 2s, 5s, 10s — max 3 retries |
| All `4xx` except above | — | No | Fix request before retrying — these are client errors |
| `BILKO-1001` | 401 | No | Wrong credentials — do not retry automatically |
| `BILKO-3011` | 500 | Yes | SendGrid transient failure — retry once after 5s |

**`Retry-After` header:** Always present on `429` responses. Value is seconds to wait.

---

## Module: Authentication (1xxx)

### BILKO-1001 — Invalid Credentials

| Field | Value |
|-------|-------|
| HTTP | `401` |
| Trigger | `POST /auth/login` — email not found or password does not match bcrypt hash |
| Retry | No |

```json
{
  "error": {
    "code": "BILKO-1001",
    "message": "Pogrešan email ili lozinka."
  }
}
```

---

### BILKO-1002 — Account Disabled

| Field | Value |
|-------|-------|
| HTTP | `403` |
| Trigger | `POST /auth/login` — user has `isActive = false` |
| Retry | No — contact support |

```json
{
  "error": {
    "code": "BILKO-1002",
    "message": "Vaš nalog je deaktiviran. Kontaktirajte podršku."
  }
}
```

---

### BILKO-1003 — Access Token Expired

| Field | Value |
|-------|-------|
| HTTP | `401` |
| Trigger | Any authenticated request — JWT `exp` claim is in the past |
| Retry | Yes — refresh token first via `POST /auth/refresh`, then retry original request |

```json
{
  "error": {
    "code": "BILKO-1003",
    "message": "Sesija je istekla. Osvježite token."
  }
}
```

---

### BILKO-1004 — Invalid Token

| Field | Value |
|-------|-------|
| HTTP | `401` |
| Trigger | Any authenticated request — JWT signature invalid or malformed |
| Retry | No — re-authenticate |

```json
{
  "error": {
    "code": "BILKO-1004",
    "message": "Token nije ispravan. Molimo prijavite se ponovo."
  }
}
```

---

### BILKO-1005 — No Authentication Token

| Field | Value |
|-------|-------|
| HTTP | `401` |
| Trigger | Any authenticated endpoint called without `Authorization: Bearer <token>` header |
| Retry | No — add token |

```json
{
  "error": {
    "code": "BILKO-1005",
    "message": "Autentifikacija je obavezna."
  }
}
```

---

### BILKO-1006 — Refresh Token Invalid or Expired

| Field | Value |
|-------|-------|
| HTTP | `401` |
| Trigger | `POST /auth/refresh` — cookie missing, token blacklisted, or expired |
| Retry | No — force full re-login |

```json
{
  "error": {
    "code": "BILKO-1006",
    "message": "Sesija je istekla. Molimo prijavite se ponovo."
  }
}
```

---

### BILKO-1007 — Auth Rate Limit Exceeded

| Field | Value |
|-------|-------|
| HTTP | `429` |
| Trigger | `POST /auth/login` or `POST /auth/register` — 5+ requests in 60 seconds from same IP |
| Retry | Yes — after `Retry-After` header value (900 seconds / 15 min lockout) |

```json
{
  "error": {
    "code": "BILKO-1007",
    "message": "Previše pokušaja prijave. Pokušajte ponovo za 15 minuta."
  }
}
```

**Response headers:**
```
Retry-After: 900
X-RateLimit-Limit: 5
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1740399600
```

---

### BILKO-1008 — Email Already Registered

| Field | Value |
|-------|-------|
| HTTP | `409` |
| Trigger | `POST /auth/register` — email already exists in `users` table |
| Retry | No — use different email or reset password |

```json
{
  "error": {
    "code": "BILKO-1008",
    "message": "Email adresa je već registrirana."
  }
}
```

---

### BILKO-1009 — Weak Password

| Field | Value |
|-------|-------|
| HTTP | `422` |
| Trigger | `POST /auth/register` or `PUT /auth/password` — password fails strength requirements |
| Retry | No — fix password |

```json
{
  "error": {
    "code": "BILKO-1009",
    "message": "Lozinka ne zadovoljava sigurnosne zahtjeve.",
    "details": {
      "password": [
        "Lozinka mora imati najmanje 8 znakova.",
        "Lozinka mora sadržavati barem jedno veliko slovo.",
        "Lozinka mora sadržavati barem jedan broj."
      ]
    }
  }
}
```

---

### BILKO-1010 — Two-Factor Authentication Required

| Field | Value |
|-------|-------|
| HTTP | `403` |
| Trigger | `POST /auth/login` — user has `twoFactorEnabled = true`, 2FA code not provided |
| Retry | No — submit TOTP code via `POST /auth/verify-2fa` |

```json
{
  "error": {
    "code": "BILKO-1010",
    "message": "Potrebna je dvofaktorska autentifikacija.",
    "details": {
      "requiresTwoFactor": ["true"]
    }
  }
}
```

---

### BILKO-1011 — Invalid 2FA Code

| Field | Value |
|-------|-------|
| HTTP | `401` |
| Trigger | `POST /auth/verify-2fa` — TOTP code incorrect or expired (outside 30s window) |
| Retry | No — request new code from authenticator app |

```json
{
  "error": {
    "code": "BILKO-1011",
    "message": "Kod za verifikaciju nije ispravan ili je istekao."
  }
}
```

---

### BILKO-1012 — Invalid Invite Token

| Field | Value |
|-------|-------|
| HTTP | `401` |
| Trigger | User follows invite link after it has expired (7 days) or already been used |
| Retry | No — request new invitation |

```json
{
  "error": {
    "code": "BILKO-1012",
    "message": "Pozivnica je nevažeća ili je istekla."
  }
}
```

---

## Module: Organizations (2xxx)

### BILKO-2001 — Organization Not Found

| Field | Value |
|-------|-------|
| HTTP | `404` |
| Trigger | Organization ID in JWT does not exist in database (account deleted while token still valid) |

```json
{
  "error": {
    "code": "BILKO-2001",
    "message": "Organizacija nije pronađena."
  }
}
```

---

### BILKO-2002 — Invalid Currency Code

| Field | Value |
|-------|-------|
| HTTP | `422` |
| Trigger | `PUT /organization` — `baseCurrency` is not one of `EUR`, `RSD`, `BAM`, `HRK` |

```json
{
  "error": {
    "code": "BILKO-2002",
    "message": "Neispravna valuta. Podržane valute: EUR, RSD, BAM, HRK.",
    "details": {
      "baseCurrency": ["Vrijednost mora biti jedna od: EUR, RSD, BAM, HRK."]
    }
  }
}
```

---

### BILKO-2003 — Invalid Language Code

| Field | Value |
|-------|-------|
| HTTP | `422` |
| Trigger | `PUT /organization` — `language` is not one of `sr`, `bs`, `hr` |

```json
{
  "error": {
    "code": "BILKO-2003",
    "message": "Neispravni jezički kod. Podržani jezici: sr, bs, hr.",
    "details": {
      "language": ["Vrijednost mora biti jedna od: sr, bs, hr."]
    }
  }
}
```

---

### BILKO-2004 — Cannot Change Base Currency

| Field | Value |
|-------|-------|
| HTTP | `400` |
| Trigger | `PUT /organization` — attempting to change `baseCurrency` when transactions already exist |

```json
{
  "error": {
    "code": "BILKO-2004",
    "message": "Osnovna valuta ne može se promijeniti jer već postoje finansijske transakcije."
  }
}
```

---

### BILKO-2005 — User Not Found in Organization

| Field | Value |
|-------|-------|
| HTTP | `404` |
| Trigger | `PUT /users/:id/role` or `DELETE /users/:id` — user UUID not in caller's organization |

```json
{
  "error": {
    "code": "BILKO-2005",
    "message": "Korisnik nije pronađen u ovoj organizaciji."
  }
}
```

---

### BILKO-2006 — Cannot Modify Owner

| Field | Value |
|-------|-------|
| HTTP | `403` |
| Trigger | `PUT /users/:id/role` or `DELETE /users/:id` — target user is `owner` |

```json
{
  "error": {
    "code": "BILKO-2006",
    "message": "Nije moguće promijeniti ili ukloniti vlasnika organizacije."
  }
}
```

---

### BILKO-2007 — Cannot Remove Self

| Field | Value |
|-------|-------|
| HTTP | `403` |
| Trigger | `DELETE /users/:id` — user attempts to delete their own account |

```json
{
  "error": {
    "code": "BILKO-2007",
    "message": "Ne možete ukloniti vlastiti korisnički račun."
  }
}
```

---

### BILKO-2008 — Cannot Invite Existing Member

| Field | Value |
|-------|-------|
| HTTP | `409` |
| Trigger | `POST /users/invite` — email already belongs to a user in this organization |

```json
{
  "error": {
    "code": "BILKO-2008",
    "message": "Korisnik s ovim emailom je već član organizacije."
  }
}
```

---

## Module: Invoices (3xxx)

### BILKO-3001 — Invoice Not Found

| Field | Value |
|-------|-------|
| HTTP | `404` |
| Trigger | `GET/PUT/PATCH/DELETE /invoices/:id` — ID not found in caller's organization |

```json
{
  "error": {
    "code": "BILKO-3001",
    "message": "Faktura nije pronađena."
  }
}
```

---

### BILKO-3002 — Customer Not Found

| Field | Value |
|-------|-------|
| HTTP | `404` |
| Trigger | `POST /invoices` — `customerId` does not exist in organization's contacts |

```json
{
  "error": {
    "code": "BILKO-3002",
    "message": "Klijent nije pronađen."
  }
}
```

---

### BILKO-3003 — Invoice Not in Draft Status

| Field | Value |
|-------|-------|
| HTTP | `400` |
| Trigger | `PUT /invoices/:id` — attempting to edit an invoice that is not in `draft` status |

```json
{
  "error": {
    "code": "BILKO-3003",
    "message": "Faktura se može mijenjati samo u statusu 'nacrt'.",
    "details": {
      "status": ["Trenutni status: sent. Samo nacrti se mogu uređivati."]
    }
  }
}
```

---

### BILKO-3004 — Invalid Invoice Status Transition

| Field | Value |
|-------|-------|
| HTTP | `400` |
| Trigger | `PATCH /invoices/:id/status` — action is not valid for current invoice status |

**Valid transitions:**
- `draft` → `sent` (action: `send`)
- `sent` or `viewed` → `paid` (action: `mark-paid`)
- Any non-cancelled → `cancelled` (action: `cancel`)

```json
{
  "error": {
    "code": "BILKO-3004",
    "message": "Nevažeća promjena statusa fakture.",
    "details": {
      "action": ["Akcija 'mark-paid' nije dozvoljena za status 'draft'."]
    }
  }
}
```

---

### BILKO-3005 — Customer Has No Email

| Field | Value |
|-------|-------|
| HTTP | `400` |
| Trigger | `POST /invoices/:id/send` — customer contact has no `email` field set |

```json
{
  "error": {
    "code": "BILKO-3005",
    "message": "Klijent nema email adresu. Dodajte email u kontakt podatke."
  }
}
```

---

### BILKO-3006 — Invoice Items Required

| Field | Value |
|-------|-------|
| HTTP | `422` |
| Trigger | `POST /invoices` — `items` array is empty |

```json
{
  "error": {
    "code": "BILKO-3006",
    "message": "Faktura mora imati najmanje jednu stavku.",
    "details": {
      "items": ["Polje items ne može biti prazno."]
    }
  }
}
```

---

### BILKO-3007 — Negative or Zero Amount

| Field | Value |
|-------|-------|
| HTTP | `422` |
| Trigger | `POST /invoices` or `POST /invoices/:id/send` — `unitPrice` or `quantity` is ≤ 0 |

```json
{
  "error": {
    "code": "BILKO-3007",
    "message": "Iznosi moraju biti veći od nule.",
    "details": {
      "items[0].unitPrice": ["Cijena mora biti pozitivan broj."]
    }
  }
}
```

---

### BILKO-3008 — Invalid Tax Rate

| Field | Value |
|-------|-------|
| HTTP | `422` |
| Trigger | `POST /invoices` — `taxRate` is negative or exceeds 100 |

```json
{
  "error": {
    "code": "BILKO-3008",
    "message": "Stopa poreza mora biti između 0 i 100.",
    "details": {
      "items[0].taxRate": ["Vrijednost mora biti između 0 i 100."]
    }
  }
}
```

---

### BILKO-3009 — Due Date Before Invoice Date

| Field | Value |
|-------|-------|
| HTTP | `422` |
| Trigger | `POST /invoices` — `dueDate` is before `invoiceDate` |

```json
{
  "error": {
    "code": "BILKO-3009",
    "message": "Datum dospijeća ne može biti prije datuma fakture.",
    "details": {
      "dueDate": ["Datum dospijeća mora biti isti ili kasniji od datuma fakture."]
    }
  }
}
```

---

### BILKO-3010 — Invoice PDF Not Available

| Field | Value |
|-------|-------|
| HTTP | `404` |
| Trigger | `GET /invoices/:id/pdf` — PDF has not yet been generated (invoice still in `draft`) |

```json
{
  "error": {
    "code": "BILKO-3010",
    "message": "PDF faktura nije dostupan. Faktura mora biti poslana da bi se generirao PDF."
  }
}
```

---

### BILKO-3011 — Invoice Email Delivery Failed

| Field | Value |
|-------|-------|
| HTTP | `500` |
| Trigger | `POST /invoices/:id/send` — SendGrid API returned error |
| Retry | Yes — once, after 5 seconds |

```json
{
  "error": {
    "code": "BILKO-3011",
    "message": "Slanje emaila nije uspjelo. Pokušajte ponovo.",
    "requestId": "req_01H9X3K5P2M7N8Q4R6S9T0V1W2"
  }
}
```

---

## Module: Expenses (4xxx)

### BILKO-4001 — Expense Not Found

| Field | Value |
|-------|-------|
| HTTP | `404` |
| Trigger | `GET/PUT/PATCH/DELETE /expenses/:id` — ID not in caller's organization |

```json
{
  "error": {
    "code": "BILKO-4001",
    "message": "Troškak nije pronađen."
  }
}
```

---

### BILKO-4002 — Expense Not in Pending Status

| Field | Value |
|-------|-------|
| HTTP | `400` |
| Trigger | `PUT /expenses/:id` or `DELETE /expenses/:id` — expense is not in `pending` status |

```json
{
  "error": {
    "code": "BILKO-4002",
    "message": "Troškak se može mijenjati ili brisati samo u statusu 'na čekanju'.",
    "details": {
      "status": ["Trenutni status: approved."]
    }
  }
}
```

---

### BILKO-4003 — Expense Already Processed

| Field | Value |
|-------|-------|
| HTTP | `400` |
| Trigger | `PATCH /expenses/:id/approve` — expense is already `approved`, `paid`, or `rejected` |

```json
{
  "error": {
    "code": "BILKO-4003",
    "message": "Troškak je već obrađen i ne može se odobriti ponovo.",
    "details": {
      "status": ["Trenutni status: approved."]
    }
  }
}
```

---

### BILKO-4004 — Vendor Not Found

| Field | Value |
|-------|-------|
| HTTP | `404` |
| Trigger | `POST /expenses` — `vendorId` not found in organization's contacts |

```json
{
  "error": {
    "code": "BILKO-4004",
    "message": "Dobavljač nije pronađen."
  }
}
```

---

### BILKO-4005 — Receipt File Too Large

| Field | Value |
|-------|-------|
| HTTP | `413` |
| Trigger | `POST /expenses` with `receiptFile` — file exceeds 10MB |

```json
{
  "error": {
    "code": "BILKO-4005",
    "message": "Fajl je prevelik. Maksimalna veličina je 10 MB."
  }
}
```

---

### BILKO-4006 — Invalid Receipt File Type

| Field | Value |
|-------|-------|
| HTTP | `422` |
| Trigger | `POST /expenses` with `receiptFile` — file is not PDF, PNG, or JPG |

```json
{
  "error": {
    "code": "BILKO-4006",
    "message": "Neispravni tip fajla. Dozvoljeni formati: PDF, PNG, JPG.",
    "details": {
      "receiptFile": ["Tip fajla 'docx' nije dozvoljen."]
    }
  }
}
```

---

### BILKO-4007 — Receipt Upload Failed

| Field | Value |
|-------|-------|
| HTTP | `500` |
| Trigger | Cloudflare R2 upload failed after ClamAV scan passed |
| Retry | Yes — exponential backoff |

```json
{
  "error": {
    "code": "BILKO-4007",
    "message": "Upload računa nije uspio. Pokušajte ponovo.",
    "requestId": "req_01H9X3K5P2M7N8Q4R6S9T0V1W2"
  }
}
```

---

### BILKO-4008 — File Rejected (Virus Detected)

| Field | Value |
|-------|-------|
| HTTP | `422` |
| Trigger | ClamAV scan detected malware in uploaded file |
| Retry | No |

```json
{
  "error": {
    "code": "BILKO-4008",
    "message": "Fajl nije prihvaćen zbog sigurnosnih razloga."
  }
}
```

---

## Module: Banking (5xxx)

### BILKO-5001 — Bank Account Not Found

| Field | Value |
|-------|-------|
| HTTP | `404` |
| Trigger | `GET/POST /bank-accounts/:id/*` — ID not found in caller's organization |

```json
{
  "error": {
    "code": "BILKO-5001",
    "message": "Bankovni račun nije pronađen."
  }
}
```

---

### BILKO-5002 — GL Account Must Be Asset Type

| Field | Value |
|-------|-------|
| HTTP | `422` |
| Trigger | `POST /bank-accounts` — referenced `accountId` is not an Asset account type |

```json
{
  "error": {
    "code": "BILKO-5002",
    "message": "Konto za bankovni račun mora biti tipa 'Imovina'.",
    "details": {
      "accountId": ["Odabrani konto je tipa 'Rashodi'. Odaberite konto tipa 'Imovina'."]
    }
  }
}
```

---

### BILKO-5003 — GL Account Not Found

| Field | Value |
|-------|-------|
| HTTP | `404` |
| Trigger | `POST /bank-accounts` — `accountId` does not exist in organization |

```json
{
  "error": {
    "code": "BILKO-5003",
    "message": "Konto nije pronađen."
  }
}
```

---

### BILKO-5004 — Bank Transaction Not Found

| Field | Value |
|-------|-------|
| HTTP | `404` |
| Trigger | `POST /bank-accounts/:id/reconcile` — `bankTransactionId` not found |

```json
{
  "error": {
    "code": "BILKO-5004",
    "message": "Bankovna transakcija nije pronađena."
  }
}
```

---

### BILKO-5005 — Transaction Already Reconciled

| Field | Value |
|-------|-------|
| HTTP | `400` |
| Trigger | `POST /bank-accounts/:id/reconcile` — either transaction is already `reconciled = true` |

```json
{
  "error": {
    "code": "BILKO-5005",
    "message": "Transakcija je već usklađena."
  }
}
```

---

### BILKO-5006 — Invalid CSV Format

| Field | Value |
|-------|-------|
| HTTP | `422` |
| Trigger | `POST /bank-accounts/:id/import` — CSV missing required columns or malformed |

```json
{
  "error": {
    "code": "BILKO-5006",
    "message": "Nevažeći format CSV fajla.",
    "details": {
      "file": ["Obavezne kolone: Date, Description, Amount, Reference."]
    }
  }
}
```

---

### BILKO-5007 — CSV File Too Large

| Field | Value |
|-------|-------|
| HTTP | `413` |
| Trigger | `POST /bank-accounts/:id/import` — CSV file exceeds 5MB |

```json
{
  "error": {
    "code": "BILKO-5007",
    "message": "CSV fajl je prevelik. Maksimalna veličina je 5 MB."
  }
}
```

---

### BILKO-5008 — Invalid CSV Date Format

| Field | Value |
|-------|-------|
| HTTP | `422` |
| Trigger | `POST /bank-accounts/:id/import` — date column values not parseable as ISO 8601 |

```json
{
  "error": {
    "code": "BILKO-5008",
    "message": "Nevažeći format datuma u CSV fajlu.",
    "details": {
      "file": ["Datumi moraju biti u formatu YYYY-MM-DD (npr. 2026-02-24)."]
    }
  }
}
```

---

## Module: Reports (6xxx)

### BILKO-6001 — From Date Required

| Field | Value |
|-------|-------|
| HTTP | `422` |
| Trigger | `GET /reports/profit-loss`, `/cash-flow`, `/vat` — `from` query parameter missing |

```json
{
  "error": {
    "code": "BILKO-6001",
    "message": "Početni datum je obavezan.",
    "details": {
      "from": ["Parametar 'from' je obavezan."]
    }
  }
}
```

---

### BILKO-6002 — To Date Required

| Field | Value |
|-------|-------|
| HTTP | `422` |
| Trigger | `GET /reports/profit-loss`, `/cash-flow`, `/vat` — `to` query parameter missing |

```json
{
  "error": {
    "code": "BILKO-6002",
    "message": "Krajnji datum je obavezan.",
    "details": {
      "to": ["Parametar 'to' je obavezan."]
    }
  }
}
```

---

### BILKO-6003 — Invalid Date Range

| Field | Value |
|-------|-------|
| HTTP | `422` |
| Trigger | Any report endpoint — `from` date is after `to` date |

```json
{
  "error": {
    "code": "BILKO-6003",
    "message": "Nevažeći raspon datuma. Početni datum mora biti prije krajnjeg.",
    "details": {
      "from": ["Mora biti prije 'to' datuma."]
    }
  }
}
```

---

### BILKO-6004 — Trial Balance Not Balanced

| Field | Value |
|-------|-------|
| HTTP | `500` |
| Trigger | `GET /reports/trial-balance` — internal data integrity check failed (debit ≠ credit totals) |
| Retry | No — requires manual investigation |

```json
{
  "error": {
    "code": "BILKO-6004",
    "message": "Greška integriteta podataka: probni bilans nije uravnotežen. Kontaktirajte podršku.",
    "requestId": "req_01H9X3K5P2M7N8Q4R6S9T0V1W2"
  }
}
```

---

## Module: Contacts (7xxx)

### BILKO-7001 — Contact Not Found

| Field | Value |
|-------|-------|
| HTTP | `404` |
| Trigger | `GET/PUT/DELETE /contacts/:id` — ID not found in caller's organization |

```json
{
  "error": {
    "code": "BILKO-7001",
    "message": "Kontakt nije pronađen."
  }
}
```

---

### BILKO-7002 — Contact Has Active Invoices

| Field | Value |
|-------|-------|
| HTTP | `400` |
| Trigger | `DELETE /contacts/:id` — contact has invoices with status other than `cancelled` |

```json
{
  "error": {
    "code": "BILKO-7002",
    "message": "Kontakt ne može biti deaktiviran jer ima aktivne fakture."
  }
}
```

---

### BILKO-7003 — Contact Has Active Expenses

| Field | Value |
|-------|-------|
| HTTP | `400` |
| Trigger | `DELETE /contacts/:id` — contact has expenses with status other than `rejected` |

```json
{
  "error": {
    "code": "BILKO-7003",
    "message": "Kontakt ne može biti deaktiviran jer ima aktivne troškove."
  }
}
```

---

### BILKO-7004 — Invalid ISO Country Code

| Field | Value |
|-------|-------|
| HTTP | `422` |
| Trigger | `POST/PUT /contacts` — `country` is not a valid ISO 3166-1 alpha-2 code |

```json
{
  "error": {
    "code": "BILKO-7004",
    "message": "Nevažeći kod države.",
    "details": {
      "country": ["Mora biti ISO 3166-1 alpha-2 kod (npr. 'RS', 'BA', 'HR')."]
    }
  }
}
```

---

### BILKO-7005 — Invalid Payment Terms

| Field | Value |
|-------|-------|
| HTTP | `422` |
| Trigger | `POST/PUT /contacts` — `paymentTerms` is negative or not an integer |

```json
{
  "error": {
    "code": "BILKO-7005",
    "message": "Rok plaćanja mora biti pozitivan cijeli broj (dani).",
    "details": {
      "paymentTerms": ["Mora biti pozitivni cijeli broj."]
    }
  }
}
```

---

## Module: Settings & Accounts (8xxx)

### BILKO-8001 — Account Not Found

| Field | Value |
|-------|-------|
| HTTP | `404` |
| Trigger | `PUT /accounts/:id` — account UUID not found in caller's organization |

```json
{
  "error": {
    "code": "BILKO-8001",
    "message": "Konto nije pronađen."
  }
}
```

---

### BILKO-8002 — Account Code Already Exists

| Field | Value |
|-------|-------|
| HTTP | `409` |
| Trigger | `POST /accounts` — `code` is not unique within the organization |

```json
{
  "error": {
    "code": "BILKO-8002",
    "message": "Konto sa ovim kodom već postoji.",
    "details": {
      "code": ["Kod '1200' je već u upotrebi."]
    }
  }
}
```

---

### BILKO-8003 — Invalid Account Type

| Field | Value |
|-------|-------|
| HTTP | `422` |
| Trigger | `POST /accounts` — `accountTypeId` is not 1–5 |

```json
{
  "error": {
    "code": "BILKO-8003",
    "message": "Nevažeći tip konta.",
    "details": {
      "accountTypeId": ["Mora biti između 1 (Imovina) i 5 (Rashodi)."]
    }
  }
}
```

---

### BILKO-8004 — Cannot Deactivate Account with Transactions

| Field | Value |
|-------|-------|
| HTTP | `400` |
| Trigger | `PUT /accounts/:id` — setting `isActive = false` on account that has transactions |

```json
{
  "error": {
    "code": "BILKO-8004",
    "message": "Konto se ne može deaktivirati jer ima postojeće transakcije."
  }
}
```

---

### BILKO-8005 — Parent Account Not Found

| Field | Value |
|-------|-------|
| HTTP | `404` |
| Trigger | `POST /accounts` — `parentAccountId` not found in organization |

```json
{
  "error": {
    "code": "BILKO-8005",
    "message": "Nadređeni konto nije pronađen."
  }
}
```

---

### BILKO-8006 — Invalid VAT Rate

| Field | Value |
|-------|-------|
| HTTP | `422` |
| Trigger | `PUT /settings/tax-rates` — any rate value is negative or exceeds 100 |

```json
{
  "error": {
    "code": "BILKO-8006",
    "message": "Stopa PDV-a mora biti između 0 i 100.",
    "details": {
      "defaultVATRate": ["Vrijednost mora biti između 0 i 100."]
    }
  }
}
```

---

## General / Cross-cutting (9xxx)

### BILKO-9001 — Insufficient Permissions

| Field | Value |
|-------|-------|
| HTTP | `403` |
| Trigger | Any endpoint — user's role not in `allowedRoles` for that endpoint |

```json
{
  "error": {
    "code": "BILKO-9001",
    "message": "Nemate dovoljna prava za ovu akciju.",
    "details": {
      "required": ["owner", "admin"],
      "current": ["accountant"]
    }
  }
}
```

---

### BILKO-9002 — Resource Not Found

| Field | Value |
|-------|-------|
| HTTP | `404` |
| Trigger | Generic fallback when a specific BILKO-Xxxx code doesn't apply |

```json
{
  "error": {
    "code": "BILKO-9002",
    "message": "Traženi resurs nije pronađen."
  }
}
```

---

### BILKO-9003 — Validation Failed

| Field | Value |
|-------|-------|
| HTTP | `422` |
| Trigger | Generic Zod schema validation failure when no specific BILKO-Xxxx code applies |

```json
{
  "error": {
    "code": "BILKO-9003",
    "message": "Validacija zahtjeva nije uspjela.",
    "details": {
      "fieldName": ["Opis greške validacije."]
    }
  }
}
```

---

### BILKO-9004 — Internal Server Error

| Field | Value |
|-------|-------|
| HTTP | `500` |
| Trigger | Unhandled exception; logged to Sentry with full stack trace |
| Retry | Yes — exponential backoff |

```json
{
  "error": {
    "code": "BILKO-9004",
    "message": "Došlo je do greške na serveru. Pokušajte ponovo.",
    "requestId": "req_01H9X3K5P2M7N8Q4R6S9T0V1W2"
  }
}
```

---

### BILKO-9005 — Rate Limit Exceeded

| Field | Value |
|-------|-------|
| HTTP | `429` |
| Trigger | General API rate limit exceeded (100 req/min for reads, 10–50 for writes) |
| Retry | Yes — after `Retry-After` header |

```json
{
  "error": {
    "code": "BILKO-9005",
    "message": "Previše zahtjeva. Usporite."
  }
}
```

---

### BILKO-9006 — Database Error

| Field | Value |
|-------|-------|
| HTTP | `500` |
| Trigger | Prisma throws unexpected DB error (connection lost, constraint violation from race condition) |
| Retry | Yes — exponential backoff |

```json
{
  "error": {
    "code": "BILKO-9006",
    "message": "Greška baze podataka. Pokušajte ponovo.",
    "requestId": "req_01H9X3K5P2M7N8Q4R6S9T0V1W2"
  }
}
```

---

### BILKO-9007 — External Service Unavailable

| Field | Value |
|-------|-------|
| HTTP | `503` |
| Trigger | SendGrid, Cloudflare R2, or ECB API is unreachable after internal retries exhausted |
| Retry | Yes — exponential backoff; 503 responses include `Retry-After` |

```json
{
  "error": {
    "code": "BILKO-9007",
    "message": "Vanjska usluga trenutno nije dostupna. Pokušajte ponovo za nekoliko minuta.",
    "requestId": "req_01H9X3K5P2M7N8Q4R6S9T0V1W2"
  }
}
```

---

### BILKO-9008 — Invalid Pagination Parameters

| Field | Value |
|-------|-------|
| HTTP | `400` |
| Trigger | Any list endpoint — `page < 1`, `perPage > 100`, or non-integer values |

```json
{
  "error": {
    "code": "BILKO-9008",
    "message": "Nevažeći parametri paginacije.",
    "details": {
      "perPage": ["Maksimalna vrijednost je 100."],
      "page": ["Mora biti pozitivan cijeli broj."]
    }
  }
}
```

---

## i18n Error Messages

All error `message` fields are returned in the organization's configured `language` (`sr`, `bs`, `hr`, `en`). Codes and `details` keys are always in English to enable programmatic handling.

**Message localization table (selected codes):**

| Code | SR (Serbian) | BS (Bosnian) | HR (Croatian) | EN (English) |
|------|-------------|--------------|---------------|--------------|
| BILKO-1001 | Pogrešan email ili lozinka. | Pogrešan email ili lozinka. | Pogrešan email ili lozinka. | Invalid email or password. |
| BILKO-1003 | Sesija je istekla. Osvježite token. | Sesija je istekla. Osvježite token. | Sesija je istekla. Osvježite token. | Session expired. Please refresh your token. |
| BILKO-1005 | Autentifikacija je obavezna. | Autentifikacija je obavezna. | Autentifikacija je obavezna. | Authentication is required. |
| BILKO-1007 | Previše pokušaja prijave. Pokušajte za 15 min. | Previše pokušaja prijave. Pokušajte za 15 min. | Previše pokušaja prijave. Pokušajte za 15 min. | Too many login attempts. Try again in 15 minutes. |
| BILKO-1008 | Email adresa je već registrirana. | Email adresa je već registrirana. | Email adresa je već registrirana. | Email address is already registered. |
| BILKO-3001 | Faktura nije pronađena. | Faktura nije pronađena. | Faktura nije pronađena. | Invoice not found. |
| BILKO-4001 | Troškak nije pronađen. | Troškak nije pronađen. | Trošak nije pronađen. | Expense not found. |
| BILKO-7001 | Kontakt nije pronađen. | Kontakt nije pronađen. | Kontakt nije pronađen. | Contact not found. |
| BILKO-9001 | Nemate dovoljna prava za ovu akciju. | Nemate dovoljna prava za ovu akciju. | Nemate dovoljna prava za ovu akciju. | You do not have permission to perform this action. |
| BILKO-9003 | Validacija zahtjeva nije uspjela. | Validacija zahtjeva nije uspjela. | Provjera zahtjeva nije uspjela. | Request validation failed. |
| BILKO-9004 | Došlo je do greške na serveru. | Došlo je do greške na serveru. | Došlo je do pogreške na poslužitelju. | An internal server error occurred. |
| BILKO-9005 | Previše zahtjeva. Usporite. | Previše zahtjeva. Usporite. | Previše zahtjeva. Usporite. | Too many requests. Please slow down. |

**Implementation:** Error messages are stored in `/src/lib/i18n/errors/` with one file per language (`sr.json`, `bs.json`, `hr.json`, `en.json`). The message is selected using the organization's `language` field from the JWT `orgId` lookup.

```typescript
// src/lib/i18n/errors/en.json (excerpt)
{
  "BILKO-1001": "Invalid email or password.",
  "BILKO-1003": "Session expired. Please refresh your token.",
  "BILKO-9001": "You do not have permission to perform this action."
}
```

```typescript
// src/lib/error-formatter.ts
function formatError(code: string, orgLanguage: string, details?: Record<string, string[]>) {
  const messages = require(`./i18n/errors/${orgLanguage}.json`)
  return {
    error: {
      code,
      message: messages[code] ?? messages['BILKO-9004'],
      ...(details && { details }),
    }
  }
}
```

---

## Quick Reference — All Error Codes

| Code | HTTP | Module | Short Description |
|------|------|--------|-------------------|
| BILKO-1001 | 401 | Auth | Invalid credentials |
| BILKO-1002 | 403 | Auth | Account disabled |
| BILKO-1003 | 401 | Auth | Access token expired |
| BILKO-1004 | 401 | Auth | Invalid token |
| BILKO-1005 | 401 | Auth | No token provided |
| BILKO-1006 | 401 | Auth | Refresh token invalid/expired |
| BILKO-1007 | 429 | Auth | Auth rate limit exceeded |
| BILKO-1008 | 409 | Auth | Email already registered |
| BILKO-1009 | 422 | Auth | Weak password |
| BILKO-1010 | 403 | Auth | 2FA required |
| BILKO-1011 | 401 | Auth | Invalid 2FA code |
| BILKO-1012 | 401 | Auth | Invalid invite token |
| BILKO-2001 | 404 | Org | Organization not found |
| BILKO-2002 | 422 | Org | Invalid currency code |
| BILKO-2003 | 422 | Org | Invalid language code |
| BILKO-2004 | 400 | Org | Cannot change base currency |
| BILKO-2005 | 404 | Org | User not found in org |
| BILKO-2006 | 403 | Org | Cannot modify owner |
| BILKO-2007 | 403 | Org | Cannot remove self |
| BILKO-2008 | 409 | Org | User already a member |
| BILKO-3001 | 404 | Invoices | Invoice not found |
| BILKO-3002 | 404 | Invoices | Customer not found |
| BILKO-3003 | 400 | Invoices | Invoice not in draft |
| BILKO-3004 | 400 | Invoices | Invalid status transition |
| BILKO-3005 | 400 | Invoices | Customer has no email |
| BILKO-3006 | 422 | Invoices | Items array empty |
| BILKO-3007 | 422 | Invoices | Negative/zero amount |
| BILKO-3008 | 422 | Invoices | Invalid tax rate |
| BILKO-3009 | 422 | Invoices | Due date before invoice date |
| BILKO-3010 | 404 | Invoices | PDF not available |
| BILKO-3011 | 500 | Invoices | Email delivery failed |
| BILKO-4001 | 404 | Expenses | Expense not found |
| BILKO-4002 | 400 | Expenses | Expense not pending |
| BILKO-4003 | 400 | Expenses | Expense already processed |
| BILKO-4004 | 404 | Expenses | Vendor not found |
| BILKO-4005 | 413 | Expenses | Receipt file too large |
| BILKO-4006 | 422 | Expenses | Invalid file type |
| BILKO-4007 | 500 | Expenses | Upload failed |
| BILKO-4008 | 422 | Expenses | Virus detected in file |
| BILKO-5001 | 404 | Banking | Bank account not found |
| BILKO-5002 | 422 | Banking | GL account must be Asset type |
| BILKO-5003 | 404 | Banking | GL account not found |
| BILKO-5004 | 404 | Banking | Bank transaction not found |
| BILKO-5005 | 400 | Banking | Transaction already reconciled |
| BILKO-5006 | 422 | Banking | Invalid CSV format |
| BILKO-5007 | 413 | Banking | CSV file too large |
| BILKO-5008 | 422 | Banking | Invalid CSV date format |
| BILKO-6001 | 422 | Reports | From date required |
| BILKO-6002 | 422 | Reports | To date required |
| BILKO-6003 | 422 | Reports | Invalid date range |
| BILKO-6004 | 500 | Reports | Trial balance not balanced |
| BILKO-7001 | 404 | Contacts | Contact not found |
| BILKO-7002 | 400 | Contacts | Contact has active invoices |
| BILKO-7003 | 400 | Contacts | Contact has active expenses |
| BILKO-7004 | 422 | Contacts | Invalid country code |
| BILKO-7005 | 422 | Contacts | Invalid payment terms |
| BILKO-8001 | 404 | Accounts | Account not found |
| BILKO-8002 | 409 | Accounts | Account code already exists |
| BILKO-8003 | 422 | Accounts | Invalid account type |
| BILKO-8004 | 400 | Accounts | Cannot deactivate account with transactions |
| BILKO-8005 | 404 | Accounts | Parent account not found |
| BILKO-8006 | 422 | Settings | Invalid VAT rate |
| BILKO-9001 | 403 | General | Insufficient permissions |
| BILKO-9002 | 404 | General | Resource not found |
| BILKO-9003 | 422 | General | Validation failed |
| BILKO-9004 | 500 | General | Internal server error |
| BILKO-9005 | 429 | General | Rate limit exceeded |
| BILKO-9006 | 500 | General | Database error |
| BILKO-9007 | 503 | General | External service unavailable |
| BILKO-9008 | 400 | General | Invalid pagination parameters |

---

**End of Error Codes Catalog**

# Roles and Permissions

# Bilko Roles and Permissions

> **Project:** Bilko
> **Version:** 1.0
> **Date:** 2026-02-24
> **Status:** Specification
> **Applies to:** `apps/api/` — `authGuard` + `roleGuard` middleware, `organizationScope`

---

## Overview

Bilko uses Role-Based Access Control (RBAC) with four fixed roles. Roles are assigned per user within an organization. A user belongs to exactly one organization and has exactly one role within it.

**Roles defined in Prisma schema (`packages/database/prisma/schema.prisma`):**

```prisma
enum UserRole {
  owner
  admin
  accountant
  viewer
}
```

Roles are embedded in the JWT access token claim `role` and enforced on every request via the `roleGuard` middleware. No additional DB lookup is required for authorization at runtime.

---

## Role Definitions

### owner

The organization creator. There is exactly one `owner` per organization. The `owner` role is assigned automatically on registration and cannot be granted via invitation.

**Capabilities:**
- All financial operations (create, edit, approve, send, cancel)
- Full user management (invite, change roles, remove users)
- Organization settings management (name, currency, language, fiscal year)
- Chart of accounts management
- Organization deletion

**Restrictions:**
- Cannot be invited — assigned only at registration
- Cannot have their own role changed by anyone
- Cannot be removed by any other user

---

### admin

A trusted operator with near-full access. Assigned by the `owner` via invitation or role change.

**Capabilities:**
- All financial operations (create, edit, approve, send, cancel)
- User management: invite users, change roles of non-owner users
- Organization settings management
- Chart of accounts management

**Restrictions:**
- Cannot change the `owner`'s role
- Cannot delete the `owner`
- Cannot delete the organization
- Cannot promote another user to `owner`

---

### accountant

A bookkeeping operator who can perform all financial data entry but cannot administer the organization or its users.

**Capabilities:**
- Create, edit, and send invoices
- Create and edit expenses (pending only)
- Create manual journal entries (transactions)
- Import bank statements (CSV)
- Reconcile bank transactions with GL
- View all reports and financial data

**Restrictions:**
- Cannot approve or delete expenses
- Cannot invite or manage users
- Cannot change organization settings
- Cannot create or deactivate accounts (chart of accounts)
- Cannot create or manage bank accounts

---

### viewer

Read-only access to all financial data within the organization. Suitable for external accountants, auditors, or stakeholders who need visibility without write access.

**Capabilities:**
- View all invoices, expenses, contacts, bank accounts, and transactions
- View all reports (dashboard, P&L, balance sheet, cash flow, VAT, trial balance)
- Download invoice PDFs

**Restrictions:**
- Cannot create, edit, or delete any record
- Cannot approve expenses
- Cannot send invoices
- Cannot import bank statements or reconcile
- Cannot manage users or settings

---

## Permission Inheritance Model

Permissions do not inherit hierarchically. Each role has a discrete, fixed set of permissions. However, higher roles consistently include all permissions of lower roles:

```
viewer  ⊂  accountant  ⊂  admin  ⊂  owner
```

This means:
- Everything a `viewer` can do, an `accountant` can also do
- Everything an `accountant` can do, an `admin` can also do
- Everything an `admin` can do, an `owner` can also do

The single exception is `owner`-exclusive operations (role assignment, organization deletion) which are not part of `admin`'s scope.

---

## Endpoint Access Matrix

Full access matrix for all 50 API endpoints. `✅` = access granted. `❌` = `403 BILKO-9001` returned.

### Authentication (no role required)

| Endpoint | owner | admin | accountant | viewer | Notes |
|----------|-------|-------|------------|--------|-------|
| `POST /auth/register` | — | — | — | — | Public — no auth required |
| `POST /auth/login` | — | — | — | — | Public — no auth required |
| `POST /auth/refresh` | — | — | — | — | Cookie-based — no role required |
| `POST /auth/logout` | ✅ | ✅ | ✅ | ✅ | Any authenticated user |
| `GET /auth/me` | ✅ | ✅ | ✅ | ✅ | Any authenticated user |

---

### Organization

| Endpoint | owner | admin | accountant | viewer | Notes |
|----------|-------|-------|------------|--------|-------|
| `GET /organization` | ✅ | ✅ | ✅ | ✅ | All roles |
| `PUT /organization` | ✅ | ✅ | ❌ | ❌ | Settings change: owner + admin |

---

### Users

| Endpoint | owner | admin | accountant | viewer | Notes |
|----------|-------|-------|------------|--------|-------|
| `GET /users` | ✅ | ✅ | ❌ | ❌ | User list: owner + admin only |
| `POST /users/invite` | ✅ | ✅ | ❌ | ❌ | admin can invite up to admin role |
| `PUT /users/:id/role` | ✅ | ❌ | ❌ | ❌ | Role changes: owner only |
| `DELETE /users/:id` | ✅ | ❌ | ❌ | ❌ | User removal: owner only |

---

### Contacts

| Endpoint | owner | admin | accountant | viewer | Notes |
|----------|-------|-------|------------|--------|-------|
| `GET /contacts` | ✅ | ✅ | ✅ | ✅ | All roles |
| `POST /contacts` | ✅ | ✅ | ✅ | ❌ | Create: owner + admin + accountant |
| `GET /contacts/:id` | ✅ | ✅ | ✅ | ✅ | All roles |
| `PUT /contacts/:id` | ✅ | ✅ | ✅ | ❌ | Edit: owner + admin + accountant |
| `DELETE /contacts/:id` | ✅ | ✅ | ❌ | ❌ | Soft-delete: owner + admin |

---

### Invoices

| Endpoint | owner | admin | accountant | viewer | Notes |
|----------|-------|-------|------------|--------|-------|
| `GET /invoices` | ✅ | ✅ | ✅ | ✅ | All roles |
| `POST /invoices` | ✅ | ✅ | ✅ | ❌ | Create: owner + admin + accountant |
| `GET /invoices/:id` | ✅ | ✅ | ✅ | ✅ | All roles |
| `PUT /invoices/:id` | ✅ | ✅ | ✅ | ❌ | Edit (draft only): owner + admin + accountant |
| `PATCH /invoices/:id/status` | ✅ | ✅ | ✅ | ❌ | Status change: owner + admin + accountant |
| `GET /invoices/:id/pdf` | ✅ | ✅ | ✅ | ✅ | PDF download: all roles |
| `POST /invoices/:id/send` | ✅ | ✅ | ✅ | ❌ | Email send: owner + admin + accountant |

---

### Expenses

| Endpoint | owner | admin | accountant | viewer | Notes |
|----------|-------|-------|------------|--------|-------|
| `GET /expenses` | ✅ | ✅ | ✅ | ✅ | All roles |
| `POST /expenses` | ✅ | ✅ | ✅ | ❌ | Create: owner + admin + accountant |
| `GET /expenses/:id` | ✅ | ✅ | ✅ | ✅ | All roles |
| `PUT /expenses/:id` | ✅ | ✅ | ✅ | ❌ | Edit (pending only): owner + admin + accountant |
| `PATCH /expenses/:id/approve` | ✅ | ✅ | ❌ | ❌ | Approve: owner + admin only |
| `DELETE /expenses/:id` | ✅ | ✅ | ❌ | ❌ | Delete (pending only): owner + admin |

---

### Bank Accounts

| Endpoint | owner | admin | accountant | viewer | Notes |
|----------|-------|-------|------------|--------|-------|
| `GET /bank-accounts` | ✅ | ✅ | ✅ | ✅ | All roles |
| `POST /bank-accounts` | ✅ | ✅ | ❌ | ❌ | Create: owner + admin |
| `GET /bank-accounts/:id/transactions` | ✅ | ✅ | ✅ | ✅ | All roles |
| `POST /bank-accounts/:id/import` | ✅ | ✅ | ✅ | ❌ | CSV import: owner + admin + accountant |
| `POST /bank-accounts/:id/reconcile` | ✅ | ✅ | ✅ | ❌ | Reconcile: owner + admin + accountant |

---

### Reports

| Endpoint | owner | admin | accountant | viewer | Notes |
|----------|-------|-------|------------|--------|-------|
| `GET /reports/dashboard` | ✅ | ✅ | ✅ | ✅ | All roles |
| `GET /reports/profit-loss` | ✅ | ✅ | ✅ | ✅ | All roles |
| `GET /reports/balance-sheet` | ✅ | ✅ | ✅ | ✅ | All roles |
| `GET /reports/cash-flow` | ✅ | ✅ | ✅ | ✅ | All roles |
| `GET /reports/vat` | ✅ | ✅ | ✅ | ✅ | All roles |
| `GET /reports/trial-balance` | ✅ | ✅ | ✅ | ✅ | All roles |

---

### Chart of Accounts

| Endpoint | owner | admin | accountant | viewer | Notes |
|----------|-------|-------|------------|--------|-------|
| `GET /accounts` | ✅ | ✅ | ✅ | ✅ | All roles |
| `POST /accounts` | ✅ | ✅ | ❌ | ❌ | Create: owner + admin |
| `PUT /accounts/:id` | ✅ | ✅ | ❌ | ❌ | Edit/deactivate: owner + admin |

---

### Transactions (General Ledger)

| Endpoint | owner | admin | accountant | viewer | Notes |
|----------|-------|-------|------------|--------|-------|
| `GET /transactions` | ✅ | ✅ | ✅ | ✅ | All roles |
| `POST /transactions` | ✅ | ✅ | ✅ | ❌ | Manual journal entry: owner + admin + accountant |

---

### Settings

| Endpoint | owner | admin | accountant | viewer | Notes |
|----------|-------|-------|------------|--------|-------|
| `GET /settings/tax-rates` | ✅ | ✅ | ✅ | ✅ | All roles |
| `PUT /settings/tax-rates` | ✅ | ✅ | ❌ | ❌ | Update: owner + admin |

---

### Currencies

| Endpoint | owner | admin | accountant | viewer | Notes |
|----------|-------|-------|------------|--------|-------|
| `GET /currencies` | ✅ | ✅ | ✅ | ✅ | All roles |
| `GET /exchange-rates` | ✅ | ✅ | ✅ | ✅ | All roles |

---

## UI Element Visibility Per Role

Frontend elements are conditionally rendered based on the user's role (available in Zustand store from `/auth/me`). This is a display-only optimization — the API enforces permissions independently.

### Navigation Sidebar

| Nav Item | owner | admin | accountant | viewer |
|----------|-------|-------|------------|--------|
| Dashboard | ✅ | ✅ | ✅ | ✅ |
| Invoices | ✅ | ✅ | ✅ | ✅ |
| Expenses | ✅ | ✅ | ✅ | ✅ |
| Banking | ✅ | ✅ | ✅ | ✅ |
| Reports | ✅ | ✅ | ✅ | ✅ |
| Chart of Accounts | ✅ | ✅ | ✅ | ✅ (read-only) |
| Contacts | ✅ | ✅ | ✅ | ✅ (read-only) |
| Settings | ✅ | ✅ | ❌ | ❌ |
| Users & Teams | ✅ | ✅ | ❌ | ❌ |

---

### Action Buttons

| Action | owner | admin | accountant | viewer |
|--------|-------|-------|------------|--------|
| "New Invoice" button | ✅ | ✅ | ✅ | Hidden |
| "Send Invoice" button | ✅ | ✅ | ✅ | Hidden |
| "Mark as Paid" button | ✅ | ✅ | ✅ | Hidden |
| "Cancel Invoice" button | ✅ | ✅ | ✅ | Hidden |
| "New Expense" button | ✅ | ✅ | ✅ | Hidden |
| "Approve Expense" button | ✅ | ✅ | Hidden | Hidden |
| "Delete Expense" button | ✅ | ✅ | Hidden | Hidden |
| "New Contact" button | ✅ | ✅ | ✅ | Hidden |
| "Edit Contact" button | ✅ | ✅ | ✅ | Hidden |
| "Delete Contact" button | ✅ | ✅ | Hidden | Hidden |
| "Invite User" button | ✅ | ✅ | Hidden | Hidden |
| "Change Role" dropdown | ✅ | Hidden | Hidden | Hidden |
| "Remove User" button | ✅ | Hidden | Hidden | Hidden |
| "Add Bank Account" button | ✅ | ✅ | Hidden | Hidden |
| "Import CSV" button | ✅ | ✅ | ✅ | Hidden |
| "Reconcile" button | ✅ | ✅ | ✅ | Hidden |
| "New Account" (CoA) button | ✅ | ✅ | Hidden | Hidden |
| "Deactivate Account" button | ✅ | ✅ | Hidden | Hidden |
| "Manual Journal Entry" | ✅ | ✅ | ✅ | Hidden |
| "Update Settings" button | ✅ | ✅ | Hidden | Hidden |

---

### Settings Page Sections

| Section | owner | admin | accountant | viewer |
|---------|-------|-------|------------|--------|
| Organization Info (editable) | ✅ | ✅ | — | — |
| Tax Rates (editable) | ✅ | ✅ | — | — |
| Users List | ✅ | ✅ | — | — |
| Invite User form | ✅ | ✅ | — | — |
| Change User Role | ✅ | ❌ | — | — |
| Remove User | ✅ | ❌ | — | — |
| Delete Organization | ✅ | ❌ | — | — |

Accountant and viewer roles do not have access to the Settings page — the nav item is hidden and direct URL access returns `403 BILKO-9001`.

---

## Data Scope Rules — Organization-Level Multi-tenancy

Every authenticated user has their `organizationId` embedded in the JWT access token payload:

```typescript
interface AccessTokenPayload {
  sub: string       // User ID
  email: string
  role: UserRole
  orgId: string     // Organization ID — always present
  iat: number
  exp: number
}
```

### organizationScope Middleware

The `organizationScope` middleware runs after `authGuard` and `roleGuard` on all data-access endpoints. It attaches `req.organizationId` from the JWT and enforces that every DB query is scoped to that organization.

```typescript
// src/middleware/organization.middleware.ts
function organizationScope(req: AuthRequest, res: Response, next: NextFunction) {
  if (!req.user?.organizationId) {
    return res.status(401).json({ error: { code: 'BILKO-1005', message: 'Authentication is required.' } })
  }
  req.organizationId = req.user.organizationId
  next()
}
```

### Mandatory Query Scoping

Every Prisma query on organization-owned resources **must** include `where: { organizationId: req.organizationId }`. This prevents cross-organization data leakage even if a user somehow obtains a valid JWT with a different `orgId`.

```typescript
// Example: invoice fetch — always org-scoped
const invoice = await prisma.invoice.findFirst({
  where: {
    id: req.params.id,
    organizationId: req.organizationId,   // MANDATORY — never omit
  }
})

if (!invoice) {
  throw new NotFoundError('BILKO-3001')   // Returns 404 — same as if not found
}
```

Returning `404` (not `403`) when a resource exists in a different organization is intentional — it prevents enumeration of cross-org record IDs.

### Data Isolation Guarantees

| Scenario | Behavior |
|----------|----------|
| User accesses own org's invoice | `200` — returns invoice |
| User accesses invoice from another org | `404 BILKO-3001` — treated as not found |
| User's JWT has invalid `orgId` | `404 BILKO-2001` — organization not found |
| Deleted organization's records | Cascade delete (defined in Prisma schema) |
| User removed from org but JWT still valid | First request returns `404 BILKO-2001` |

All 15 database models with `organizationId` field enforce this scoping:
- `Organization`, `User`, `Account`, `Contact`
- `Invoice`, `InvoiceItem`, `Expense`, `Transaction`
- `BankAccount`

**Global models** (not org-scoped, shared across all organizations):
- `Currency`, `ExchangeRate`, `AccountType` — read-only reference data
- `SchemaVersion` — migration tracking

---

## Role Assignment and Invitation Flow

### Registration — Owner Assignment

When an organization is registered, the first (and only) `owner` is created:

```
POST /auth/register
  → Create Organization
  → Create User (role = 'owner')
  → Seed Chart of Accounts (country-based defaults)
  → Return JWT pair
```

The `owner` role cannot be granted by invitation. The `POST /users/invite` endpoint explicitly rejects `role: 'owner'` with `422 BILKO-9003`.

---

### Invitation Flow

An `owner` or `admin` can invite new users with roles `admin`, `accountant`, or `viewer`.

```
POST /users/invite
  { email, fullName, role: 'admin' | 'accountant' | 'viewer' }
  → Validate role (cannot be 'owner')
  → Create user record (passwordHash = null, isActive = false)
  → Generate one-time invite token (JWT, expires in 7 days)
  → Send invite email via SendGrid
  → Return { user, inviteLink }
```

The invitee clicks the link:

```
GET /auth/accept-invite?token=<JWT>
  → Verify token signature + expiry
  → Prompt user to set password (frontend form)

POST /auth/accept-invite
  { token, password }
  → Hash password
  → Set user.isActive = true
  → Invalidate invite token
  → Return JWT pair (auto-login)
```

**Invite constraints:**
- Invite link is single-use — consumed on first `POST /auth/accept-invite`
- Invite expires after 7 days (`BILKO-1012`)
- Cannot invite an email that already has a user in the organization (`BILKO-2008`)
- `admin` can invite up to `admin` role (cannot invite users with higher role than themselves)

---

### Role Change Flow

Only the `owner` can change another user's role:

```
PUT /users/:id/role
  { role: 'admin' | 'accountant' | 'viewer' }
  → Validate: caller must be 'owner'
  → Validate: target is not owner (BILKO-2006)
  → Validate: target is not caller (BILKO-2007)
  → Update user.role in DB
  → Log to LoggedAction
  → Return updated user
```

**Behavior after role change:**
- Change is effective on the **next API request** by the affected user
- Current active JWT is not invalidated immediately (access tokens expire in 15 min)
- On token refresh, the new role is embedded in the new JWT
- For immediate effect, invalidate all refresh tokens (force re-login) — not implemented in MVP

---

### User Removal Flow

Only the `owner` can remove users:

```
DELETE /users/:id
  → Validate: caller must be 'owner'
  → Validate: target is not owner (BILKO-2006)
  → Validate: target is not caller (BILKO-2007)
  → Set user.isActive = false (soft delete — preserves audit trail)
  → Add all user's refresh tokens to blacklist
  → Log to LoggedAction
  → Return 204
```

User data (invoices created, expenses entered) is retained for audit trail purposes. The `users` table record remains with `isActive = false`. The user cannot log in after removal.

---

## Permission Flow Diagram

```mermaid
flowchart TD
    REQUEST["API Request\nGET/POST/PUT/PATCH/DELETE /api/v1/*"] --> HELMET["Helmet\nSecurity headers"]
    HELMET --> CORS["CORS\nOrigin validation"]
    CORS --> RL["Rate Limiter\nper-IP / per-user"]
    RL -->|"429 BILKO-9005"| R429["429 Too Many Requests"]
    RL --> LOGGER["Morgan Logger\nHTTP access log"]
    LOGGER --> AUTH_GUARD["authGuard\nExtract Bearer token"]

    AUTH_GUARD -->|"No Authorization header"| R401A["401 BILKO-1005\nNo token"]
    AUTH_GUARD -->|"Token expired"| R401B["401 BILKO-1003\nToken expired"]
    AUTH_GUARD -->|"Invalid signature"| R401C["401 BILKO-1004\nInvalid token"]
    AUTH_GUARD -->|"Valid JWT"| EXTRACT["Extract claims\nsub, email, role, orgId"]

    EXTRACT --> ROLE_GUARD["roleGuard(allowedRoles)\nCheck role membership"]
    ROLE_GUARD -->|"Role not in allowedRoles"| R403["403 BILKO-9001\nInsufficient permissions"]
    ROLE_GUARD -->|"Role authorized"| ORG_SCOPE["organizationScope\nAttach req.organizationId"]

    ORG_SCOPE --> VALIDATE["Zod Validation\nschema.parse(req.body)"]
    VALIDATE -->|"Schema errors"| R422["422 BILKO-9003\nValidation failed"]
    VALIDATE -->|"Valid body"| HANDLER["Route Handler\n(module controller)"]

    HANDLER --> DB_QUERY["Prisma Query\nWHERE organizationId = req.organizationId"]
    DB_QUERY -->|"Record not in org"| R404["404 BILKO-Xxxx\nNot found"]
    DB_QUERY -->|"Business rule violation"| R400["400 BILKO-Xxxx\nBad request"]
    DB_QUERY -->|"DB error"| R500["500 BILKO-9006\nDatabase error"]
    DB_QUERY -->|"Success"| AUDIT["LoggedAction INSERT\nAppend-only audit trail"]
    AUDIT --> RESPONSE["200/201/204\nSuccess Response"]

    style R401A fill:#dc2626,color:#fff
    style R401B fill:#dc2626,color:#fff
    style R401C fill:#dc2626,color:#fff
    style R403 fill:#ea580c,color:#fff
    style R404 fill:#ca8a04,color:#fff
    style R400 fill:#ca8a04,color:#fff
    style R422 fill:#ca8a04,color:#fff
    style R429 fill:#ca8a04,color:#fff
    style R500 fill:#7c3aed,color:#fff
    style RESPONSE fill:#16a34a,color:#fff
    style DB_QUERY fill:#336791,color:#fff
    style AUDIT fill:#dc2626,color:#fff
```

---

## Role Assignment Diagram

```mermaid
flowchart LR
    subgraph "Registration"
        REG["POST /auth/register"] --> OWNER["owner\n(auto-assigned)"]
    end

    subgraph "Invitation — by owner or admin"
        INVITE["POST /users/invite\nrole: admin | accountant | viewer"] --> PENDING["Pending User\n(isActive = false)"]
        PENDING -->|"Accepts invite within 7 days"| ACTIVE["Active User\n(assigned role)"]
        PENDING -->|"Invite expires"| EXPIRED["BILKO-1012\nInvalid invite"]
    end

    subgraph "Role Changes — by owner only"
        OWNER -->|"PUT /users/:id/role"| CHANGE["Role updated\nEffective on next JWT refresh"]
    end

    subgraph "User Removal — by owner only"
        OWNER -->|"DELETE /users/:id"| SOFT_DEL["isActive = false\nRefresh tokens invalidated"]
    end

    style OWNER fill:#00E5A0,color:#000
    style ACTIVE fill:#16a34a,color:#fff
    style SOFT_DEL fill:#dc2626,color:#fff
    style EXPIRED fill:#ca8a04,color:#fff
```

---

## Middleware Implementation Reference

```typescript
// src/middleware/auth.middleware.ts

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

// Step 1: Verify JWT and attach user to request
async function authGuard(req: AuthRequest, res: Response, next: NextFunction) {
  const authHeader = req.headers.authorization

  if (!authHeader?.startsWith('Bearer ')) {
    return res.status(401).json({ error: { code: 'BILKO-1005', message: 'Authentication is required.' } })
  }

  const token = authHeader.substring(7)

  try {
    const payload = jwt.verify(token, process.env.JWT_SECRET!) as AccessTokenPayload
    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: { code: 'BILKO-1003', message: 'Session expired. Please refresh your token.' } })
    }
    return res.status(401).json({ error: { code: 'BILKO-1004', message: 'Invalid token. Please log in again.' } })
  }
}

// Step 2: Check role authorization
function roleGuard(allowedRoles: UserRole[]) {
  return (req: AuthRequest, res: Response, next: NextFunction) => {
    if (!req.user) {
      return res.status(401).json({ error: { code: 'BILKO-1005', message: 'Authentication is required.' } })
    }

    if (!allowedRoles.includes(req.user.role)) {
      return res.status(403).json({
        error: {
          code: 'BILKO-9001',
          message: 'You do not have permission to perform this action.',
          details: {
            required: allowedRoles,
            current: [req.user.role],
          },
        },
      })
    }

    next()
  }
}

// Step 3: Apply organization scope
function organizationScope(req: AuthRequest, res: Response, next: NextFunction) {
  req.organizationId = req.user!.organizationId
  next()
}

// Convenience: all authenticated roles
const allRoles: UserRole[] = ['owner', 'admin', 'accountant', 'viewer']

// Usage in routes:
router.post('/invoices',
  authGuard,
  roleGuard(['owner', 'admin', 'accountant']),
  organizationScope,
  validate(CreateInvoiceSchema),
  invoicesController.create
)

router.get('/invoices',
  authGuard,
  roleGuard(allRoles),
  organizationScope,
  invoicesController.list
)

router.patch('/expenses/:id/approve',
  authGuard,
  roleGuard(['owner', 'admin']),
  organizationScope,
  expensesController.approve
)
```

---

## Summary Table

| Capability | owner | admin | accountant | viewer |
|-----------|-------|-------|------------|--------|
| **Invoices** | | | | |
| View invoices | ✅ | ✅ | ✅ | ✅ |
| Create / edit invoice | ✅ | ✅ | ✅ | ❌ |
| Send invoice | ✅ | ✅ | ✅ | ❌ |
| Mark invoice paid / cancel | ✅ | ✅ | ✅ | ❌ |
| **Expenses** | | | | |
| View expenses | ✅ | ✅ | ✅ | ✅ |
| Create / edit expense | ✅ | ✅ | ✅ | ❌ |
| Approve expense | ✅ | ✅ | ❌ | ❌ |
| Delete expense | ✅ | ✅ | ❌ | ❌ |
| **Contacts** | | | | |
| View contacts | ✅ | ✅ | ✅ | ✅ |
| Create / edit contact | ✅ | ✅ | ✅ | ❌ |
| Deactivate contact | ✅ | ✅ | ❌ | ❌ |
| **Banking** | | | | |
| View bank accounts & transactions | ✅ | ✅ | ✅ | ✅ |
| Create bank account | ✅ | ✅ | ❌ | ❌ |
| Import CSV / reconcile | ✅ | ✅ | ✅ | ❌ |
| **GL Transactions** | | | | |
| View transactions | ✅ | ✅ | ✅ | ✅ |
| Create manual journal entry | ✅ | ✅ | ✅ | ❌ |
| **Reports** | | | | |
| View all reports | ✅ | ✅ | ✅ | ✅ |
| **Chart of Accounts** | | | | |
| View accounts | ✅ | ✅ | ✅ | ✅ |
| Create / edit / deactivate accounts | ✅ | ✅ | ❌ | ❌ |
| **Settings** | | | | |
| View tax rates | ✅ | ✅ | ✅ | ✅ |
| Update tax rates | ✅ | ✅ | ❌ | ❌ |
| Update org details | ✅ | ✅ | ❌ | ❌ |
| **Users** | | | | |
| View user list | ✅ | ✅ | ❌ | ❌ |
| Invite user | ✅ | ✅ | ❌ | ❌ |
| Change user role | ✅ | ❌ | ❌ | ❌ |
| Remove user | ✅ | ❌ | ❌ | ❌ |
| **Organization** | | | | |
| Delete organization | ✅ | ❌ | ❌ | ❌ |

---

**End of Roles and Permissions Documentation**

# Account Lockout (Brute-Force zaštita) — A12

# Bilko — Account Lockout (Brute-Force zaštita) — A12 / MC #104687

**Status:** implementirano 2026-07-03, commit `13b3a729` na `feat/task-104687` (Wave 1 — čeka merge u dev)
**Vlasnik:** CodeCraft backend · Proveo PASS (nezavisna verifikacija svih 5 AC)

## Ponašanje

- Nakon **5 uzastopnih neuspješnih** email/password login pokušaja, nalog se zaključava **15 minuta**.
- **6. pokušaj vraća HTTP 423** (`BILKO-AUTH-005`) **čak i s tačnom šifrom** — provjera zaključanosti ide PRIJE verifikacije šifre.
- Uspješan login **resetuje brojač** na 0 (legitimni korisnici s povremenim tipfelerima se ne zaključavaju).
- Istekli `locked_until` se auto-čisti prije provjere šifre (cooldown se sam otključa).
- **Admin unlock:** `PUT /api/v1/users/{id}/unlock` (permisija `users:manage`, org-scoped — cross-org pokušaj vraća 404).
- **Entra-only korisnici** (`passwordHash = NULL`, V66) nikad ne dotiču lockout logiku; Entra SSO put (`markEntraLogin`) ne konsultuje lockout.

## Implementacija

| Sloj | Šta |
|---|---|
| Migracija | `V108__account_lockout.sql` — `users.failed_login_attempts INT NOT NULL DEFAULT 0`, `users.locked_until TIMESTAMPTZ NULL` (aditivno) + drop/recreate 3 `bilko_auth.*` SECURITY DEFINER lookup funkcije (RETURNS TABLE promjena; OWNER/GRANT identični V31/V66 originalima — diffano) |
| Servis | `AuthService.kt` — lock-check prije `verifyPassword`; brojač = jedan atomski parametrizovani UPDATE (nema izgubljenih inkremenata pod konkurencijom); `adminUnlock(orgId, userId)` |
| HTTP | `LockedException` → `BILKO-AUTH-005` (423) u `StatusPages.kt`; ruta unlock u `UserManagementRoutes.kt`; dokumentovano u `ERROR-CODES.md` + `openapi.yaml` |
| Testovi | `AuthServiceLockoutTest.kt` — 5 scenarija (6. pokušaj s tačnom šifrom → 423; reset na uspjeh; cooldown expiry; admin unlock; cross-org 404). Izvršeno 5/5 + regresija `AuthServiceTest` 31/31, skipped=0 (XML ground truth) |

## Bitno: javna login ruta je 410

`POST /api/v1/auth/login` vraća **410 Gone** platformski (WP4/MC #103144 — Bilko je Entra-only, password login penzionisan). Lockout je time defense-in-depth: javna brute-force površina je ZATVORENA (Proveo live probe: 7×410 na api.bilko.cloud), a lockout se automatski primjenjuje ako se ruta ikad reaktivira. Odluka: rutu NE reaktivirati radi live-423 demonstracije.

## Poznati follow-up

- `bilko_auth.find_user_by_email` / `find_user_by_id` i dalje nemaju `SET search_path = public, pg_temp` (naslijeđeno iz V31, nije uvedeno ovim taskom) — hardening ticket.

## Evidence

- `/Users/makinja/system/evidence/104687/build-report-20260703.md`
- `/Users/makinja/system/evidence/104687/proveo-verdict-20260703.md`
- P2P mesh: `mesh-thr-357b1180` (builder) → `mesh-thr-516f2df0` / `mesh-msg-ba98cc59` (Proveo PASS)

## Ops napomene (otkriveno tokom taska)

1. **Gitleaks pre-commit** je gađao `../../.gitleaks.toml` — rušio SVAKI commit iz nested `.claude/worktrees/*` (fail-closed "Secrets detected" bez skena). Fixano u `.husky/pre-commit` (commit `13b3a729`): config je tracked na repo root-u → `--show-toplevel/.gitleaks.toml`.
2. **Testcontainers silent-skip:** `~/.testcontainers.properties` je pokazivao na legacy `docker.raw.sock` koji Ryuk ne može bind-mountati na aktuelnom Docker Desktopu → SVI integration testovi tiho SKIPPED uz "BUILD SUCCESSFUL". Fix: `unix:///var/run/docker.sock` + `ryuk.disabled=true`. **Lekcija: uvijek čitati `tests=`/`skipped=` iz testsuite XML-a, ne vjerovati zelenom buildu.**

# KPO Export RBAC Fix — MC #105326

# KPO Export RBAC Fix — MC #105326

## Finding

Proveo adversarial verify on MC #105321 (2026-07-12) found that `GET /reports/kpo/export/pdf`
and `GET /reports/kpo/export/xlsx` in `ReportRoutes.kt` were missing the
`requirePermission(principal, "report:export")` guard that every other export route
(KIR, KPR, VAT, P&L, balance-sheet, accountant CSV package) already has. Git diff confirmed
the gap pre-dated the T1b diff — it was a pre-existing asymmetry, not a regression.

## Fix

Added `if (requirePermission(principal, "report:export")) return@get` to both KPO export
routes in `apps/api/src/main/kotlin/no/alai/bilko/routes/ReportRoutes.kt`, matching the exact
pattern used at the other 11 export-route call sites in the same file (13 total after the fix).

## Tests

Added 4 integration tests to `apps/api/src/test/kotlin/no/alai/bilko/routes/ReportRoutesHttpIntegrationTest.kt`:
- `GET reports kpo export pdf rejects viewer role with 403`
- `GET reports kpo export xlsx rejects viewer role with 403`
- `GET reports kpo export pdf allows owner role` (passes RBAC, hits NotImplemented stub per MC #100038)
- `GET reports kpo export xlsx allows owner role`

Verified: `./gradlew integrationTest --tests "no.alai.bilko.routes.ReportRoutesHttpIntegrationTest" --rerun`
→ BUILD SUCCESSFUL, 0 failures / 0 errors.

## P2P Peer Verify

Proveo independent pre-verifier: PASS.
`mesh-thr-d41602c0-65fa-4d4c-bca4-8fe770f11704` / `mesh-msg-e3c3869f-f160-4e65-a9d0-8ba27f000686`.
Transcript: `/tmp/alai/p2p-pairing-evidence/mc105326-transcript.md`.

# Bilko — Payment-event obaveze (JOPPD): dizajn, scope odluke, runbook — MC #105742

# Bilko — Payment-event obaveze (JOPPD): dizajn, scope odluke, runbook

**MC:** #105671 (dizajn) + #105741 (Task A) + #105742 (Task B) + #105744 (Finverge BA verifikacija) + #105747 (RS redizajn, otvoreno)
**Status (2026-07-15):** Task A merged (PR 164, `b1417252`, azdo/main). Task B PR 165 open (`6b9ebe5c`), NOT merged — Proveo P2P adversarial verify još u toku (mesh thread otvoren, guard-rail auto-BLOCKED nije pravi verdikt). HR-07/RS-07 katalog i dalje `enabled: false`.

---

## 1. Šta feature radi

Payment-event obaveza je Bilko-ova podsjetnik logika koja se okida na **stvarnu isplatu plaće**, ne na kalendarski datum kao postojeći mjesečni podsjetnici (npr. MIP-1023, Obrazac 1002).

Tok (HR, implementirano):

```
POST /payroll/organizations/{orgId}/payslip-runs/mark-paid
  { periodYear, periodMonth, paymentDate }
        │  (V123: payslips.payment_date, SERIALIZABLE transakcija)
        ▼
PayrollService.markPayrollRunPaid()
        │  postavlja payment_date na SVE payslip redove org/period
        │  (ista transakcija, samo na write-grani, nikad na idempotent no-op)
        ▼
PaymentEventObligationService.onPayrollRunPaid(orgId, periodYear, periodMonth, paymentDate)
        │  HR org → čita HR-07 (JOPPD) iz kataloga SAMO za title/form metadata
        │  due_date = resolveNextBusinessDay(paymentDate)
        │  RS/BA org → no-op (vidi §2b, §2c)
        ▼
upsert ComplianceDeadlines  (keyed: org, year, deadlineType, period)
        │  korekcija datuma → UPDATE istog reda, ne duplikat
        ▼
notifyIfNeeded()  — JEDNA in-app + email notifikacija na kreiranje, VAN transakcije
        (bez T-7/T-1 kadence — nema lead-time koncepta, rok = dan isplate)
```

`resolveNextBusinessDay(date)` — čista funkcija, semantika je **roll-forward SAMO ako datum pada na neradni dan**, ne "uvijek sljedeći radni dan":
- Pon–Pet → passthrough (utorak isplata ostaje utorak rok)
- Subota → ponedjeljak (+2)
- Nedjelja → ponedjeljak (+1)

Ovo je vikend-only MVP (bez praznik-tabele — vidi §2d).

---

## 2. Scope odluke s razlozima (ključno za budućnost)

### (a) BA_FED (FBiH) i BA_RS isključeni iz payment-event tipa — Finverge verifikacija #105744, verdikt **NE**

MIP-1023 (FBiH) i Obrazac 1002 (RS-BiH) su **fiksni mjesečni agregatni izvještaji**, ne payment-event:
- MIP-1023: rok = 15. u mjesecu za prethodni mjesec (čl. 31 st. 5 Pravilnika o primjeni Zakona o porezu na dohodak FBiH), period-tag se odnosi na obračunski mjesec, **ne na mjesec isplate**.
- Obrazac 1002: rok = 10. u mjesecu za sve isplate iz prethodnog mjeseca (Zakon o poreskom postupku RS).

Oba entiteta su konfirmisana sa 7+ (FBiH) i 6+ (RS-BiH) nezavisnih izvora (FEB, paragraf.ba, advokat-prnjavorac.com, fineks.ba, poreskaupravars.org, vladars.rs). Već su ispravno modelovani u postojećem fiksno-mjesečnom katalogu (PR 157, #105641, CEO-odobreno #105640) — Task B ih namjerno NE dira. **Ne dodavati BA_FED/BA_RS u payment-event tip triger.**

### (b) RS (Srbija) isključena iz Task B builda — red-zone FAIL, #105742 redzone-tax-verdict.md

Red-zone porezna verifikacija (persona `porez-hr-105742`, web-verified 5+ nezavisnih izvora: porezionline.rs, aktivasistem.com, paragraf.rs, purs.gov.rs) je dala **FAIL** verdikt na originalni HR+RS dizajn:

- **PPP-PD (RS Srbija) se podnosi PRIJE isplate**, ne poslije. Mehanizam: prijava → PURS kontrola → **BOP broj (Broj Odobrenja za Plaćanje)** → BOP se unosi u nalog za prenos → tek onda isplata.
- Mark-paid-poslije-isplate model (kao HR-07) je **konceptualno pogrešan** za RS: ako je korisnik već kliknuo "isplaćeno" bez prethodno podnesene PPP-PD/BOP-a, klijent je već u prekršaju — podsjetnik koji stigne nakon toga je zakašnjeo po definiciji, ne samo "kasni".
- Dodatno flagovano: repo dokument `PAYROLL-PHASE2-PLAN.md` §2b sadrži interno neusklađen opis roka ("15th of month following payment") koji je u sukobu i sa web-verified pravilom i sa dizajn-katalogom RS-07 (`PER_PAYMENT_EVENT`). Fix isporučen odvojeno u **#105747** — nije popravljeno u Task B.

**Posljedica za build:** Task B je implementiran **HR-only**. RS org u `onPayrollRunPaid()` je dokumentovan no-op — nema deadline red, nema notifikaciju, log linija referencira #105747. RS-07 katalog entry ostaje netaknut (`enabled: false`).

### (c) HR-07/RS-07 katalog `enabled: false` do praznik-tabele (#105743, Lexicon gate)

`ObligationEngine.generateDeadlines()` i dalje ne emituje HR-07/RS-07 (potvrđeno testom `ObligationEngineUnaffectedTest` — 3/3 PASS, uključujući provjeru protiv REALNOG učitanog kataloga). Payment-event insertion je potpuno odvojen kod-put od kalendarskog generatora — katalog služi samo kao metadata SSOT (title/form), ne kao evaluator za ovaj tip roka. `enabled: true` čeka:
1. Praznik-tabela (#105743, MVP je trenutno vikend-only)
2. Lexicon sign-off na `requires_expert_validation` konvenciju (isti obrazac kao postojeći RS-07 flag)

### (d) Vikend-only MVP — praznici su Task C (odvojen, ne blokira A/B)

Dizajn (#105671 §4) eksplicitno je priznao ovaj gap kao MVP-known-limitation, ne skriveni nedostatak: puni praznik-kalendar zahtijeva `holidays` referentnu tabelu po jurisdikciji/godini + Lexicon/tax-expert sign-off (Zakon o blagdanima HR vs Zakon o praznicima RS se razlikuju). Cost/risk ako se ship-a bez praznika: pogrešan rok kad je isplata 1-2 radna dana prije praznika (npr. dan prije Uskrsnog ponedjeljka — vikend-only logika kaže "sljedeći dan" = sam praznik, što je netačno). Task C nosi ovu tabelu odvojeno, ne blokira A/B ship.

**Bitna napomena (red-zone stavka 1):** AC1 pseudokod (Mon-Fri passthrough, Sat/Sun roll-forward) je **ispravan izvor istine** za JOPPD pravilo. Prozna formulacija u originalnom dizajn-sažetku ("next business day after payment date") je dvosmislena i može zavesti na pogrešnu "uvijek +1" implementaciju — kod treba pratiti AC1, ne prozni opis.

---

## 3. Tehnika

### SERIALIZABLE transakcija (Proveo P2P race nalaz + fix)

Proveo adversarial P2P review na #105741 (`proveo-angie-105741`, verdikt PASS s jednim MEDIUM nalazom) je našao da `markPayrollRunPaid()` radi read-then-conditionally-write pod default `TRANSACTION_READ_COMMITTED` izolacijom, bez `SELECT...FOR UPDATE` i bez SERIALIZABLE. Konkretan race: dva konkurentna mark-paid poziva za isti org+period sa RAZLIČITIM datumima mogu oba pročitati pre-write stanje, oba računati `changed=true`, i kasniji UPDATE tiho pobjeđuje (lost update) — "gubitnik" dobija 200/`changed=true` iako je njegov upis pregažen.

**Fix (commit `3aa0ece2`):** `markPayrollRunPaid()` sada koristi
```kotlin
orgTransaction(
    organizationId = organizationId,
    transactionIsolation = java.sql.Connection.TRANSACTION_SERIALIZABLE,
) { ... }
```
1:1 kopija postojećeg repo presedana za isti read-then-derive-then-write oblik (`ExpenseService.kt:267-269`, `InvoiceService.kt:661-664` — broj-generacija). Bez retry-on-serialization-failure wrappera (repo-wide grep potvrdio: nijedan presedan ga nema).

Novi konkurentni test (2 real threads, `CountDownLatch`, isti pattern kao `FiscalDeviceSequenceTest`): provjerava da nakon dva istovremena poziva sa različitim datumima **svi payslip redovi u run-u nose ISTI konačni payment_date** — nema split-state. 7/7 PASS, ponovljeno 3x, 0 flake.

### Upsert keying

`ComplianceDeadlines` upsert je keyed na `(organizationId, year, deadlineType, period)` — korekcija datuma (npr. payroll admin ispravi pogrešnu isplatu) ažurira POSTOJEĆI red umjesto da duplira. Potvrđeno testom "correction updates the existing deadline row instead of duplicating" (assertion na isti `deadline id` kroz korekciju).

### Notifikacija van transakcije

`notifyIfNeeded()` se zove IZ route handler-a, NAKON što `dbQuery{}` vrati rezultat — izvan `markPayrollRunPaid()`-ove SERIALIZABLE transakcije. Fires samo ako `result.obligationResult` nije null (tj. nikad na idempotent no-op granu).

### RS/BA no-op s testovima

`onPayrollRunPaid()` grana na `country != HR` → no-op, pokriveno testovima "RS org mark-paid does not create any compliance deadline" i "does not fire any notification". Isti no-op put pokriva i BA_FED/BA_RS (§2a).

### Migracija

**Task A:** `V123__payslip_payment_date.sql` — `ALTER TABLE payslips ADD COLUMN IF NOT EXISTS payment_date DATE NULL` + indeks na `(organization_id, period_year, period_month, payment_date)`. Flyway-replay test (V121 lekcija): migracija privremeno uklonjena → test PADA (3/3 FAILED), vraćena → PASS — dokazuje da test stvarno testira migraciju, ne samo Kotlin model.

**Task B:** Nema nove migracije — `ComplianceDeadlines` (V9) je već imala sve potrebne kolone, potvrđeno čitanjem postojeće Exposed definicije prije pisanja koda.

### PR / commit trag

| Task | Repo | PR | Commit | Status |
|---|---|---|---|---|
| A (#105741) | Bilko | [PR 164](https://dev.azure.com/alai-holding/Bilko/_git/Bilko/pullrequest/164) | `d7d27d0c` + fix `3aa0ece2` | **merged** (`b1417252`) |
| B (#105742) | Bilko | [PR 165](https://dev.azure.com/alai-holding/Bilko/_git/Bilko/pullrequest/165) | `6b9ebe5c` | open, adversarial verify u toku |

---

## 4. Runbook / gotchas

### Kako se aktivira za prave klijente

1. **#105743** — sagraditi `holidays` referentnu tabelu (jurisdikcija, datum, naziv), seed za HR + RS za tekuću + narednu godinu.
2. `resolveNextBusinessDay()` proširiti da konsultuje tabelu kad postoji za org-ovu jurisdikciju, fallback na vikend-only ako nema podataka za tu godinu (isti defanzivni pattern kao `OrgComplianceProfile.default()`).
3. **Lexicon sign-off** zabilježen po `requires_expert_validation` konvenciji — TEK ONDA flip `enabled: true` na HR-07 (i RS-07 nakon #105747 redizajna, vidi ispod).
4. Bez ovog koraka HR-07/RS-07 ostaju `enabled: false` u katalogu — payment-event insertion put radi nezavisno od tog flaga (flag utiče samo na `ObligationEngine`-ov kalendarski generator, ne na `PaymentEventObligationService`), ali production-ready gate je i dalje ovaj sign-off.

### RS (Srbija) redizajn — #105747, otvoreno

RS-07 (PPP-PD) NE smije se implementirati po istom "mark-paid → deadline poslije → jedna notifikacija" šablonu kao HR-07. Minimalne opcije za redizajn (iz red-zone verdikta):
- UX kao "upozorenje PRIJE potvrde isplate — PPP-PD mora biti podnesena i BOP dobijen prije nego označite kao isplaćeno", ili
- Ako MVP ne može modelovati prijava-prije-isplate tok, notifikacioni tekst mora eksplicitno reći da rok prethodi isplati, ne "X dana poslije".

Ne kopirati HR-07 `resolveNextBusinessDay(paymentDate)` šablon direktno na RS-07 bez ove korekcije.

### PAYROLL-PHASE2-PLAN.md §2b — zastario opis

Repo dokument `docs/regulatory/PAYROLL-PHASE2-PLAN.md` §2b sadrži netačan/zastario opis PPP-PD roka ("Due by the 15th of the month following payment") koji je u sukobu i sa web-verified pravilom i sa RS-07 katalog tipom (`PER_PAYMENT_EVENT`). Isti tekst identifikovan identičan u više worktree kopija (`angie-105568`, `codecraft-105355`, `codecraft-105193/4`, `codecraft-105192`, `proveo-105276`, `codecraft-105687`). Fix pripada #105747 — sinhronizovati sve kopije nakon RS redizajna, ne prije.

### Prije `mc.js ready`/`done` na #105742

- PR 165 mora biti merged (trenutno open).
- Proveo adversarial P2P verdikt na #105742 mora doći kroz stvarni verifier, ne mesh guard-rail auto-BLOCKED poruku (poznat lažni pozitiv, viđen i na #105741 i #105742 threadovima — `eval` agent eksplicitno kaže da nije pravi verifier response).
- #105747 (RS redizajn) je odvojen task, ne blokira HR-only #105742 merge.

---

## Izvori (evidence)

- `~/system/evidence/105671/design-proposal.md` — Petter Graff arhitekturni dizajn, MC #105671
- `~/system/evidence/105741/build-evidence-2026-07-15.md` — Task A build (codecraft-hadi-105741), SERIALIZABLE fix
- `~/system/evidence/105741/proveo-p2p-verdict.md` — Proveo adversarial P2P (proveo-angie-105741), PASS + MEDIUM race nalaz
- `~/system/evidence/105742/build-evidence-2026-07-15.md` — Task B build (codecraft-hadi-105742), HR-only scope
- `~/system/evidence/105742/redzone-tax-verdict.md` — red-zone porezna verifikacija (porez-hr-105742), FAIL na RS
- `~/system/evidence/105744/finverge-ba-payment-event.md` — Finverge BA verifikacija (finverge-105744), NE za BA_FED/BA_RS

# Bilko — Financial Audit Trail & Retention Architecture

# Bilko — Financial Audit Trail & Retention Architecture

**Parent:** MC #105872 · **Plan:** `~/.claude/plans/greedy-greeting-flame.md` (CEO-approved 2026-07-17) · **Design team:** Petter Graff (lead), Martin Kleppmann (event-log semantics), Bruce Momjian (Postgres)

CEO mandate (2026-07-17): Bilko is serious accounting software — every mutation of a financial record must leave a trace ("if someone deletes a file and claims we lost their data, we must be able to prove what happened"). Files themselves do not have to be kept once their legal retention window expires, but the **trace of what happened must survive forever**.

## Status at time of writing (2026-07-17)

Phase A (foundation) is **merged to `azdo/main`** via Azure DevOps PR #178 (commit `c44ee7c9`, squash-merge of the full A2–A8 + C2 chain, 24 files, 4707 insertions) — genuinely a two-parent merge commit (`git cat-file -p c44ee7c9` shows parents `3f144e82`/`ce7ee96f`), consistent with a real PR merge, though this specific instance doesn't carry azdo's usual auto-generated "Merge pull request N from branch into main" message text that its neighboring commits do (those PRs were completed via REST rather than the azdo web UI the same evening, per team lead). Three sibling PRs from the same wave: **#179** (Lexicon legal docs, commit `a71a0b15`), **#180** (D1/D2 demo-country fix, commit `3f144e82`), **#181** (B4 storage consolidation, commit `ce64b93b`). PR numbers/attribution here are as reported by the team lead; this session could not independently query the azdo PR REST endpoint (`az repos pr show` fails with a local keychain error, `-25308 Can't fetch password from system`, unrelated to the PAT itself — see `~/.claude/projects/-Users-makinja/memory/technical_azdo_git_auth_paths_sp_not_in_org_2026-07-17.md`, PAT was rotated and confirmed working earlier the same day). The merge-commit hashes and their file contents **are** independently tool-verified (`git log`/`git show --stat` against `azdo/main`). This page documents what is **live in `main` today** and flags explicitly what is still open.

| Item | Status |
|---|---|
| A1 — RS/BA/HR retention law verification | Done (MC #105873) |
| A2/A3 — `financial_audit_log` + `document_retention_manifest` schema | Merged main (V124, V125), PR #178 |
| A4/A5 — capture trigger, pilot + 6-table fan-out | Merged main (V126, V128), PR #178 |
| A6 — actor/request-id context plumbing | Merged main (3 adversarial rounds, see below), PR #178 |
| A7 — backfill of existing rows | Merged main (V129), PR #178 |
| A8 — activity feed + entity timeline read API | Merged main (V130), PR #178; **web UI panel not built**, flagged to Vizu |
| C1 — `deleted_by`/`deletion_reason` columns | Merged main (V127), PR #178 |
| C2 — `InvoiceService.deleteInvoice` USER_DELETE snapshot | Merged main, PR #178 |
| B4 — Storage: R2/GCS → Azure Blob SDK, `deleteBlob()` | Merged main (`ce64b93b`), PR #181 |
| B1 — retention citation fixes + per-type override table | **MC #105880, in progress** — not yet in code |
| B5 — upload path writes `document_retention_manifest` row | **MC #105884, in progress** |
| B2 — purge worker, dry-run mode | **MC #105881 created, blocked** — red-zone (Momjian+Graff), waiting on A3+B1 |
| B3 — purge worker, live mode | **MC #105882 created, blocked** — waiting on B2 + ≥1 clean dry-run sprint on stage + explicit CEO sign-off before first live activation |
| D1/D2 — demo-country display bug (parallel workstream, same wave, not part of audit trail scope) | Fixed and merged (`3f144e82`), PR #180; documented separately, referenced here only for context |

**Do not treat B1/B2/B3/B5 as done.** MC #105881/#105882 exist but are deliberately blocked, not started — the purge worker does not run anywhere today; nothing purges blobs or applies retention. This page's "purge runbook" section below describes the *design*, not a currently operable procedure.

## Why a third audit table

Two audit mechanisms already existed before this program:

- **V1 `logged_actions`** — append-only JSONB, but only ever used for `ENTRA_JIT_LINK`/`SECURITY_VIOLATION` events, and has no `org_id`/`country_code` (any RLS check against it needs an expensive subquery).
- **V51 `audit_log`** — append-only, but scoped to admin-portal actions only.

Neither covers ordinary financial-entity mutations (invoices, expenses, contacts, transactions). Before this program, `InvoiceService.deleteInvoice` was a **hard delete with no audit trail at all** (`InvoiceService.kt:1004-1018` pre-change) — the exact gap the CEO mandate calls out.

Kleppmann's arbitration: build a **third** table, not a consolidation of V1/V51 and not per-domain tables. It carries `org_id` + `country_code` from day one (unlike V1), so ADR-017 Phase 2B-style partitioning/tenant-query patterns work immediately.

## Schema

### `financial_audit_log` (V124, `apps/api/src/main/resources/db/migration/V124__financial_audit_log.sql`)

Partitioned, append-only audit trail for financial-entity mutations and read-sensitive actions.

```
event_id       BIGINT GENERATED ALWAYS AS IDENTITY
org_id         UUID NOT NULL REFERENCES organizations(id)
country_code   VARCHAR(10) NOT NULL      -- denormalized, indexed, NOT the partition key
entity_type    VARCHAR(32)
entity_id      UUID
action         VARCHAR(24) NOT NULL      -- INSERT | UPDATE | DELETE | EXPORT | DOWNLOAD |
                                          -- RESTORE | USER_DELETE | RETENTION_PURGE
actor_user_id  UUID REFERENCES users(id) ON DELETE SET NULL
actor_label    TEXT
before_data    JSONB
after_data     JSONB
changed_fields TEXT[]
reason         TEXT
request_id     TEXT
client_ip      INET
is_backfilled  BOOLEAN NOT NULL DEFAULT false
occurred_at    TIMESTAMPTZ(6) NOT NULL DEFAULT now()

PRIMARY KEY (event_id, occurred_at)   -- occurred_at required in PK: it's the partition key
```

Design decisions (Momjian + Kleppmann arbitration, all tool-verified against the live schema, not assumed):

- **`PARTITION BY RANGE (occurred_at)`, monthly.** Purge is a time predicate (`DROP PARTITION`, never a million-row `DELETE`). `country_code` is a plain indexed column, not the partition key — this is the explicit rebuttal of ADR-017 Phase 2B's original country-list partitioning idea (Momjian: YAGNI, purge doesn't need country as a physical boundary).
- **`retain_until` is deliberately NOT a column.** Retention differs by jurisdiction (HR/RS/BA_FED/BA_RS) and by entity type within a jurisdiction (see retention table below), so it's computed at purge time from `organizations.legal_retention_years` plus a per-type override table (B1, not yet built), not stored redundantly on every row.
- **Append-only enforcement is two layers, not one:** `REVOKE UPDATE/DELETE FROM bilko_app` (grants: INSERT+SELECT only) **and** `BEFORE UPDATE/DELETE RAISE EXCEPTION` triggers on the partitioned parent. The trigger layer exists specifically to also catch `bilko_admin`, which is `BYPASSRLS` but *not* superuser — `BYPASSRLS` skips RLS policies, it does not skip triggers. PG16 propagates parent triggers to all partitions automatically, including ones created later by the (not-yet-built) partition-maintenance job.
- **RLS is PERMISSIVE + FORCE**, consistent with the current Phase 2A org-isolation era — `org_isolation_select`/`org_isolation_insert` scoped to `app.current_org_id`, plus `platform_admin_full_read`. A RESTRICTIVE flip is an explicit, separate, later CEO-gated decision; this migration does not touch it.
- **`country_code` is `VARCHAR(10)`, not `VARCHAR(8)`.** This was a real bug caught by Momjian's design review of the first commit: the source column (`organizations.country`) was widened to `VARCHAR(10)` back in V16 specifically to hold `BA_FED`/`BA_RS` (6 chars), and V120 did the same for `compliance_deadlines.country` "to keep country-code columns consistent." The original `VARCHAR(8)` would have fit today's values with zero headroom — any future jurisdiction code longer than 8 characters would have thrown inside the audit trigger and aborted the *parent* invoice/expense mutation transaction, which is a worse failure mode than a normal app-level validation error on a table explicitly designed to be an invisible safety net. Fixed pre-merge; a dedicated `BA_FED`/`BA_RS` width test fixture was added (previously the 30/30 green suite only exercised `HR`/`RS`/`BA`, none of which are close to the old 8-char limit).
- Initial monthly partitions cover 2026-07 through 2026-10 only. **A partition-maintenance job to create future months does not exist yet** — this is an open operational gap, not a documentation oversight; inserts for November 2026 onward will fail with "no partition found for row" unless a job is built before then.

### `document_retention_manifest` (V125, generalizes the existing V78 `hr_einvoice_archive` WORM pattern to all uploaded files)

```
manifest_id       BIGSERIAL PRIMARY KEY
org_id            UUID NOT NULL REFERENCES organizations(id)
entity_type       VARCHAR(32)
entity_id         UUID
original_filename TEXT NOT NULL
content_type      TEXT
file_size         BIGINT
sha256_hex        CHAR(64) NOT NULL
storage_backend   TEXT
storage_path      TEXT
uploaded_by       UUID REFERENCES users(id) ON DELETE SET NULL
uploaded_at       TIMESTAMPTZ(6) NOT NULL DEFAULT now()
blob_purged_at    TIMESTAMPTZ(6)
blob_purge_reason TEXT
purged_by_job     TEXT
```

This table is **permanent by design** — it has no `retain_until` because it never expires. It is the direct, literal answer to the CEO scenario: a row here proves a file existed, who uploaded it, its exact SHA-256, and — once the blob itself is purged — exactly when, why (`RETENTION_PURGE` vs `USER_DELETE`, matching `financial_audit_log.action`), and by which job run. `bilko_app` has INSERT+SELECT only; there is no append-only trigger here (unlike `financial_audit_log`) because a legitimate, narrow future UPDATE path exists — the not-yet-built purge worker marking `blob_purged_at` — and that grant is deliberately deferred to the Phase B2/B3 migration that creates the `bilko_purge_worker` role, since granting to a role that doesn't exist yet is a Flyway failure.

### Capture mechanism (V126 pilot on `invoices`, V128 fan-out to 6 more tables)

Arbitration point 1 (Graff + Momjian, overruling a pure application-level design): **capture is a generic `AFTER` DB trigger** (`capture_financial_audit()`, `to_jsonb(OLD/NEW)`, keyed off `TG_TABLE_NAME`), synchronous and transactional — not `pg_notify`/async, which would create a durability gap. This was chosen specifically because this same repo already proved app-level capture gets forgotten: `deleteInvoice` never called `logged_actions` before this program. Covers `invoices`, `expenses`, `contacts`, `transactions`, `bank_accounts`, `bank_transactions`, `invoice_items`.

Read-sensitive actions the trigger structurally cannot see — `export`, `download`, `restore` — are Kleppmann's second arbitration point: those are **application-level** events written into the same table from the service layer, since a DB trigger only fires on `SELECT`... it never fires at all, it can't be the mechanism for read events.

### C1/C2 — soft-delete and hard-delete audit coverage

- **V127** adds `deleted_by` + `deletion_reason` to every table with `deleted_at` — the actual list was tool-verified via a live `information_schema.columns` query against a full V1..V126 Testcontainers replay, not assumed from the task spec, and correctly caught tables the spec missed (`bank_accounts`, `chat_conversations`, `exchange_rates`, `expense_documents`, `offer_items`, `offers`, `travel_orders`).
- **C2**: `InvoiceService.deleteInvoice` still does a hard delete (DRAFT-only semantics unchanged), but now writes a `financial_audit_log` row with `action='USER_DELETE'` — a full invoice+line-items JSON snapshot — in the same transaction, before the delete. This is deliberately **not deduplicated** against the V128 trigger's own low-level `DELETE` row for the same statement: `USER_DELETE` is the business-level "someone claims we lost their invoice, prove what happened" answer; the trigger's `DELETE` row is the safety net that fires even for deletions that bypass the service entirely (e.g. direct SQL).

### A8 — read API

`GET /orgs/{id}/activity` (org-wide feed) and `GET /{entity}/{id}/timeline` (single-entity history), both paginated, both using the composite indexes created in V124. Gated by a new `activity:read` permission (V130), seeded **only** for `owner`/`admin` — not `viewer`, not `accountant` — because `before_data`/`after_data` carry full row snapshots including PII (e.g. a contact's OIB/JMBG-equivalent tax ID). Both endpoints filter directly on `financial_audit_log.org_id = principal.organizationId` rather than the existing `ResourceAccessFilter.requireOrgOwnership` helper, because that helper's table coverage predates this program and is missing 3 of the 7 audited tables. Cross-org access returns a 404 for the org feed and an empty list (not a 403/404) for the entity timeline, specifically to avoid leaking entity-existence across tenants via response-code side channel. **The web UI panel for this API was explicitly scoped out** — flagged to Vizu as a separate task rather than built as a rushed stub, per the implementing agent's own assessment that building it to the existing app's quality bar is realistically over an hour of frontend work.

## A6 saga — three adversarial rounds, two real bugs caught

A6 (`SET LOCAL app.current_user_id` / `app.current_request_id`, MC #105877) is the most important lesson from this program for anyone touching Ktor plugin install-order in this codebase. It went through three rounds of Momjian build → Parisa Tabriz (Securion) adversarial verify before landing.

**Round 1 finding (ThreadLocal vs. coroutine dispatcher hop):** the original implementation used a plain `ThreadLocal` to carry actor context. `dbQuery{}` dispatches onto `Dispatchers.IO`, which is a thread pool — a `ThreadLocal` set on the request-handling thread is simply not visible on whatever IO-pool thread the query actually executes on. Result: `actor_user_id` silently NULL for essentially all real requests. Fixed by switching to a kotlinx.coroutines `ThreadContextElement`, which is designed to survive exactly this kind of dispatcher hop.

**Round 2 finding (install-site / phase-ordering — the more interesting one):** even with the coroutine-context mechanism now correct in isolation, it was still not effective end-to-end, because `installOrgScopePlugin()` was installed via `intercept(ApplicationPhase.Plugins)` at the `Application.module()` level — which runs **before** Ktor's routing tree even dispatches into the `authenticate("bilko-jwt") { }` block, i.e. before `BilkoPrincipal` is resolved. So `call.principal<BilkoPrincipal>()` inside the interceptor was **always** null in production, authenticated request or not.

This is the same class of bug as **MC #104962**, which had already found and fixed the identical mistake for the sibling `TrialGatePlugin`: install at `Application` level and the plugin sees a permanently-null principal, no matter how correct its internal logic is. `TrialGatePlugin`'s own doc comment says this explicitly — but `installOrgScopePlugin()` had not been given the same fix.

The org_id side of the same mechanism was *not* affected, and understanding why is the actual transferable lesson: `orgTransaction(organizationId: String, ...)` takes `organizationId` as an explicit function parameter, sourced by every real call site via `effectiveOrgId(principal)` called **inside** the route handler (which does run after auth). So org_id never depended on the broken plugin's principal read at all — `currentOrgIdThreadLocal` turned out to be dead code in production (zero call sites). `actor_id` had no equivalent parameter — its only source was the broken plugin — so only the actor side silently failed.

**Fix:** `installOrgScopePlugin()` was changed from an `Application` extension to a `Route` extension, intercepting `ApplicationPhase.Call` (not `Plugins`), called as the literal first statement inside `authenticate("bilko-jwt") { }` in `Routing.kt` — before `install(TrialGatePlugin)` and all route registrations. `TrialGatePlugin` couldn't be copied verbatim as a pattern because it uses the `on(AuthenticationChecked)` hook, which is a plain synchronous callback with no `proceed()` — fine for a check-and-throw, but unable to keep a coroutine context element alive forward into a later route handler's `dbQuery` call, which is exactly what A6 needs.

Round 3's regression test (`OrgScopeActorContextHttpIntegrationTest.kt`) is itself a lesson worth keeping: it deliberately goes through the *real* HTTP client, the *real* JWT verifier, and the *real* routing tree — not the `withActorContextForTest` seam that round 2's test used, which structurally could not have caught this bug because it never calls `installOrgScopePlugin()`'s actual install path at all.

**Lesson for future Ktor plugin work in this codebase:** if a plugin needs `call.principal<T>()` to be non-null, verify empirically (not by inspection) which phase it actually observes the principal at — `ApplicationPhase.Plugins` at the `Application` level is provably too early for anything gated by `authenticate(...)`, regardless of where in the source file the `intercept()` call is textually nested. This has now bitten two different plugins (`TrialGatePlugin` in #104962, `OrgScopeSessionVariable` here) with the same root cause.

## Retention law — verified findings (MC #105873, primary sources)

| Jurisdiction | Books (journal/ledger) | Auxiliary books | Financial statements | e-invoice (fiscalized) | Payroll records |
|---|---|---|---|---|---|
| Croatia (HR) | at least 11 years (ZOR NN 78/2015 čl. 10(2)) | at least 11 years | permanent* (pin at B1) | 6 years (Zakon o fiskalizaciji NN 89/2025 čl. 35) | ≥6 years / analytics permanent |
| Serbia (RS) | 10 years (Zakon o računovodstvu, "Sl. glasnik RS" 73/2019 čl. 28) | 5 years | 20 years | n/a (SEF rules separate — pin at B1) | permanent |
| BiH — Federation (BA_FED) | at least 11 years (Zakon o računovodstvu i reviziji FBiH, "Sl. novine FBiH" 15/2021 čl. 49) | pin at B1 | pin at B1 | n/a | pin at B1 |
| BiH — Republika Srpska entity (BA_RS) | at least 10 years ("Sl. glasnik RS" 115/2025, article number TBD — not yet pinned from primary text) | at least 5 years | permanent | n/a | pin at B1 |

Two compliance findings from this verification are **not yet fixed in code** — they are B1's job, not done:

- **Code cites a dead law:** `PluginRS.kt:325` cites "Sl. glasnik RS br. 62/2013, čl. 24" — that law was replaced by 73/2019 in its entirety. The 10-year figure for books happens to still be correct, but the legal basis citation is wrong and must be updated to 73/2019 čl. 28.
- **🔴 Compliance gap, not just a citation issue:** `V50` currently sets `organizations.legal_retention_years = 10` for BA orgs generally. FBiH law requires **at least 11**. Any `BA_FED` organization in Bilko today is configured with a retention period one year short of its legal minimum. This is a live data-value fix, not a comment fix, and is the highest-priority item inside B1.

The BA_RS article number in the new 115/2025 law could not be pinned from available sources (target site had a TLS failure); the 10/5/permanent figures are confirmed via secondary sources, but B1's implementer must pin the exact article from the official Sl. glasnik RS 115/25 text before it is cited in any docs or code comment — this is a repeat of the same "don't propagate an unverified article number" discipline that caught the RS 62/2013→73/2019 gap in the first place.

## GDPR — erasure vs. retention

Position (plan arbitration point 7, not yet formalized in Privacy Policy/DPIA — that's Lexicon's MC #105277, linked, separate from this page): under **GDPR Art. 17(3)(b)**, the right to erasure does not apply where processing is necessary for compliance with a legal obligation — here, the accounting-law retention requirements in the table above. A financial audit snapshot (`financial_audit_log.before_data`/`after_data`, or a `document_retention_manifest` row) is **not** deleted on a user erasure request during the applicable retention window. The only erasure-adjacent action available is pseudonymizing `actor_label`/user-identifying fields after the retention window closes, and that is explicitly scoped as: every erasure request goes through Securion/legal review, never a self-serve deletion path, and never automatic.

## Purge worker — design (not yet built; B2/B3 are open tasks)

This section is a design skeleton for the not-yet-implemented `RetentionPurgeWorker`, so it is documented in the same place as the schema it operates on. **Nothing described below exists in the running system today.**

- **Primary path:** `DROP TABLE <partition>` for a `financial_audit_log` monthly partition once every row in it is past its jurisdiction's retention window. Fallback for mixed-retention partitions (a partition spanning rows from different orgs/countries with different retention lengths): batched `DELETE` with `LIMIT 5000` + commit per batch + `pg_sleep` throttle.
- **Order of operations is fixed and load-bearing: blob first, then row.** Deleting the `document_retention_manifest`/audit-row pointer before the blob would create an orphaned blob with no trace pointing to it — the opposite of this program's purpose. The blob delete must succeed (or be confirmed already-gone, 404-as-success, matching `ReceiptService.deleteBlob()`'s existing idempotent contract from B4) before the manifest row is marked `blob_purged_at`.
- **Dedicated `bilko_purge_worker` role**, `BYPASSRLS`, least-privilege, narrower than `bilko_admin`. A `SECURITY DEFINER purge_expired_partition()` function owned by `bilko_admin` is the intended privilege-elevation boundary rather than granting the worker role broad table access directly.
- **The purge worker's own actions are themselves audited** ("audit the auditor") in a `purge_worker_log` table — any trigger-disable needed for a legitimate purge happens inside the same transaction as the log write.
- **Dry-run is the default mode, and is required before any live run.** A dry-run must print the candidate rows/partitions it *would* purge and delete nothing; the acceptance test for B2 is specifically that a row still exists after a dry-run pass.
- **Live mode (B3) requires explicit CEO sign-off before its first activation**, and only after at least one full sprint of clean dry-run output on stage. This is a 🔴 red-zone item in the parent plan — senior-only, never a locally-dispatched builder task.
- **Cron mechanism is not yet confirmed.** `pg_cron` availability on Azure Flexible Server has not been verified (flagged to FlowForge); the fallback is the existing app-level cron pattern already used by `InvoiceCronRoutes.kt` (`COMPLIANCE_CRON_SECRET`).
- **How to read a dry-run log once B2 exists:** each dry-run entry will list, per candidate partition/row set: org, country, entity type, computed `retain_until` (from `organizations.legal_retention_years` + the not-yet-built per-type override table from B1), and the action that would be taken (`DROP PARTITION` vs. batched `DELETE`). Approval for a live run means a human has read that candidate list and confirmed it matches expectation — not just that the dry-run exited zero.

## Relationship to ADR-017 Phase 2B

ADR-017 Phase 2B originally proposed country-code-list partitioning. It was never implemented — Flyway's actual `HEAD` was `V116` at the start of this program (V36/V37 slots referenced in the old ADR discussion were long since consumed by unrelated migrations), so this program's `financial_audit_log` design is effectively a green-field implementation of "partition a big multi-tenant table," not a migration of an existing partitioned table. Momjian's rebuttal, adopted here: partition by `occurred_at` (RANGE, monthly) with `country_code` as a plain indexed column, not a partition key — purge is fundamentally a time predicate, and a country-list partition scheme would need to be extended by hand every time a new market is added, for no purge-performance benefit.

## Testing discipline (applies to every migration in this program)

Every migration listed above has a companion `V1xxMigrationTest.kt` (Testcontainers `postgres:16`) that does a **full `V1..V1xx` replay**, not a fresh-schema shortcut — this is the direct lesson from the V121 incident (Exposed's `SchemaUtils.create()`-based test schemas do not see `CHECK` constraints introduced by a migration; the only way to prove a constraint is real is to replay the actual migration chain and show that removing it makes the negative test fail). Each migration test includes negative tests (`UPDATE`/`DELETE` on the audit tables must throw, for both `bilko_app` and `bilko_admin`), an RLS cross-org test, and — where relevant — a partition-existence/partition-drop test.

## Evidence index

| Task | Evidence |
|---|---|
| A1 retention law verification | `~/system/evidence/105873/a1-retention-verification.md` |
| A6 build (3 rounds) | `~/system/evidence/105875/momjian-review.md` |
| A6 adversarial verify (Securion, 3 rounds) | `~/system/evidence/105877/parisa-verify.md`, `~/system/evidence/105877/verdict.md` |
| A7 backfill | `~/system/evidence/105878/verdict.md` |
| A8 activity/timeline API | `~/system/evidence/105879/verdict.md` |
| B4 storage consolidation | `~/system/evidence/105883/verdict.md` |
| C1 soft-delete columns | `~/system/evidence/105885/verdict.md` |
| C2 delete snapshot | `~/system/evidence/105886/verdict.md` |
| D1 demo-country bug root cause | `~/system/evidence/105874/d1-rootcause.md` |
| D2 demo-country bug fix + E2E | `~/system/evidence/105887/d2-verdict.md` |

## Open follow-ups

- MC #105880 (B1) — fix the dead-law citation and the BA_FED 10→11 year compliance gap; build the per-type retention override table.
- MC #105884 (B5) — upload path writes a `document_retention_manifest` row at upload time (blocked on both V125 and the B4 Azure Blob refactor being merged, which they now are — this can proceed).
- B2/B3 purge worker — MC #105881/#105882 created, both blocked (B2 on A3+B1; B3 on B2 + a clean dry-run sprint + CEO sign-off). Dry-run first, live mode requires CEO sign-off.
- Partition-maintenance job for `financial_audit_log` beyond 2026-10 — not started; will start failing inserts in November 2026 if not built.
- Web UI panel for the A8 activity/timeline API — flagged to Vizu, not scheduled.
- MC #105277 (Lexicon) — Privacy Policy/DPIA update for the Art. 17(3)(b) erasure-vs-retention position described above.
- ADR for this design has not been written into the repo yet — outline below, for whoever picks up that task.

## ADR outline (for a future repo-side ADR, not written to the repo by this task)

Proposed title: **ADR-0XX — Financial Audit Trail: append-only partitioned log, not a consolidation of V1/V51**

1. Context: CEO mandate, gaps in V1/V51 coverage, `deleteInvoice` hard-delete-with-no-trail example.
2. Decision: third table (`financial_audit_log`), RANGE-partitioned by `occurred_at` monthly, `country_code` denormalized not partition key; dual append-only enforcement (REVOKE + trigger); hybrid capture (DB trigger for mutations, application-level for read-sensitive actions); permanent `document_retention_manifest` for file-level trace, decoupled from blob lifecycle.
3. Alternatives considered and rejected: consolidating into V1 or V51 (Kleppmann — wrong semantics, V1 has no tenant context); async/`pg_notify` capture (durability gap); country-code partitioning per original ADR-017 Phase 2B sketch (Momjian — no purge-performance benefit, extra operational burden per new market); hash-chained tamper-evidence (deferred, not rejected — revisit only on explicit regulatory/enterprise requirement).
4. Consequences: purge becomes a partition-drop operation once B2/B3 land; every future table needing financial audit coverage follows the V126/V128 trigger-fan-out pattern; A6's install-site lesson should be called out as a standing Ktor-plugin gotcha for this codebase.
5. Status: Phase A implemented and merged; Phase B (retention enforcement) open.

# Bilko — Retention entity_type mapping (V133, MC #105965)

# Retention entity_type mapping (V133)

**MC:** #105965 (parent #105872 audit-trail program) · **Autor:** Bruce Momjian (CodeCraft) · **Verdict:** PASS (FORGE MLX peer-verify PASS) · **Grana:** azdo `feat/105965-entity-type-mapping` (nije u main)

## Problem

`document_retention_manifest.entity_type` je čuvao IMENA TABELA ('expenses','inbox_items' iz B5), a `entity_type_retention_policy` (B1) je seedovana PRAVNIM KATEGORIJAMA ('isprave','eracun'). Nisu se mapirali → RetentionPurgeWorker fail-safe skip → dry-run nalazi ~0 kandidata. Worker je (linije 53-68, 336-342) eksplicitno imenovao ovaj gap kao MC #105965.

## Rješenje — dedicated lookup tabela

**`entity_type_mapping(jurisdiction, table_entity_type, legal_entity_type)`** (V133). Odluka: zasebna tabela, NE kolona na policy tabeli.
- **Zašto tabela, ne kolona:** composite FOREIGN KEY `(jurisdiction, legal_entity_type) → entity_type_retention_policy(jurisdiction, entity_type)` na DB nivou onemogućava da mapping red pokazuje na nepostojeću pravnu kategoriju (dokazano V133MigrationTest FK-rejection testom). Kolona bi rekreirala isti "entity_type znači dvije stvari" ambiguitet i ne bi izrazila jurisdikcijski-skoupovanu many-to-one vezu.
- **Seed:** HR + BA_FED expenses/inbox_items → isprave. **RS / BA_RS namjerno NEmapirani** — nema odgovarajućeg policy reda (zasebna pravna-verifikaciona rupa, MC follow-up, NE izmišljati kategorije).
- **Worker:** rezolvira entity_type kroz mapping prvo; ako nemapiran, fallback = tretira entity_type kao već-pravnu-kategoriju (čuva staro ponašanje za fixture koji koriste 'isprave' direktno); fail-safe skip samo ako oba lookupa promaše.

## Testovi (V121 standard, Testcontainers postgres:16)

21/21 zeleno: V133MigrationTest (5: seed exactness, UNIQUE, CHECK, composite-FK rejection, grants) + 16 RetentionPurgeWorkerTest uklj. `candidatesFound > 0` na realnom seedu + companion "ukloni mapping red → isti kandidat nestane" (doslovni V121 'dokaz = ukloni → test pada'). V131/V132 regresija re-run zelena.

## Odnos prema purge lancu

Ovo je PREP: odblokira **#105881 (B2 dry-run)**. **B3 live purge (#105882) ostaje CEO-sign-off gated** — nijedan živi DELETE nije pokrenut. Evidence: `~/system/evidence/105965/verdict.md`.

# Bilko — RetentionPurgeWorker DRY-RUN (B2, MC #105881)

# RetentionPurgeWorker DRY-RUN (B2)

**MC:** #105881 (parent #105872) · **Autor:** Bruce Momjian (CodeCraft) + Graff red-zone review · **Verdict:** PASS (FORGE MLX peer-verify PASS) · **Grana:** azdo (rebase tip 844fc11c, NIJE main-merge dok red-zone gate ne prođe)

## Šta radi

Dry-run purge worker: čita `document_retention_manifest` + `entity_type_mapping` (V133) + `entity_type_retention_policy` + `legal_retention_years`, **LOGUJE kandidate za purge, NIŠTA NE BRIŠE**. Sada kad je #105965 mapping integrisan, dry-run NALAZI kandidate (prije: fail-safe skip → 0).

## Dokaz

39/39 Testcontainers (postgres:16) zeleno end-to-end na merge-anoj grani: V131(9)+V132(9)+V133(5)+RetentionPurgeWorkerTest(16), uklj. `candidatesFound > 0` asertaciju + V121 companion "ukloni mapping → test pada". Evidence: `~/system/evidence/105881/verdict-b2-finalization-2026-07-19.md`.

## Cron mehanizam

`RetentionPurgeCronRoutes.kt` — POST `/api/v1/internal/retention/purge-dry-run`, `PURGE_CRON_SECRET` header auth, fail-closed 503, mounted u Routing.kt (isti pattern kao InvoiceCronRoutes/ComplianceCronRoutes). **INERTAN** dok se ne provisionira `PURGE_CRON_SECRET` + eksterni trigger. Odluka: app-cron fallback; pg_cron (Azure Flexible) optimizacija odgođena do #105964 RBAC.

## 🔴 Red-zone invarijante (7, svaka grep-verifikovana, ne pretpostavljena)

1. `DRY_RUN` hard-coded `true`, nema override. 2. Nula DELETE/UPDATE/DROP dodano #105965 integracijom (samo read-only SELECT lookup). 3. `purge_expired_partition()` i dalje bez ijednog caller-a, bez novog EXECUTE granta. 4. `bilko_purge_worker` write grant nepromijenjen/uzak. 5. mapping-miss fallback matchuje samo human-authored policy redove (companion test). 6. cron ruta fail-closed/inert. 7. `entity_type_mapping` + `entity_type_retention_policy` read-only kroz worker (svaki ref = .selectAll()). Graff review: `~/system/evidence/105881/graff-review.md`.

## Odnos prema B3

**B3 live purge (#105882) = jedina preostala CEO-sign-off tačka cijelog programa.** B2 ne dodaje nijedan EXECUTE/DELETE/DROP grant (grep-potvrđeno). Živi purge se ne može desiti dok CEO eksplicitno ne odobri B3 + ne provisionira live flag + PURGE_CRON_SECRET.

# Bilko — Support agent rola + RLS temelj (Faza A, MC #106050/#106051)

# Support agent rola + RLS (Support MVP Faza A)

**MC:** #106050 (A1) + #106051 (A2), parent #106049 · **Autor:** Bruce Momjian · **Red-zone verify:** Parisa Tabriz (Securion) PASS · **Grana:** azdo `feat/106051-support-agent-rbac` (c4c8b4f4, NIJE main dok se A4/B ne ožiče)

## Šta

DB temelj za support osoblje: nova rola `support_agent` koja vidi tikete SVIH klijenata ali NIŠTA finansijsko.

- **V133** — permission catalog: `support_agent` dobija TAČNO `support_ticket:read` + `support_ticket:reply` (colon-format po V67 CHECK-u). NULA finance/accounting/payroll ključeva. Idempotentno.
- **V134** — RLS + grants: cross-org SELECT na `support_tickets` gejtован NOVIM GUC-om **`app.is_support_agent`** (NAMJERNO NE reciklira `app.is_platform_admin` — to bi tiho dalo supportu sve što platform-admin tabele daju). Column-scoped UPDATE grant (status, resolution_note, triage_json, external_ref, updated_at — org_id/context_bundle isključeni na GRANT nivou). Usput zakrpio pre-existing gap: support_tickets (V73) nikad nije imao eksplicitan bilko_app GRANT (nevidljivo u prod jer bilko_admin BYPASSRLS).
- **Rola = pravi users.role login**, ne service-account (bilko-support-triage je mašinski, bez per-human atribucije).

## Sigurnost (Parisa adversarial verify, 6/6 drži)

1. Finance leak: nema — `is_support_agent` se ne pojavljuje ni u jednoj finance/audit policy kroz 134 migracije; support konekcija na te tabele vezana istim org_isolation policy-ma. 2. GUC scope: `is_support_agent` samo u V134, samo na support_tickets policy-ma. 3. Cross-org: samo support_tickets otvoren, ništa drugo. 4. Permission over-grant: resolve() = exact-equality lookup, bez wildcard/prefix; mehanički ne može vratiti više od 2 ključa. 5. Column-scoped grant potvrđen. 6. RLS nigdje globalno ugašen; nema GUC-injection primitiva. Evidence: `~/system/evidence/106051/parisa-adversarial-verify.md`.

## ⚠️ Bitno za sljedeću fazu (A4/B)

Aplikacijski sloj JOŠ ne ožičuje rolu — RLS politike su trenutno nedostižan **dead code** (siguran failure mode; rute i dalje idu preko `app.is_platform_admin`). A4 (`requireSupportAgent()` guard) + B (rute koje SET LOCAL `app.is_support_agent`) su prvi kod koji stварno pali novi GUC → **traže vlastiti adversarial pass**, ne voze na ovom verdiktu (Parisa flag).

# Bilko — Support ticket reply + GUC aktivacija (Faza B, MC #106054-106057)

# Support ticket reply + GUC aktivacija (Faza B)

**MC:** #106054 (B1) #106055 (B2) #106056 (B3) #106057 (B4) + #106071 (gap-fix), parent #106049 · **Build:** Momjian (B1+gap) + CodeCraft (B2-B4) · **Red-zone verify:** Parisa Tabriz GUC-execution adversarial PASS · **Grana:** azdo `feat/106051-support-agent-rbac` (5c06350a, NIJE main)

## Šta

Support agent sada može ODGOVORITI korisniku na tiket (async), i tu se PRVI put aktivira `app.is_support_agent` prekidač (dosad dead-code).

- **B1** (V136): `support_ticket_messages` thread tabela (agent↔customer poruke) + RLS (agent cross-org kroz app.is_support_agent, customer samo svoj ticket) + uski grants. Append-only.
- **B2** 🔴: `POST /admin/support/tickets/{id}/messages` — agent reply. **PRVI kod koji izvršava `SET LOCAL app.is_support_agent='true'`** — unutar vlastite transakcije, tek nakon requireSupportAgent() guarda, mirror OrgScopeSessionVariable pattern. Upis poruke + NotificationService (korisnik dobije obavijest) + audit_log.
- **B3**: `GET /support/tickets/{id}/messages` — customer strana, postojeći JWT, NE pali GUC; app-layer org+ticket WHERE filter (defense-in-depth uz RLS) → cross-tenant = 404.
- **B4**: `GET /admin/support/tickets` scope za support_agent (cross-org) + platform_admin backward-compat. Zaseban guard/sealed-class, ne dira detail/PATCH.
- **#106071 gap-fix**: generički invite putevi (POST /admin/invitations, POST /admin/users, PUT /users/:id/role) sada REJECT-uju role=support_agent — samo dedicated platform-admin support-invite (A3) ga smije dodijeliti.

## 🔴 Sigurnost (Parisa GUC-execution adversarial, sve drži)

1. **Pool leakage: nema** — Database.kt isAutoCommit=false (SET LOCAL transaction-scoped); svaki request svježa Exposed transakcija; pool-test (Hikari pool=1, druga tx čita NULL, 3×) + Parisa nezavisno potvrdila handler shape. 2. **Ekskluzivnost**: B4 sealed-class exhaustive `when` = compile-time garancija da se nikad ne postave oba prekidača; grep — nijedan drugi fajl ne referencira is_support_agent. 3. **#106071 stварno zatvoren** na 5c06350a (3 call-site exact-match reject, ne tiketiran). 4. **B3** ne pali GUC, app-layer filter daje pravi 404. 5. **B4** detail/PATCH byte-for-byte nepromijenjeni. Evidence: `~/system/evidence/106055/parisa-b-guc-adversarial-verify.md`.

## Otvoreni tech-debt (van scope-a, evidentiran)
#106070 StatusPages 403-body · #106072 org-name unique · #106075 V73 ::uuid 500 · Jackson-vs-kotlinx serialization mehanizam (D1 docs presuda).

## Ostaje
C (Vizu support konzola UI) → D (docs) → E (Angie E2E uživo).

# Bilko — Support sistem — arhitektura (MC #106061)

**Parent:** MC #106049 (Support MVP) · **Ovaj task:** MC #106061 (D1) · **Agent:** Skillforge · **Datum:** 2026-07-20

Ova stranica postoji da se izbjegne zabuna od 2026-07-13 ("dva backena") — Bilko ima **tri odvojena support mehanizma** plus jedan odgođen. Nijedan se ne preklapa niti dijeli kod s drugim. Ako trebate promijeniti support ponašanje, prvo utvrdite u KOJI od ova tri/četiri komada spada promjena.

## 1. Mapa sistema

```mermaid
flowchart TB
    subgraph customer["Customer-facing"]
        CW["ChatWidget.tsx<br/>apps/web/components/chatbot/"]
        CBS["ChatbotService.kt<br/>/chatbot/message endpoint<br/>KANONSKI AI customer backend"]
        CC["ComplianceClassifier.kt<br/>floor/compliance guard (#105476)<br/>fail-closed na compliance pitanja"]
        CW -->|"POST /chatbot/message"| CBS
        CBS --> CC
    end

    subgraph staff["Staff-only (interno)"]
        BSA["bilko-support-answer.js<br/>ZASEBAN staff-copilot daemon<br/>NIJE customer-facing, NE dijeli kod s ChatbotService"]
    end

    subgraph human["Human support (OVAJ MVP, MC #106049)"]
        SA["support_agent rola<br/>RBAC: support_ticket:read / support_ticket:reply"]
        RLS["RLS cross-org izuzetak<br/>app.is_support_agent GUC<br/>SAMO support_tickets + support_ticket_messages"]
        REPLY["Async ticket reply<br/>NotificationService polling<br/>support_ticket_messages thread"]
        SA --> RLS
        RLS --> REPLY
    end

    subgraph future["Faza 3 (ODGOĐENO)"]
        RT["Real-time live chat<br/>AI->čovjek handoff<br/>build in-house (Ktor WS+Redis) vs Chatwoot/Crisp<br/>trigger: realan volumen support zahtjeva"]
    end

    CC -.->|"nikad ne dijeli backend"| SA
    BSA -.->|"nikad ne dijeli backend"| SA
    REPLY -.->|"kad dođe red"| RT

```

## 2. AI chat — customer widget (KANONSKI, ne dira ovaj MVP)

- **Backend:** `ChatbotService` — endpoint `POST /chatbot/message`. Ovo je JEDINI kanonski AI customer-facing backend u Bilku.
- **Frontend:** `ChatWidget.tsx` (`apps/web/components/chatbot/ChatWidget.tsx`).
- **Compliance guard:** `ComplianceClassifier.kt` — floor/compliance guard iz MC #105476, live u produkciji, PASS. Fail-closed dizajn: na pitanjima koja spadaju u compliance-klasu bez pouzdanog KB hita, sistem odbija davati odgovor (ne pretpostavlja).
- **Verifikovano iz koda:** `apps/api/src/main/kotlin/no/alai/bilko/services/ChatbotService.kt` i `services/chatbot/ComplianceClassifier.kt` — komentari na linijama 160–265 potvrđuju fail-closed granu za compliance-klasu pitanja.

## 3. AI staff triage — zaseban daemon

- **`bilko-support-answer.js`** — ZASEBAN staff-copilot daemon. Pomaže ALAI/Bilko support osoblju da brže odgovori na tikete (draft odgovora, KB pretraga za internu upotrebu).
- **NIJE customer-facing.** Ne prima poziv od `ChatWidget.tsx`, ne dijeli kod ni endpoint s `ChatbotService`.
- Postoji nezavisno od RBAC/RLS promjena u ovom MVP-u (Faza A/B) — support\_agent rola ne mijenja kako ovaj daemon radi.

## 4. Human support — NOVO u ovom MVP-u (MC #106049)

Ovo je jedini dio arhitekture koji je STVARNO nov u ovom talasu. Detalji u:

- **Faza A** — "[Bilko — Support agent rola + RLS temelj (Faza A)](https://docs.alai.no/books/backend/page/bilko-support-agent-rola-rls-temelj-faza-a-mc-106050106051)" (MC #106050 A1 + #106051 A2) — nova `support_agent` rola, RBAC permisije, RLS cross-org SELECT/UPDATE izuzetak na `support_tickets` preko nove `app.is_support_agent` GUC.
- **Faza B** — "[Bilko — Support ticket reply + GUC aktivacija (Faza B)](https://docs.alai.no/books/backend/page/bilko-support-ticket-reply-guc-aktivacija-faza-b-mc-106054-106057)" (MC #106054–#106057) — `support_ticket_messages` thread tabela + RLS (B1), POST reply endpoint koji PRVI PUT u kodu stvarno postavlja `SET LOCAL app.is_support_agent = 'true'` (B2), GET customer strana poruka (B3), lista tiketa scoped za support\_agent uz regresiju platform\_admin (B4).
- Puna RBAC/RLS referenca i runbook — vidi ["Bilko — Kako support agent radi"](https://docs.alai.no/books/backend/page/bilko-kako-support-agent-radi-runbook-mc-106063) (E2, runbook).

## 5. Faza 3 (odgođeno) — Real-time live chat + AI→čovjek handoff

- **Status:** ODGOĐENO. Nije dio ovog MVP-a (MC #106049 pokriva samo async ticket reply).
- **Otvorena odluka kad dođe red:** build in-house (Ktor WebSocket + Redis/ACA sticky sessions) vs gotov servis (Chatwoot/Crisp — napomena: GDPR data-processor ugovor za PII bio bi potreban ako se ide ovim putem).
- **Trigger za pokretanje:** realan volumen support zahtjeva koji async ticket-reply model više ne pokriva dovoljno br‌zo.

## 6. Linkovi

- Linkovano iz `DEPLOY-MAP.md` i `BUILD-BLUEPRINT.md` u repou (`~/business/ALAI-Holding-AS/products/Bilko/`).
- Plan: `~/.claude/plans/bilko-support-mvp-2026-07-20.md`
- Evidence: `~/system/evidence/106050/` (A1, ako postoji), `~/system/evidence/106051/` (A2), `~/system/evidence/106053/` (A3/A4), `~/system/evidence/106054/` (B1), `~/system/evidence/106055/` (B2/B3/B4).

# Bilko — Kako support agent radi (runbook, MC #106063)

**Parent:** MC #106049 (Support MVP) · **Ovaj task:** MC #106063 (E2) · **Agent:** Skillforge · **Datum:** 2026-07-20

Vidi također: [Faza A](https://docs.alai.no/books/backend/page/bilko-support-agent-rola-rls-temelj-faza-a-mc-106050106051) (RBAC+RLS temelj), [Faza B](https://docs.alai.no/books/backend/page/bilko-support-ticket-reply-guc-aktivacija-faza-b-mc-106054-106057) (ticket reply + GUC aktivacija), i ["Support sistem — arhitektura" (D1)](https://docs.alai.no/books/backend/page/bilko-support-sistem-arhitektura-mc-106061) za mapu svih support mehanizama.

## 1. Kako se support agent provisionira

- Dedicated endpoint: **`POST /admin/support/agents/invite`** (`SupportAgentProvisioningRoutes.kt`, MC #106052/A3).
- **Ko može pozvati:** SAMO `platform_admin` (`requirePlatformAdmin()`) — isti širok gate koji koriste svi drugi platform-ops admin route-ovi. Nema support\_agent-specifičnog uslova za KO smije pozvati novog agenta — samo za šTA taj agent, jednom kreiran, smije poslije vidjeti.
- **DTO:** `InviteSupportAgentRequest(email, fullName)` — SAMO ta dva polja. Nema `role` ni `organizationId` polja na DTO-u uopšte.
- **Rola i org su hardkodovani server-side:** rola je uvijek `"support_agent"`, org je uvijek interni org sa fiksnim imenom `"Bilko Internal — Support"` (seedovan V135 migracijom). Caller ne može cilje ni jedan ni drugi preko request body-ja.
- **Zašto ne postojeći generički `POST /admin/invitations`:** taj endpoint uvijek koristi `organizationId = principal.organizationId` (org pozivaoca), što nikad nije interni support org — nema načina da se kroz njega ubaci support\_agent u interni org.
- **Aktivacija:** pozvana osoba se prijavljuje kroz ISTI, nepromijenjeni Entra JIT invite-accept put kao svaki drugi Bilko invite (`InviteService.acceptInviteInsideTransaction`, pozvano iz `AuthService.createSessionFromEntraIdToken`). Nema novog auth koda, nema service accounta. JWT nakon prijave nosi `role="support_agent"` kao bilo koja druga rola.

### 1a. Zatvorena rupa — #106071 generic-invite reject

Kad su `InviteService.VALID_ROLES` i `UserProvisioningService.VALID_ROLES` prošireni da uključe `support_agent` (da bi gornji dedicated endpoint mogao pozvati isti servisni sloj), TRI POSTOJEĆA generička route-a (gated samo sa `users:manage`, dakle bilo koji admin/owner SVOJE org-e) su tiho počela prihvatati `role="support_agent"` i preko sebe:

- `POST /admin/invitations` (InviteRoutes.kt)
- `POST /admin/users` i `PUT /users/:id/role` (UserManagementRoutes.kt)

**Fix:** nova funkcija `rejectSupportAgentRoleInGenericFlow(role)` u `RbacHelper.kt` — baca `ForbiddenException("SUPPORT_AGENT_ROLE_FORBIDDEN_HERE...")` na sva tri generička route-a PRIJE nego zahtjev stigne do servisnog sloja. VALID\_ROLES ostaju široki (jer dedicated endpoint legitimno zove istu `createInvite()` funkciju) — reject je na route sloju, ne na servisnom, upravo na ta tri mjesta koja je Parisa našla, ne paralelna allowlist koja bi mogla driftati.

## 2. Jackson vs kotlinx.serialization — razriješena neslaganja (Momjian vs Parisa)

**Zadatak je tražio da se PROČITA stvarni kod prije nego što se prepiše bilo čija tvrdnja.** Pročitano: `apps/api/src/main/kotlin/no/alai/bilko/plugins/Serialization.kt` i `SupportAgentProvisioningRoutes.kt` na branch-u `feat/106051-support-agent-rbac` (commit 5c06350a).

**Nalaz: Bruce Momjian je bio u pravu — mehanizam je Jackson's `FAIL_ON_UNKNOWN_PROPERTIES` (default `true`, nikad eksplicitno postavljen niti isključen u kodu), NE kotlinx.serialization `ignoreUnknownKeys`.**

Dokaz iz koda:

```
// Serialization.kt
fun Application.configureSerialization() {
    install(ContentNegotiation) {
        jackson {
            disable(SerializationFeature.INDENT_OUTPUT)
            registerModule(JavaTimeModule())
            disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
            findAndRegisterModules()
        }
    }
}

val bilkoJson = Json {
    ignoreUnknownKeys = true   // <- postoji, ali NIJE registrovan u ContentNegotiation
    ...
}
```

- `install(ContentNegotiation) { jackson { ... } }` — Ktor-ov HTTP content-negotiation sloj je konfigurisan SAMO sa Jackson-om. Nema `json(bilkoJson)` ni bilo koje druge kotlinx registracije u tom bloku.
- DTO-ovi kao `CreateInviteRequest` i `InviteSupportAgentRequest` nose `@Serializable` anotaciju (kotlinx marker), ali se stvarno deserijalizuju preko `call.receive<T>()`, što ide kroz ContentNegotiation — dakle kroz Jackson, ne kroz kotlinx, bez obzira na anotaciju na DTO-u.
- `bilkoJson` (kotlinx `Json` instanca sa `ignoreUnknownKeys = true`) postoji u istom fajlu, ali služi za internu JSON obradu (komentar u kodu: "used internally for JSON parsing") — NIJE registrovana kao content-negotiation mehanizam za HTTP request body.
- Jackson-ov `ObjectMapper` (kreiran preko Ktor-ovog `jackson {}` DSL-a) ima `FAIL_ON_UNKNOWN_PROPERTIES` default `true` — i nigdje u repou (grep potvrđen, nula pogodaka) se to eksplicitno ne isključuje.
- **Live test-dokaz** (ne samo statika koda): `SupportAgentProvisioningTest.kt`, test `T06 "request body cannot override role or organizationId — DTO has no such fields, extra keys are rejected outright"` — šalje JSON sa dodatnim `role`/`organizationId` poljima na `InviteSupportAgentRequest` DTO, unutar `testApplication {}` (stvarna Ktor test-aplikacija, ne mock), i tvrdi `HttpStatusCode.BadRequest`. Test-ov vlastiti komentar kaže eksplicitno: *"This codebase's Jackson ObjectMapper (Serialization.kt) does NOT disable FAIL\_ON\_UNKNOWN\_PROPERTIES, so an unrecognized JSON key is rejected outright with 400."* Pozitivna kontrola u istom testu potvrđuje da isti email SA samo ispravnim poljima uspijeva (201) — dokazuje da je 400 specifično zbog nepoznatih ključeva, ne neke druge greške.

**Zaključak za tim:** kad god vidite `@Serializable` na DTO-u u ovom repou, to NE garantuje da će kotlinx pravila (npr. `ignoreUnknownKeys`) važiti na HTTP granici — treba provjeriti da li je taj DTO stvarno deserijalizovan preko ContentNegotiation-a (Jackson, ovaj repo) ili preko direktnog poziva na `bilkoJson.decodeFromString<T>()` (kotlinx, druga upotreba u istom fajlu).

## 3. Šta support agent VIDI

- SVI support tiketi, cross-org — preko `app.is_support_agent` GUC-a postavljenog samo na support ruta-ma, nakon što je `requirePermission(principal, "support_ticket:read")`/`"support_ticket:reply"` već prošao.
- Reply/triage polja na tiketu preko ograničenog UPDATE-a: `status`, `resolution_note`, `triage_json`, `external_ref`, `updated_at` — kolonski GRANT (V134 Part D), ne cijeli red.

## 4. Šta support agent NE VIDI

- **Finance/payroll/accounting/settings** — nula permisija dodijeljeno support\_agent roli van `support_ticket:read`/`support_ticket:reply` (V133 Part C/D, provjereno grep-om — nema trećeg reda u `role_permissions` za tu rolu).
- **RLS-nivo dokaz (Faza A evidence, V134MigrationTest test 5):** sa `app.is_support_agent=true` postavljenim, pokušaj pristupa `organizations`, `invoices`, `expenses`, `employees`, `payslips`, `financial_audit_log` — svih šest odbijeno. Dva različita, oba tačna načina odbijanja nađena: "permission denied" (organizations/invoices/expenses — nula GRANT za bilko\_app na tim tabelama uopšte, prethodno postojeći, nepovezan gap) i "0 redova" (employees/payslips/financial\_audit\_log — GRANT postoji preko V101/V124, ali nijedna politika na tim tabelama ne konsultuje `is_support_agent`, pa RLS ispravno filtrira sve).
- **Ostali ticket route-ovi:** support\_agent NE može pristupiti `GET /admin/support/tickets/{id}` ni `PATCH` preko svoje role — ti route-ovi ostaju gated sa `requireSupportTriageService()` (odvojen guard od `support_agent` role-flow-a, nepromijenjen ovim MVP-om).

## 5. RBAC permission-key referenca

<table id="bkmrk-permission-keyzna%C4%8Den"><thead><tr><th>Permission key</th><th>Značenje</th><th>Format</th></tr></thead><tbody><tr><td>`support_ticket:read`</td><td>Čitanje/listanje tiketa preko svih organizacija</td><td>colon-format (resource:verb)</td></tr><tr><td>`support_ticket:reply`</td><td>Odgovor / triage tiketa (status + resolution\_note) preko svih organizacija</td><td>colon-format (resource:verb)</td></tr></tbody></table>

**Zašto colon, ne dot:** dispatch je originalno tražio `support.tickets.read`/`support.tickets.reply` (dot-notation). V67-ova `permission_key_format` CHECK constraint (`key ~ '^[a-z_]+:[a-z_]+$'`) zahtijeva tačno jednu dvotačku, bez tačaka — svaki postojeći ključ u katalogu (npr. `invoice:read`, `expense:create`) već poštuje taj format. Umjesto proširenja constraint-a za jednu feature-preferencu imena, korišteno je `support_ticket:read`/`support_ticket:reply` (singular resource, isti obrazac).

## 6. RLS cross-org izuzetak — zašto i kako ograničen

**Zašto postoji izuzetak uopšte:** support osoblje MORA vidjeti tikete preko svih organizacija da bi triage funkcionisao — jedan support agent opslužuje sve klijente, ne samo jednu org. Standardni Bilko RLS model (org\_id-scoped) bi u tom slučaju blokirao support agenta da vidi ijedan tiket van svoje (interne) org-e.

**Kako je ograničen (ključna crvena-zona odluka, Momjian, MC #106051):**

- **Nova, uska GUC: `app.is_support_agent`** — NIJE ponovna upotreba postojeće `app.is_platform_admin` GUC-e. `support_tickets` je već imao `support_tickets_admin_all` politiku (V73) gated na `app.is_platform_admin` — ISTU GUC koju konsultuju i `purge_worker_log`, `document_retention_manifest`, `financial_audit_log`. Da je support\_agent-ov cross-org pristup implementiran postavljanjem `app.is_platform_admin`, ista transakcija bi automatski zadovoljila i sve DRUGE tabele gated na tu GUC — tiho dodjeljujući support agentu pristup retention manifestima, purge logovima, finansijskim audit trailovima. Točno ono što ovaj task postoji da SPRIJEČI.
- **Sealed-class ekskluzivnost:** nova GUC se konsultuje SAMO od strane dvije nove politike na `support_tickets` (`support_tickets_support_agent_select`, `support_tickets_support_agent_reply`) — nikad od bilo koje druge tabele/politike u ovom repou. Verifikovano live testom koji grep-uje `pg_policies` katalog za string `"is_support_agent"` i tvrdi nula pogodaka van `support_tickets` (V134MigrationTest, "6 - GUC ISOLATION").
- **SAMO support\_tickets + support\_ticket\_messages** — nikad finance. RLS izuzetak pokriva tačno dvije tabele iz ovog MVP-a; nijedna finance/payroll/accounting tabela nije dirana.
- **Ko postavlja GUC:** route sloj (`SupportTicketRoutes.kt`, B2), unutar `transaction {}` bloka, SAMO NAKON što je `requireSupportAgent()` već vratio validan principal (dakle samo za zahtjev koji je nezavisno dokazao `support_ticket:read`/`reply` permisiju) — GUC nikad sama nije autorizaciona odluka, samo downstream RLS mehanizam.
- **Provjera curenja kroz connection pool (top adversarial pitanje, B faza):** `SupportAgentGucPoolTest.kt` — HikariCP pool size 1 (forsira reuse konekcije) + `isAutoCommit = false` (isti kao produkcijska konfiguracija). Test 2: odmah nakon agent zahtjeva na ISTOJ pooled konekciji, sljedeća customer-scoped transakcija NE nasljeđuje cross-org vidljivost, i direktna `current_setting('app.is_support_agent', true)` provjera na početku te transakcije čita NULL/prazno. Test 4: `app.is_platform_admin` se nikad ne postavlja na support\_agent putu — nema unakrsne kontaminacije GUC-ova.
- **Kolonski GRANT, ne FOR ALL:** support\_agent-ov UPDATE je ograničen na tačno kolone koje postojeći PATCH triage flow piše (status, resolution\_note, triage\_json, external\_ref, updated\_at) — preko GRANT UPDATE (kolona-lista), isti mehanizam koji V125/V132 već koriste za `bilko_purge_worker`. Postgres nema kolonski-scoped RLS, pa RLS odlučuje KOJI REDOVI, GRANT odlučuje KOJE KOLONE.

## 7. Otvoreni tech-debt

- **\#106070** — (prati se odvojeno, van scope-a ovog MVP-a; provjeriti `mc.js show 106070` za trenutni status prije referenciranja detalja).
- **\#106072** — (isto, van scope-a; provjeriti `mc.js show 106072`).
- **\#106075** — (isto, van scope-a; provjeriti `mc.js show 106075`).
- **Poznat, označen (ne skriven) gap iz B faze:** `support_tickets_customer_select` (V73) cast-uje `app.current_org_id` direktno na `::uuid` bez `NULLIF` guard-a, baca sirovi exception umjesto čistog deny kad je ta GUC zaista nepostavljena. Zaobiđeno u testu (postavlja throwaway validan UUID na agent putu, koji tu GUC nikad stvarno ne treba), NE popravljeno — van scope-a ovog taska, već praćeno.
- **Pre-existing GRANT gap na support\_tickets (nađen i ispravljen u V134, ne bio prije praćen kao poseban ticket):** tabela nije imala eksplicitan GRANT SELECT/INSERT za `bilko_app` uopšte — nevidljivo u produkciji jer Flyway/API oboje konektuju kao `bilko_admin` (BYPASSRLS). V134 dodaje nedostajući GRANT, što čini i STARE V73 politike (customer/admin) stvarno dostupnim za `bilko_app` ulogu, ne samo nove.

## 8. Merge status (na dan pisanja ove stranice)

Branch `feat/106051-support-agent-rbac` (azdo), tip 5c06350a. NIJE merge-ovan u `azdo/main`. Faza A (Parisa adversarial verify) i Faza B (GUC-execution adversarial pass) oboje još čekaju Parisa Tabriz-ov nezavisni pregled prije merge-a — to je eksplicitni merge-gate koji su i Momjian i CodeCraft ostavili otvorenim u svojim verdiktima (vidi `~/system/evidence/106051/verdict.md` i `~/system/evidence/106055/verdict.md`).