# Backend

API reference, database, authentication, services, middleware

# API & Data

# API Reference

# API Reference

> **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. API Overview & Conventions

<!-- GUIDANCE: Define the API style, versioning strategy, and key design principles. -->

**API style:** `{{RESTful HTTP/JSON | GraphQL | gRPC}}`
**API version strategy:** `{{URL versioning: /v1/ | Header versioning}}`
**OpenAPI spec:** `{{https://api.domain.com/docs/openapi.json}}`
**Interactive docs:** `{{https://api.domain.com/docs}}`

**Design conventions:**
- Resources named as plural nouns: `/users`, `/orders`, `/products`
- HTTP methods map to CRUD: GET (read), POST (create), PUT (replace), PATCH (update), DELETE (remove)
- Response format: always JSON
- Timestamps: ISO 8601 UTC (`2024-01-15T10:30:00Z`)
- IDs: UUID v4 strings
- Booleans: `true` / `false` (never `1` / `0`)
- Empty collections: `[]` (never `null`)
- Missing optional fields: omitted (never `null` unless semantically null)

---

## 2. Base URLs

<!-- GUIDANCE: List base URLs for every deployment environment. -->

| Environment | Base URL |
|-------------|----------|
| Development | `http://localhost:4000/v1` |
| Staging | `https://api-staging.{{domain.com}}/v1` |
| Production | `https://api.{{domain.com}}/v1` |

---

## 3. Authentication

<!-- GUIDANCE: Describe every supported authentication method. Most APIs use Bearer JWT. -->

**Method:** Bearer Token (JWT)

**Obtain tokens:** `POST /auth/login` (see Auth section below)

**Include in requests:**
```
Authorization: Bearer <access_token>
```

**Token lifetimes:**
- Access token: 15 minutes
- Refresh token: 30 days (rotate on use)

**Refresh tokens:** `POST /auth/refresh` with `{ "refreshToken": "..." }` in body.

**API Key authentication** (machine-to-machine):
```
X-API-Key: <api_key>
```
API keys are scoped and managed at `{{https://dashboard.domain.com/api-keys}}`.

---

## 4. Common Request/Response Headers

### 4.1 Request Headers

| Header | Required | Description |
|--------|----------|-------------|
| `Authorization` | Yes (auth routes) | `Bearer <token>` |
| `Content-Type` | Yes (POST/PUT/PATCH) | `application/json` |
| `Accept` | No | `application/json` (default) |
| `X-Request-ID` | No | Client-provided idempotency ID |
| `Accept-Language` | No | `en`, `nb`, etc. — affects response locale |

### 4.2 Response Headers

| Header | Description |
|--------|-------------|
| `Content-Type` | `application/json; charset=utf-8` |
| `X-Request-ID` | Echo of client request ID (or server-generated) |
| `X-RateLimit-Limit` | Total requests allowed in window |
| `X-RateLimit-Remaining` | Remaining requests in current window |
| `X-RateLimit-Reset` | Unix timestamp when window resets |
| `Retry-After` | Seconds to wait (set when 429 returned) |

---

## 5. Error Response Format

<!-- GUIDANCE: Define the standard error envelope. Every error must follow this format. -->

```json
{
  "error": {
    "code": "VALIDATION_ERROR",
    "message": "Request validation failed",
    "details": [
      {
        "field": "email",
        "message": "Invalid email format",
        "code": "INVALID_FORMAT"
      }
    ],
    "requestId": "req_7f3a2b1c",
    "timestamp": "2024-01-15T10:30:00Z"
  }
}
```

**Standard error codes:**

| HTTP Status | Error Code | Meaning |
|------------|-----------|---------|
| 400 | `VALIDATION_ERROR` | Request body / params failed validation |
| 400 | `BAD_REQUEST` | Malformed request |
| 401 | `UNAUTHORIZED` | Missing or invalid authentication |
| 401 | `TOKEN_EXPIRED` | JWT has expired — refresh required |
| 403 | `FORBIDDEN` | Authenticated but lacks permission |
| 404 | `NOT_FOUND` | Resource does not exist |
| 409 | `CONFLICT` | Resource already exists / version conflict |
| 422 | `UNPROCESSABLE` | Valid format but business rule violation |
| 429 | `RATE_LIMITED` | Too many requests |
| 500 | `INTERNAL_ERROR` | Unexpected server error |
| 503 | `SERVICE_UNAVAILABLE` | Temporary downtime |

---

## 6. Resources

<!-- GUIDANCE: Add one section per resource. Copy the template block below for each new resource. -->

---

### 6.1 Authentication

#### POST /auth/login

Authenticate user and receive token pair.

**Auth required:** No

**Request body:**
```json
{
  "email": "user@example.com",
  "password": "{{password}}"
}
```

**Response `200 OK`:**
```json
{
  "accessToken": "eyJhbGciOiJIUzI1NiJ9...",
  "refreshToken": "dGhpcyBpcyBhIHJlZnJlc2g...",
  "expiresIn": 900,
  "user": {
    "id": "usr_01HX7...",
    "email": "user@example.com",
    "name": "John Doe",
    "role": "user"
  }
}
```

**Error responses:**
| Status | Code | Condition |
|--------|------|-----------|
| 401 | `INVALID_CREDENTIALS` | Wrong email or password |
| 429 | `RATE_LIMITED` | > 5 failed attempts in 15 min |

---

#### POST /auth/refresh

Rotate access and refresh tokens.

**Auth required:** No

**Request body:**
```json
{ "refreshToken": "dGhpcyBpcyBhIHJlZnJlc2g..." }
```

**Response `200 OK`:** Same as login response.

---

#### POST /auth/logout

Revoke refresh token.

**Auth required:** Yes (Bearer)

**Request body:**
```json
{ "refreshToken": "dGhpcyBpcyBhIHJlZnJlc2g..." }
```

**Response `204 No Content`**

---

### 6.2 Users

**Endpoints:**

| Method | Path | Description | Auth |
|--------|------|-------------|------|
| `GET` | `/users` | List users (paginated) | Admin |
| `GET` | `/users/:id` | Get user by ID | Self or Admin |
| `POST` | `/users` | Create user | Admin |
| `PATCH` | `/users/:id` | Update user fields | Self or Admin |
| `DELETE` | `/users/:id` | Delete user (soft) | Admin |
| `GET` | `/users/me` | Get current user | Authenticated |
| `PATCH` | `/users/me` | Update current user | Authenticated |

---

#### GET /users

List users with pagination and filtering.

**Auth required:** Admin

**Query parameters:**

| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `page` | integer | `1` | Page number |
| `pageSize` | integer | `25` | Items per page (max: 100) |
| `search` | string | — | Search name or email (min 2 chars) |
| `role` | string | — | Filter by role: `admin`, `user`, `viewer` |
| `status` | string | `active` | Filter by status: `active`, `inactive`, `all` |
| `sort` | string | `createdAt` | Sort field |
| `dir` | string | `desc` | Sort direction: `asc`, `desc` |

**Response `200 OK`:**
```json
{
  "data": [
    {
      "id": "usr_01HX7...",
      "email": "user@example.com",
      "name": "Jane Doe",
      "role": "user",
      "status": "active",
      "createdAt": "2024-01-15T10:30:00Z",
      "updatedAt": "2024-01-15T10:30:00Z"
    }
  ],
  "pagination": {
    "page": 1,
    "pageSize": 25,
    "total": 142,
    "totalPages": 6
  }
}
```

---

#### GET /users/:id

**Auth required:** Self or Admin

**Path parameters:**
| Parameter | Type | Description |
|-----------|------|-------------|
| `id` | UUID | User ID |

**Response `200 OK`:**
```json
{
  "id": "usr_01HX7...",
  "email": "user@example.com",
  "name": "Jane Doe",
  "role": "user",
  "status": "active",
  "profile": {
    "avatarUrl": "https://cdn.domain.com/avatars/...",
    "bio": "Software developer"
  },
  "createdAt": "2024-01-15T10:30:00Z",
  "updatedAt": "2024-01-15T10:30:00Z"
}
```

**Error responses:**
| Status | Code | Condition |
|--------|------|-----------|
| 404 | `NOT_FOUND` | User does not exist |
| 403 | `FORBIDDEN` | Non-admin accessing another user |

---

#### POST /users

**Auth required:** Admin

**Request body:**
```json
{
  "email": "newuser@example.com",
  "name": "New User",
  "role": "user",
  "password": "{{password}}"
}
```

**Response `201 Created`:** Full user object (same as GET /users/:id)

---

#### PATCH /users/:id

**Auth required:** Self or Admin

**Request body** (all fields optional):
```json
{
  "name": "Updated Name",
  "profile": {
    "bio": "Updated bio"
  }
}
```

**Response `200 OK`:** Updated user object.

---

#### DELETE /users/:id

Soft-deletes user (sets `deletedAt`, anonymizes PII).

**Auth required:** Admin

**Response `204 No Content`**

---

### 6.3 {{RESOURCE_NAME}}

<!-- GUIDANCE: Copy and complete this section for each additional resource. -->

**Endpoints:**

| Method | Path | Description | Auth |
|--------|------|-------------|------|
| `GET` | `/{{resource}}` | List `{{resource}}` | `{{Auth level}}` |
| `GET` | `/{{resource}}/:id` | Get by ID | `{{Auth level}}` |
| `POST` | `/{{resource}}` | Create | `{{Auth level}}` |
| `PATCH` | `/{{resource}}/:id` | Update | `{{Auth level}}` |
| `DELETE` | `/{{resource}}/:id` | Delete | `{{Auth level}}` |

**TODO:** Document all endpoints for this resource following the pattern above.

---

## 7. Pagination Format

All list endpoints return the same pagination envelope:

```json
{
  "data": [...],
  "pagination": {
    "page": 1,
    "pageSize": 25,
    "total": 142,
    "totalPages": 6,
    "hasNextPage": true,
    "hasPreviousPage": false
  }
}
```

**Cursor pagination** (high-performance, for infinite scroll):
```
GET /feed?cursor=eyJpZCI6MTIzfQ&pageSize=20
```
Response includes `nextCursor` — pass as `cursor` in next request.

---

## 8. Filtering & Sorting Conventions

**Filter parameters:**
```
GET /orders?status=pending&createdAt[gte]=2024-01-01&total[lte]=1000
```

| Operator | Suffix | Example |
|----------|--------|---------|
| Equals | (none) | `?status=active` |
| Greater than | `[gt]` | `?price[gt]=100` |
| Greater than or equal | `[gte]` | `?price[gte]=100` |
| Less than | `[lt]` | `?price[lt]=500` |
| Less than or equal | `[lte]` | `?price[lte]=500` |
| In list | `[in]` | `?status[in]=active,pending` |
| Not in list | `[nin]` | `?status[nin]=deleted` |

**Sort:** `?sort=createdAt&dir=desc` (default: `createdAt desc`)

---

## 9. Webhooks Documentation

<!-- GUIDANCE: Define the webhook event format and delivery guarantee. -->

**Webhook endpoint:** Configured per-account at `{{https://dashboard.domain.com/webhooks}}`

**Delivery:** HTTP POST with JSON body, signed with HMAC-SHA256.

**Signature verification:**
```ts
const signature = req.headers['x-webhook-signature'];
const computed = crypto
  .createHmac('sha256', webhookSecret)
  .update(rawBody)
  .digest('hex');
const valid = crypto.timingSafeEqual(
  Buffer.from(signature), Buffer.from(computed)
);
```

**Event envelope:**
```json
{
  "id": "evt_01HX7...",
  "type": "user.created",
  "data": { /* resource object */ },
  "timestamp": "2024-01-15T10:30:00Z",
  "version": "1"
}
```

**Available events:**

| Event | Trigger |
|-------|---------|
| `user.created` | New user registered |
| `user.updated` | User profile changed |
| `user.deleted` | User account deleted |
| `{{resource}}.{{action}}` | `{{Description}}` |

**Retry policy:** 5 retries with exponential backoff. Undeliverable after 24 hours → marked as failed.

---

## 10. Rate Limiting

| Endpoint Group | Limit | Window |
|---------------|-------|--------|
| Public endpoints | 100 req | 1 minute |
| Authenticated endpoints | 1000 req | 1 minute |
| Admin endpoints | 5000 req | 1 minute |
| Auth endpoints (login) | 5 req | 15 minutes |
| Webhook delivery | N/A | — |

**Response when rate limited (`429 Too Many Requests`):**
```json
{
  "error": {
    "code": "RATE_LIMITED",
    "message": "Too many requests. Please retry after 60 seconds.",
    "retryAfter": 60
  }
}
```

---

## 11. Code Examples

### cURL

```bash
# Login
curl -X POST https://api.domain.com/v1/auth/login \
  -H "Content-Type: application/json" \
  -d '{"email": "user@example.com", "password": "secret"}'

# Get users (with token)
curl -X GET "https://api.domain.com/v1/users?page=1&pageSize=10" \
  -H "Authorization: Bearer <access_token>"
```

### JavaScript (fetch)

```ts
const response = await fetch('https://api.domain.com/v1/users', {
  headers: {
    'Authorization': `Bearer ${accessToken}`,
    'Content-Type': 'application/json',
  },
});

if (!response.ok) {
  const error = await response.json();
  throw new Error(error.error.message);
}

const { data, pagination } = await response.json();
```

### Python

```python
import httpx

client = httpx.Client(
    base_url="https://api.domain.com/v1",
    headers={"Authorization": f"Bearer {access_token}"}
)

response = client.get("/users", params={"page": 1, "pageSize": 10})
response.raise_for_status()
result = response.json()
```

---

## 12. SDK Availability

| Language | Package | Repository | Status |
|----------|---------|------------|--------|
| TypeScript / JavaScript | `@{{company}}/api-client` | `{{URL}}` | `{{Available/Planned}}` |
| Python | `{{company}}-python` | `{{URL}}` | `{{Available/Planned}}` |
| Go | `{{company}}-go` | `{{URL}}` | `{{Planned}}` |

---

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

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

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

# Bilko Database Schema

> **Status:** IMPLEMENTED
> **Last verified:** 2026-05-20
> **Canonical backend:** `apps/api` Kotlin/Ktor service
> **Database:** PostgreSQL on GCP Cloud SQL for deployed environments
> **Schema source of truth:** Flyway SQL migrations + Exposed table mappings

This document describes the database schema currently used by the Bilko Kotlin/Ktor API.
It replaces the older ORM-era database notes and must be kept aligned with:

- Flyway migrations: `apps/api/src/main/resources/db/migration/`
- Exposed table mappings: `apps/api/src/main/kotlin/no/alai/bilko/models/Tables.kt`
- Route/service behaviour: `apps/api/src/main/kotlin/no/alai/bilko/routes/` and `apps/api/src/main/kotlin/no/alai/bilko/services/`
- Environment mapping: `infrastructure/gcp/ENV-MATRIX.md`

Do not treat generated diagrams, frontend type definitions, or archived deployment notes as database authority.

---

## 1. Architecture Overview

Bilko is a multi-tenant accounting SaaS. The active backend stores tenant data in PostgreSQL and scopes business tables by `organization_id` where appropriate.

Runtime stack:

- **API:** Kotlin/Ktor
- **SQL migration engine:** Flyway
- **Kotlin SQL mapping:** JetBrains Exposed
- **Database engine:** PostgreSQL
- **Deployed DB platform:** GCP Cloud SQL
- **Primary migration command path:** Cloud Build / backend Gradle Flyway tasks

The schema is forward-only. Applied migrations must not be edited after deployment. If a deployed environment has Flyway metadata drift, repair is handled as a controlled operations procedure with target identity checks, schema checks, transcript, and postflight validation.

### Relationship overview

```mermaid
erDiagram
  organizations ||--o{ users : owns
  organizations ||--o{ accounts : owns
  organizations ||--o{ contacts : owns
  organizations ||--o{ invoices : owns
  organizations ||--o{ expenses : owns
  organizations ||--o{ transactions : owns
  organizations ||--o{ bank_accounts : owns
  organizations ||--o{ recurring_invoices : owns
  organizations ||--o{ adapter_config : configures
  organizations ||--o{ stripe_webhook_events : receives

  users ||--o{ refresh_tokens : has
  users ||--o{ invoices : creates
  users ||--o{ expenses : creates
  users ||--o{ transactions : creates
  users ||--o{ logged_actions : actor

  account_types ||--o{ accounts : classifies
  accounts ||--o{ accounts : parent
  accounts ||--o{ invoice_items : revenue_account
  accounts ||--o{ expenses : expense_account
  accounts ||--o{ bank_accounts : ledger_account
  accounts ||--o{ transactions : debit_account
  accounts ||--o{ transactions : credit_account

  contacts ||--o{ invoices : customer
  contacts ||--o{ expenses : vendor
  contacts ||--o{ recurring_invoices : template_customer

  invoices ||--o{ invoice_items : contains
  bank_accounts ||--o{ bank_transactions : imports
  transactions ||--o{ bank_transactions : reconciles
  currencies ||--o{ exchange_rates : base_currency
  currencies ||--o{ exchange_rates : target_currency
```

---

## 2. Source-of-Truth Rules

1. **Add schema changes via new Flyway migrations only.**
   - Use the next available version under `apps/api/src/main/resources/db/migration/`.
   - Never rewrite an already-applied migration in demo, staging, or production.

2. **Update Exposed mappings in the same change.**
   - `Tables.kt` should match the migrated database surface used by routes/services.

3. **Update API and docs together.**
   - If a schema change alters request/response shapes, update `docs/backend/openapi.yaml` and relevant backend docs.

4. **Validate with Flyway before deploy promotion.**
   - A valid deploy target must pass Flyway validation before the API is considered healthy.

5. **Keep environment identity explicit.**
   - Staging, demo, and production databases are separate targets. Migration or repair work must state which database is being touched.

---

## 3. Migration Inventory

As of this verification pass, the repository contains **36 Flyway migration files**. The deployed stage repair for MC #101509 validated the current Flyway version as **35** after applying pending migrations.

Important migration groups:

| Version range | Purpose                                                                                                                                               |
| ------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- |
| V1-V6         | Initial accounting schema, compatibility columns, encrypted identifiers, supplementary tables, organization logo, role storage normalization          |
| V7-V15        | Plan tiers, Stripe webhook logging, compliance calendar, recurring invoices, audit enum cleanup, demo/CI seed data, trial fields                      |
| V16-V24       | Country constraints, RLS permissive baseline, invoice/expense status enum support, demo org fixes, CI viewer, BA jurisdiction split, BA entity charts |
| V25-V29       | Adapter configuration, platform-admin marker, Serbia chart, logged action width, UAT demo users                                                       |
| V30-V35       | RLS/session auth hardening, security-definer auth helpers, demo admin grants, bcrypt-prefix normalization, UAT password hash reset                    |

Operational note from MC #101509:

- Staging checksum drift was detected for V22, V25, V26, and V28.
- Schema checks proved checksum-only drift before repair.
- Controlled Flyway repair + migrate brought staging to version 35 and `flyway validate` passed.
- The authoritative stage Cloud Build trigger then succeeded.

---

## 4. Tenant and Security Model

Most business data is scoped by `organization_id` and accessed through authenticated Ktor routes. The schema includes:

- Organization-level tenant boundary.
- User roles per organization.
- Platform-admin marker for controlled platform operations.
- Refresh-token session storage.
- Logged action/audit table.
- Row-level-security related migration work in the V17 and V30+ series.

Application code must set the correct organization/user context before querying tenant-scoped tables. Any new table containing tenant data should include `organization_id` unless it is intentionally global reference data.

---

## 5. Tables

The following table inventory is derived from `Tables.kt` and active migrations.

### Column notation

The detailed type/constraint authority remains Flyway SQL plus `Tables.kt`. This document uses these shorthand types for quick review:

| Notation              | Meaning                                          |
| --------------------- | ------------------------------------------------ |
| `uuid pk`             | UUID primary key                                 |
| `uuid fk`             | UUID foreign key                                 |
| `text` / `varchar(n)` | String column; exact length is migration-defined |
| `numeric`             | Decimal money/rate value                         |
| `date` / `timestamp`  | Date/time column                                 |
| `json/jsonb`          | Structured JSON column                           |
| `bool`                | Boolean                                          |
| `soft-delete`         | Nullable `deleted_at` lifecycle column           |

Common lifecycle columns are `created_at`, `updated_at`, `version`, and `deleted_at` where present.

### 5.1 `organizations`

Tenant root table.

Key fields:

- `id`
- `name`
- `registration_number`
- `vat_number`, `vat_country`, `vat_registered`, `vat_rate`
- `firm_type`
- `base_currency`
- `country`, `language`
- `fiscal_year_start`
- `logo_url`
- `security_settings`
- subscription/trial fields: `plan_tier`, `quota_invoices_month`, `quota_contacts`, `quota_users`, `stripe_customer_id`, `stripe_subscription_id`, `trial_started_at`, `trial_ends_at`
- lifecycle fields: `created_at`, `updated_at`, `version`, `deleted_at`

Column summary:

| Column group       | Type/constraint summary                                                                   |
| ------------------ | ----------------------------------------------------------------------------------------- |
| Identity           | `id uuid pk`, `name text`                                                                 |
| Registration/tax   | registration and VAT columns are nullable text values; VAT registration is boolean-backed |
| Locale/market      | `country`, `language`, base currency, fiscal year start                                   |
| Branding/settings  | `logo_url`, `security_settings json/jsonb`                                                |
| Subscription/trial | plan, quota, Stripe IDs, trial timestamps                                                 |
| Lifecycle          | timestamps, `version`, `deleted_at soft-delete`                                           |

Notes:

- `country` is constrained by migration history and used by market-specific tax/e-invoice logic.
- BA jurisdiction support was added in the V22/V23/V24 migration set.

### 5.2 `users`

Authenticated users belonging to organizations.

Key fields:

- `id`
- `organization_id`
- `email`
- `password_hash`
- `full_name`
- `role`
- `two_factor_enabled`, `two_factor_secret`, `two_factor_backup_codes`
- `notification_preferences`
- `last_login_at`
- invite fields: `invite_token`, `invite_expires_at`
- `status`
- `is_platform_admin`
- lifecycle fields: `created_at`, `updated_at`, `version`, `deleted_at`

Column summary:

| Column group             | Type/constraint summary                              |
| ------------------------ | ---------------------------------------------------- |
| Identity                 | `id uuid pk`, `organization_id uuid fk`              |
| Login                    | `email text unique`, `password_hash text`            |
| RBAC                     | `role text`, `status text`, `is_platform_admin bool` |
| 2FA                      | boolean flag, secret, backup-code storage            |
| Invites/session metadata | invite token/expiry, last login timestamp            |
| Lifecycle                | timestamps, `version`, `deleted_at soft-delete`      |

Notes:

- Password and 2FA behaviour is implemented in the Kotlin auth services.
- `is_platform_admin` was added by V26.

### 5.3 `refresh_tokens`

Session refresh-token storage.

Key fields:

- `id`
- `user_id`
- `jti`
- `expires_at`
- `created_at`
- `version`

Notes:

- Used by auth-lifecycle and logout/revocation flows.
- Session listing/revocation routes are documented in OpenAPI.

### 5.4 `account_types`

Reference data for account classifications.

Key fields:

- `id`
- `name`
- `normal_balance`
- `created_at`
- `version`

### 5.5 `accounts`

Chart of accounts entries.

Key fields:

- `id`
- `organization_id`
- `code`
- `name`
- `account_type_id`
- `currency_code`
- `parent_account_id`
- `is_active`
- lifecycle fields: `created_at`, `updated_at`, `version`, `deleted_at`

Notes:

- Market-specific chart additions exist for BA and RS.
- Account hierarchy is represented with `parent_account_id`.

### 5.6 `contacts`

Customers and vendors.

Key fields:

- `id`
- `organization_id`
- `type`
- `name`
- `email`, `phone`
- registration and tax identifiers: `registration_number`, `vat_number`, `jmbg`, `jmbg_hash`, `oib`, `oib_hash`
- address fields: `address_line1`, `address_line2`, `city`, `postal_code`, `country`
- `currency_code`
- `payment_terms`
- `notes`
- `is_active`
- lifecycle fields: `created_at`, `updated_at`, `version`, `deleted_at`

Notes:

- Sensitive personal/business identifiers are handled through the Kotlin service layer and migration-provided columns.

### 5.7 `invoices`

Sales invoices and e-invoice tracking.

Key fields:

- `id`
- `organization_id`
- `customer_id`
- `invoice_number`
- dates: `invoice_date`, `due_date`, `sent_at`, `viewed_at`, `paid_at`
- money fields: `currency_code`, `exchange_rate`, `subtotal`, `tax_amount`, `discount_amount`, `total_amount`, `base_amount`
- `status`
- `notes`, `terms`, `pdf_url`
- e-invoice fields: `is_reverse_charge`, `sef_id`, `sef_document_id`, `sef_status`, `sef_submitted_at`, `sef_accepted_at`
- `created_by`
- lifecycle fields: `created_at`, `updated_at`, `version`, `deleted_at`

Column summary:

| Column group        | Type/constraint summary                                             |
| ------------------- | ------------------------------------------------------------------- |
| Identity/scope      | `id uuid pk`, `organization_id uuid fk`, `customer_id uuid fk`      |
| Numbering/dates     | invoice number, invoice/due dates, send/view/pay timestamps         |
| Amounts             | subtotal, tax, discount, total, base amount as numeric money values |
| Status              | status text/enum-backed by migration history                        |
| E-invoice           | reverse-charge flag and SEF IDs/status/timestamps                   |
| Ownership/lifecycle | creator user, timestamps, `version`, `deleted_at soft-delete`       |

Notes:

- Invoice status enum support was added by V18.
- Serbia SEF integration fields are present on the invoice table.

### 5.8 `invoice_items`

Line items for invoices.

Key fields:

- `id`
- `invoice_id`
- `line_number`
- `description`
- `quantity`
- `unit_price`
- `tax_rate`
- `vat_exempt`
- `line_total`
- `account_id`
- `created_at`
- `version`
- `deleted_at`

### 5.9 `recurring_invoices`

Recurring invoice templates/schedules.

Key fields:

- `id`
- `organization_id`
- `contact_id`
- `frequency`
- `next_issue_date`
- `day_of_month`
- `currency_code`
- `notes`
- `is_active`
- `template_data`
- `created_at`, `updated_at`

### 5.10 `expenses`

Purchase/expense records.

Key fields:

- `id`
- `organization_id`
- `vendor_id`
- `expense_number`
- `expense_date`
- money fields: `currency_code`, `exchange_rate`, `amount`, `base_amount`, `tax_amount`
- `category`
- `payment_method`
- `account_id`
- `description`
- `receipt_url`
- `status`
- approval/payment fields: `approved_by`, `approved_at`, `paid_at`
- `created_by`
- lifecycle fields: `created_at`, `updated_at`, `version`, `deleted_at`

Column summary:

| Column group       | Type/constraint summary                                               |
| ------------------ | --------------------------------------------------------------------- |
| Identity/scope     | `id uuid pk`, `organization_id uuid fk`, `vendor_id uuid fk`          |
| Numbering/date     | expense number and expense date                                       |
| Amounts            | amount, base amount, tax amount, currency, exchange rate              |
| Classification     | category, payment method, expense ledger account                      |
| Approval/payment   | status, approver, approved timestamp, paid timestamp                  |
| Evidence/lifecycle | receipt URL, creator, timestamps, `version`, `deleted_at soft-delete` |

Notes:

- Expense status enum support was added by V19.

### 5.11 `transactions`

General ledger transactions.

Key fields:

- `id`
- `organization_id`
- `transaction_date`
- `description`
- `debit_account_id`
- `credit_account_id`
- money fields: `amount`, `currency_code`, `exchange_rate`, `base_amount`
- source reference: `reference_type`, `reference_id`
- lock/reconciliation fields: `locked`, `locked_at`, `reconciled`, `reconciled_at`
- `notes`
- `created_by`
- `created_at`
- `version`
- `deleted_at`

Column summary:

| Column group      | Type/constraint summary                                               |
| ----------------- | --------------------------------------------------------------------- |
| Identity/scope    | `id uuid pk`, `organization_id uuid fk`                               |
| Double-entry legs | debit account FK, credit account FK                                   |
| Amounts           | amount, base amount, currency, exchange rate                          |
| Source reference  | reference type/id links back to invoices, expenses, or manual entries |
| Controls          | lock and reconciliation flags/timestamps                              |
| Lifecycle         | creator, `created_at`, `version`, `deleted_at soft-delete`            |

### 5.12 `bank_accounts`

Bank accounts linked to ledger accounts.

Key fields:

- `id`
- `organization_id`
- `account_id`
- `bank_name`
- `account_number`
- `iban`
- `currency_code`
- `current_balance`
- `is_active`
- lifecycle fields: `created_at`, `updated_at`, `version`, `deleted_at`

### 5.13 `bank_transactions`

Imported or entered bank movements.

Key fields:

- `id`
- `bank_account_id`
- `transaction_date`
- `amount`
- `description`
- `reference`
- `reconciled`
- `matched_transaction_id`
- `created_at`
- `version`
- `deleted_at`

### 5.14 `currencies`

Currency reference data.

Key fields:

- `code`
- `name`
- `symbol`
- `decimal_places`
- `is_active`
- `created_at`
- `version`

### 5.15 `exchange_rates`

Foreign-exchange rates.

Key fields:

- `id`
- `base_currency`
- `target_currency`
- `rate`
- `effective_date`
- `source`
- `last_updated`
- `version`
- `deleted_at`

### 5.16 `logged_actions`

Audit table populated by database/application audit paths.

Column summary:

| Column group   | Type/constraint summary                         |
| -------------- | ----------------------------------------------- |
| Identity       | `event_id` primary identifier                   |
| Target         | schema/table names                              |
| Actor/time     | user ID, timestamp, client IP, application name |
| Change payload | action, row data, changed fields, query text    |

Key fields:

- `event_id`
- `schema_name`
- `table_name`
- `user_id`
- `action_timestamp`
- `action`
- `row_data`
- `changed_fields`
- `query`
- `client_ip`
- `application_name`

Notes:

- V28 widened the `action` column.
- V30+ migrations add auth/RLS-related grants and helper functions.

### 5.17 `chat_conversations`

AI assistant conversation storage.

Key fields:

- `id`
- `user_id`
- `organization_id`
- `messages`
- `updated_at`
- `version`
- `deleted_at`

### 5.18 `beta_interests`

Public/beta interest capture.

Key fields:

- `id`
- `email`
- `company_size`
- `use_case`
- `source`
- `created_at`
- `version`

### 5.19 `leads`

Lead capture records from public/landing flows.

Key fields:

- `id`
- `name`
- `email`
- `company`
- `phone`
- `country`
- `message`
- `lead_source`
- `ip`
- `user_agent`
- `status`
- `created_at`

### 5.20 `stripe_webhook_events`

Payment provider webhook idempotency/audit log.

Key fields:

- `id`
- `event_type`
- `organization_id`
- `payload`
- `processed_at`
- `error`

### 5.21 `sef_webhook_events`

SEF status webhook idempotency/audit log added by V36. Because SEF does not expose a documented immutable event ID, the API computes a SHA-256 idempotency key from the parsed SEF payload and raw body before calling `SefService.handleWebhook()`.

Key fields:

- `id` — SHA-256 idempotency key; primary key
- `sef_invoice_id`
- `status`
- `status_date`
- `payload`
- `processing_status` — `processing`, `processed`, or `failed`
- `processed_at`
- `error`
- `created_at`

Notes:

- Duplicate webhook deliveries with the same idempotency key return `200` with `duplicate=true` and are not reprocessed once processing is in progress or complete.
- Failed events are marked `failed`; a later duplicate delivery can retry processing.
- Signature verification still happens first via `X-Sef-Signature` / `SEF_WEBHOOK_SECRET`.

### 5.22 `adapter_config`

Per-market integration adapter toggles.

Key fields:

- `id`
- `market`
- `adapter_type`
- `adapter_name`
- `enabled`
- `reason`
- `updated_at`
- `updated_by`

Notes:

- Added by V25.
- Used to control market adapters such as e-invoice integrations.

### 5.23 `schema_version`

Legacy/internal schema marker table mapped by Exposed.

Key fields:

- `version`
- `applied_at`
- `description`

Notes:

- Flyway remains the migration authority. This table is not a replacement for Flyway history.

---

## 6. Cross-Cutting Conventions

### UUID identifiers

Most business tables use UUID primary keys. Public API paths expose UUID strings for resource identifiers.

### Soft deletion

Several tenant/business tables include `deleted_at`. Application queries should exclude soft-deleted rows unless a route is explicitly designed for archive/audit use.

### Optimistic version field

Many tables include a `version` field. Preserve it when adding update paths and migrations.

### Money

Money columns are stored as decimal/numeric values with explicit currency fields. `base_amount` fields support organization base-currency reporting.

### Country and market support

Market-specific support currently includes HR/RS/BA concepts across country constraints, tax rates, chart-of-accounts migrations, SEF fields, and adapter configuration.

---

## 7. Operational Procedures

### Add a table or column

1. Create a new Flyway migration with the next version.
2. Add or update the corresponding Exposed mapping in `Tables.kt`.
3. Update services/routes/tests that use the new field.
4. Update OpenAPI and backend docs if API shape changes.
5. Run Flyway validation/migration in the intended environment.
6. Capture evidence for MC/PR review.

### Change an existing applied migration

Do not edit it. Instead:

1. Create a new forward migration.
2. Explain the compatibility path in the PR/MC evidence.
3. Validate on a non-production target before promotion.

### Repair Flyway metadata drift

Only perform repair after all of these are captured:

1. Target identity: project, instance, database, environment.
2. Flyway validate output showing exact drift.
3. Schema checks proving the live schema matches expected intent.
4. Written runbook and abort conditions.
5. Repair transcript.
6. Post-repair validate/migrate/info output.
7. Deployment or smoke evidence if the drift blocked CI/CD.

MC #101509 is the reference example for this flow.

---

## 8. Validation Checklist

Before marking database documentation current:

- `Tables.kt` table inventory reviewed.
- Flyway migration directory reviewed.
- No stale deployment assumptions remain in this document.
- No legacy ORM workflow is presented as active.
- OpenAPI/API docs updated when endpoint shapes changed.
- Environment-specific migration claims cite evidence.

---

## 9. Index and Performance Strategy

The exact index inventory is migration-defined and should be inspected with `psql` against the target database when diagnosing query plans. The application design depends on these index principles:

| Query family        | Required access pattern                                                   |
| ------------------- | ------------------------------------------------------------------------- |
| Tenant lists        | composite lookup by `organization_id` plus status/date/name as applicable |
| Invoice lists       | organization + customer/status/date ordering                              |
| Expense lists       | organization + vendor/status/date ordering                                |
| Ledger reports      | organization + transaction date range; debit/credit account joins         |
| Bank reconciliation | bank account + reconciliation status + transaction date                   |
| Auth                | user email lookup and refresh-token `jti`/user lookup                     |
| Audit               | table/action/time filtering and user/time filtering                       |

Performance targets for product-facing paths:

- Tenant-scoped list endpoints should avoid full-table scans across organizations.
- Month/quarter report queries should be bounded by organization and date range.
- Background reconciliation/export jobs may use broader scans, but should be batchable and observable.
- Any new high-cardinality field used in filters should include an index decision in the migration PR.

When adding an index:

1. Add it in a new Flyway migration.
2. Explain the route/report it supports.
3. Verify with `EXPLAIN` or a representative query when data volume makes the risk material.

---

## 10. Audit Log Scaling and Retention

`logged_actions` can grow faster than ordinary tenant tables. Current documentation stance:

- The active schema keeps audit rows in PostgreSQL and records actor, target table, action, row data, changed fields, query text, client IP, and application name.
- V28 widened the action field to support current action labels.
- No partitioning migration is currently documented as applied in `Tables.kt`/Flyway source of truth.

Future scaling decision:

- If audit volume threatens report/API latency or storage budgets, introduce an explicit Flyway migration for partitioning or archival.
- The migration must include retention policy, query impact, backfill plan, and restore/audit requirements.
- Until that migration exists, do not describe partitioning as active behaviour.

---

## 11. Known Follow-Ups

- Keep `docs/backend/openapi.yaml` aligned with implemented Ktor routes.
- Keep `docs/backend/API-REFERENCE.md` aligned with OpenAPI.
- Keep deployment docs aligned with GCP Cloud Run and Cloud SQL reality.
- Consider generating a schema snapshot from a migrated Cloud SQL-compatible database for future reviews.

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