Backend
API reference, database, authentication, services, middleware
- API & Data
- Services & Middleware
- Backend Architecture Document
- Service Design Document — Payment Service
- Middleware Design
- External Services Integration
- Event Schema Documentation
- Backend Architecture
- Service Design
- Database Schema
- Error Codes Catalog
- Roles and Permissions
- Account Lockout (Brute-Force zaštita) — A12
- KPO Export RBAC Fix — MC #105326
- Bilko — Payment-event obaveze (JOPPD): dizajn, scope odluke, runbook — MC #105742
- Bilko — Financial Audit Trail & Retention Architecture
- Bilko — Retention entity_type mapping (V133, MC #105965)
- Bilko — RetentionPurgeWorker DRY-RUN (B2, MC #105881)
- Bilko — Support agent rola + RLS temelj (Faza A, MC #106050/#106051)
- Bilko — Support ticket reply + GUC aktivacija (Faza B, MC #106054-106057)
- Bilko — Support sistem — arhitektura (MC #106061)
- Bilko — Kako support agent radi (runbook, MC #106063)
API & Data
API Reference
Drop Backend API Reference
Auto-generated from source code analysis. All file references are relative to
src/drop-app/src/.
Overview
Drop uses Next.js App Router API routes (app/api/). All responses use a consistent JSON envelope:
{ "data": { ... } } // Success
{ "error": "code", "message": "...", "details": [...] } // Error
Authentication is via httpOnly cookie (drop_token) containing a signed JWT (HS256, 24h expiry).
Pass-Through Model: Drop uses a PSD2 pass-through model — it NEVER holds customer money. There is no wallet, no balance, no top-up. User funds remain in their bank account at all times. Drop uses:
- AISP (Account Information Service Provider) — reads bank balance via Open Banking
- PISP (Payment Initiation Service Provider) — initiates transfers directly from user's bank account
The bank_accounts.balance field stores the last AISP-read balance from the user's real bank (cached for display) — NOT a Drop-held balance.
Authentication
POST /api/auth/register
Create a new user account.
| Field | Source |
|---|---|
| File | app/api/auth/register/route.ts |
| Auth | None |
| Rate Limit | 10 req/min per IP |
Request Body:
| Field | Type | Required | Validation |
|---|---|---|---|
| string | Yes | RFC-like regex, unique | |
| password | string | Yes | Min 8 chars, must contain letters + digits |
| firstName | string | Yes | validateName() — 1-100 chars, at least one letter, no HTML/script |
| lastName | string | Yes | Same as firstName |
| phone | string | No | International format +XXXXXXXXXXXX (8-15 digits) |
| dateOfBirth | string | Yes | ISO date string, must be >= 18 years old |
Success Response (201):
{
"data": {
"id": "usr_...",
"email": "user@example.com",
"firstName": "...",
"lastName": "...",
"dateOfBirth": "...",
"kycStatus": "pending",
"createdAt": "2026-..."
}
}
Error Responses:
| Status | Code | Condition |
|---|---|---|
| 400 | bad_request | Invalid JSON body |
| 409 | conflict | Email already registered |
| 422 | validation_error | Field validation failures (returned in details array) |
| 429 | rate_limited | Too many requests |
POST /api/auth/login
Authenticate with email and password.
| Field | Source |
|---|---|
| File | app/api/auth/login/route.ts |
| Auth | None |
| Rate Limit | 10 req/min per IP |
Request Body:
| Field | Type | Required |
|---|---|---|
| string | Yes | |
| password | string | Yes |
Success Response (200):
{
"data": {
"id": "usr_...",
"email": "...",
"firstName": "...",
"lastName": "...",
"kycStatus": "approved"
}
}
Error Responses:
| Status | Code | Condition |
|---|---|---|
| 400 | bad_request | Missing email or password |
| 401 | unauthorized | Invalid credentials |
| 429 | rate_limited | Too many requests |
GET /api/auth/me
Get current authenticated user with bank accounts.
| Field | Source |
|---|---|
| File | app/api/auth/me/route.ts |
| Auth | Required (cookie) |
Success Response (200):
{
"data": {
"id": "usr_...",
"email": "...",
"firstName": "...",
"lastName": "...",
"totalBalance": 58030.0,
"bankAccounts": [
{
"id": "ba_1",
"bankName": "DNB",
"accountNumber": "1234.56.78901",
"balance": 45230.0,
"currency": "NOK",
"isPrimary": true
}
],
"kycStatus": "approved",
"createdAt": "..."
}
}
POST /api/auth/logout
Logout and revoke all sessions.
| Field | Source |
|---|---|
| File | app/api/auth/logout/route.ts |
| Auth | Required (cookie) |
Calls revokeAllSessions() to invalidate all session records, then clears the auth cookie.
Success Response (200):
{ "message": "Logged out" }
POST /api/auth/refresh
Refresh the authentication token (issue new JWT, create new session record).
| Field | Source |
|---|---|
| File | app/api/auth/refresh/route.ts |
| Auth | Required (cookie) |
Success Response (200):
{
"data": {
"userId": "usr_...",
"email": "...",
"role": "user"
}
}
Transactions
GET /api/transactions
List user's transactions with pagination and filtering.
| Field | Source |
|---|---|
| File | app/api/transactions/route.ts |
| Auth | Required |
Query Parameters:
| Param | Type | Default | Notes |
|---|---|---|---|
| page | int | 1 | Min 1 |
| limit | int | 20 | Min 1, Max 50 |
| type | string | - | remittance or qr_payment |
| status | string | - | processing, completed, or failed |
Success Response (200):
{
"data": [
{
"id": "tx_rem_1",
"type": "remittance",
"status": "completed",
"amount": -2000,
"currency": "NOK",
"recipientName": "Mama Jasmina",
"createdAt": "..."
}
],
"pagination": { "page": 1, "limit": 20, "total": 3 }
}
Note: amount is negated in the response (always shown as outgoing).
GET /api/transactions/[id]
Get single transaction details with exchange rate info.
| Field | Source |
|---|---|
| File | app/api/transactions/[id]/route.ts |
| Auth | Required |
Success Response (200):
{
"data": {
"id": "tx_rem_1",
"type": "remittance",
"status": "completed",
"sendAmount": 2000,
"sendCurrency": "NOK",
"receiveAmount": 23400,
"receiveCurrency": "RSD",
"exchangeRate": 11.7,
"fee": 10,
"total": 2010,
"recipientName": "Mama Jasmina",
"recipientCountry": "Serbia",
"createdAt": "...",
"completedAt": "..."
}
}
GET /api/transactions/summary
Get transaction summary statistics (all-time and this month).
| Field | Source |
|---|---|
| File | app/api/transactions/summary/route.ts |
| Auth | Required |
Success Response (200):
{
"data": {
"allTime": {
"totalCount": 3,
"totalSent": 5000,
"totalPaid": 129,
"remittanceCount": 2,
"qrPaymentCount": 1
},
"thisMonth": { "..." }
}
}
POST /api/transactions/remittance
Create a remittance (international money transfer).
| Field | Source |
|---|---|
| File | app/api/transactions/remittance/route.ts |
| Auth | Required |
| Rate Limit | 10 req/min per IP |
| KYC | Must be approved |
Request Body:
| Field | Type | Required | Validation |
|---|---|---|---|
| recipientId | string | Yes | Must belong to user |
| amount | number | Yes | 100-50,000 NOK, max 2 decimal places |
| currency | string | No | Defaults to NOK |
| bankAccountId | string | No | Defaults to primary bank account |
Business Logic:
- Verify recipient belongs to user
- Look up exchange rate for recipient's currency
- Verify bank account exists and has sufficient balance
- Fee: 0.5% of amount
- Debit bank account (atomic transaction)
- Create transaction record with status
processing
Success Response (201):
{
"data": {
"id": "tx_rem_...",
"type": "remittance",
"status": "processing",
"sendAmount": 2000,
"sendCurrency": "NOK",
"receiveAmount": 23400,
"receiveCurrency": "RSD",
"exchangeRate": 11.7,
"fee": 10,
"feePercent": 0.5,
"total": 2010,
"recipientName": "...",
"recipientCountry": "Serbia",
"fromAccount": "DNB",
"eta": "1-2 business days",
"createdAt": "..."
}
}
Error Responses:
| Status | Code | Condition |
|---|---|---|
| 400 | bad_request | Missing/invalid fields |
| 400 | no_bank_account | No linked bank account |
| 402 | insufficient_balance | Bank account balance too low |
| 403 | kyc_required | KYC not approved |
| 404 | not_found | Recipient not found |
| 422 | validation_error | Unsupported currency corridor |
POST /api/transactions/qr-payment
Create a QR payment to a merchant.
| Field | Source |
|---|---|
| File | app/api/transactions/qr-payment/route.ts |
| Auth | Required |
| Rate Limit | 10 req/min per IP |
Request Body:
| Field | Type | Required | Validation |
|---|---|---|---|
| merchantId | string | Yes | Must exist |
| amount | number | Yes | 1-100,000 NOK, max 2 decimal places |
Business Logic:
- Verify merchant exists
- Get user's primary bank account
- Fee: 1% of amount
- Debit bank account (atomic transaction)
- Create transaction with status
completed(instant)
Success Response (201):
{
"data": {
"id": "tx_qr_...",
"type": "qr_payment",
"status": "completed",
"amount": 129,
"currency": "NOK",
"fee": 1.29,
"feePercent": 1,
"merchantName": "Ahmetov Kebab",
"merchantId": "mer_1",
"fromAccount": "DNB",
"createdAt": "..."
}
}
Recipients
GET /api/recipients
List user's recipients with pagination.
| Field | Source |
|---|---|
| File | app/api/recipients/route.ts |
| Auth | Required |
Query Parameters: page (default 1), limit (default 20, max 50)
Bank account numbers are masked in response (e.g., *****5678).
Supported Countries: RS (Serbia), BA (Bosnia), PL (Poland), PK (Pakistan), TR (Turkey)
POST /api/recipients
Add a new recipient.
| Field | Source |
|---|---|
| File | app/api/recipients/route.ts |
| Auth | Required |
Request Body:
| Field | Type | Required | Validation |
|---|---|---|---|
| name | string | Yes | validateName() |
| country | string | Yes | Must be in supported list |
| currency | string | Yes | - |
| bankAccount | string | Yes | - |
| bankName | string | No | Sanitized to 200 chars |
DELETE /api/recipients/[id]
Delete a recipient.
| Field | Source |
|---|---|
| File | app/api/recipients/[id]/route.ts |
| Auth | Required |
Returns 204 No Content on success. Returns 404 if recipient not found or not owned by user.
Cards (FUTURE — feature-flagged, all flags default to false)
Note: The entire Cards section is a FUTURE feature, gated behind feature flags. All card-related feature flags default to
false. These endpoints exist in code but return 404 when flags are disabled. Cards require a card issuing partner (e.g., Stripe Issuing) before activation.
GET /api/cards
List user's cards (excludes cancelled).
| Field | Source |
|---|---|
| File | app/api/cards/route.ts |
| Auth | Required |
POST /api/cards
Create a new card (virtual or physical).
| Field | Source |
|---|---|
| File | app/api/cards/route.ts |
| Auth | Required |
Request Body:
| Field | Type | Required | Notes |
|---|---|---|---|
| type | string | No | virtual (default) or physical |
GET /api/cards/[id]
Get card details. Card number is masked (---- ---- ---- XXXX), CVV is hidden (---).
| Field | Source |
|---|---|
| File | app/api/cards/[id]/route.ts |
| Auth | Required |
PCI-DSS compliant: never exposes full card number or CVV.
PATCH /api/cards/[id]
Freeze or unfreeze a card.
| Field | Source |
|---|---|
| File | app/api/cards/[id]/route.ts |
| Auth | Required |
Request Body: { "status": "active" | "frozen" }
DELETE /api/cards/[id]
Cancel a card (soft delete — sets status to cancelled).
| Field | Source |
|---|---|
| File | app/api/cards/[id]/route.ts |
| Auth | Required |
POST /api/cards/[id]/physical
Order physical version of a virtual card.
| Field | Source |
|---|---|
| File | app/api/cards/[id]/physical/route.ts |
| Auth | Required |
| Feature Flag | physicalCards (returns 404 if disabled) |
Request Body: { "address": "..." } (min 10 chars)
POST /api/cards/[id]/pin
Set PIN for a card.
| Field | Source |
|---|---|
| File | app/api/cards/[id]/pin/route.ts |
| Auth | Required |
| Feature Flag | cardPin (returns 404 if disabled) |
Request Body: { "pin": "1234" } (exactly 4 digits)
PIN is hashed with bcrypt before storage.
GET /api/cards/[id]/limits
Get spending limits for a card.
| Field | Source |
|---|---|
| File | app/api/cards/[id]/limits/route.ts |
| Auth | Required |
| Feature Flag | spendingLimits (returns 404 if disabled) |
PUT /api/cards/[id]/limits
Set a spending limit for a card.
| Field | Source |
|---|---|
| File | app/api/cards/[id]/limits/route.ts |
| Auth | Required |
| Feature Flag | spendingLimits (returns 404 if disabled) |
Request Body:
| Field | Type | Required | Validation |
|---|---|---|---|
| limitType | string | Yes | daily, weekly, monthly, or transaction |
| amount | number | Yes | Must be positive |
Replaces any existing limit of the same type.
Exchange Rates
See also: Currency Rates for provider source, cache/freshness behavior, fallback, and circuit-breaker verification.
GET /api/rates
Get all persisted exchange rates from NOK. This endpoint reads exchange_rates; it does not call the external FX providers directly.
| Field | Source |
|---|---|
| File | apps/drop-api/src/routes/rates.ts |
| Auth | None |
| Rate Limit | 120 req/min per IP |
Success Response (200):
{
"data": {
"baseCurrency": "NOK",
"rates": { "RSD": 10.17, "BAM": 0.17, "PLN": 0.374, "PKR": 26.5, "TRY": 3.39, "EUR": 0.087 },
"updatedAt": "...",
"stale": false
}
}
GET /api/rates/:currency
Get one persisted NOK → target-currency rate.
| Field | Source |
|---|---|
| File | apps/drop-api/src/routes/rates.ts |
| Auth | None |
| Rate Limit | 120 req/min per IP |
Response includes fee: 0.005 (0.5% remittance fee) and stale freshness status.
Notifications
GET /api/notifications
List all notifications for user.
| Field | Source |
|---|---|
| File | app/api/notifications/route.ts |
| Auth | Required |
| Feature Flag | notifications (default: enabled) |
PATCH /api/notifications
Mark notifications as read.
| Field | Source |
|---|---|
| File | app/api/notifications/route.ts |
| Auth | Required |
| Feature Flag | notifications |
Request Body: { "notificationIds": ["noti_..."] }
- Max 100 IDs per request
- IDs validated against format
^[a-z]+_[a-f0-9]{16}$
Settings
GET /api/settings
Get user settings (creates defaults if none exist).
| Field | Source |
|---|---|
| File | app/api/settings/route.ts |
| Auth | Required |
Defaults: currency=NOK, language=nb, pushEnabled=true, emailEnabled=true
PATCH /api/settings
Update user settings.
| Field | Source |
|---|---|
| File | app/api/settings/route.ts |
| Auth | Required |
Request Body (all optional):
| Field | Type | Validation |
|---|---|---|
| currency | string | Whitelist: EUR, USD, GBP, BAM, CHF, PLN, NOK, RSD, TRY, PKR |
| language | string | Whitelist: nb, en, bs, sq |
| pushEnabled | boolean | - |
| emailEnabled | boolean | - |
Merchants
POST /api/merchants/register
Register as a merchant.
| Field | Source |
|---|---|
| File | app/api/merchants/register/route.ts |
| Auth | Required |
Request Body:
| Field | Type | Required | Validation |
|---|---|---|---|
| businessName | string | Yes | validateName() |
| orgNumber | string | Yes | Exactly 9 digits, unique |
| address | string | No | Sanitized to 300 chars |
| bankAccount | string | Yes | Payout account |
Upgrades user role to merchant. Returns a QR code URI (drop://pay/{merchantId}).
GET /api/merchants/dashboard
Get merchant dashboard stats.
| Field | Source |
|---|---|
| File | app/api/merchants/dashboard/route.ts |
| Auth | Required (merchant role) |
Query Parameters: period — today (default), week, month
Returns: revenue, transactionCount, fees, netRevenue, nextPayout, payoutTime.
GET /api/merchants/qr
Get merchant QR code data.
| Field | Source |
|---|---|
| File | app/api/merchants/qr/route.ts |
| Auth | Required (merchant role) |
Returns: merchantId, businessName, qrValue (drop://pay/{id}), address.
GET /api/merchants/transactions
List merchant's QR payment transactions with pagination.
| Field | Source |
|---|---|
| File | app/api/merchants/transactions/route.ts |
| Auth | Required (merchant role) |
Query Parameters: page, limit
Customer names are partially anonymized (first name + last initial).
GDPR & Compliance
GET /api/user/data-export
Export all user data (GDPR right to data portability).
| Field | Source |
|---|---|
| File | app/api/user/data-export/route.ts |
| Auth | Required |
Creates a data_access_request record with type export and status completed.
Success Response (200):
{
"data": {
"user": {
"id": "usr_...",
"email": "...",
"first_name": "...",
"last_name": "...",
"phone": "+47...",
"date_of_birth": "1995-03-15",
"kyc_status": "approved",
"role": "user",
"created_at": "..."
},
"transactions": [ {...}, {...} ],
"recipients": [ {...}, {...} ],
"bankAccounts": [ {...} ],
"settings": { "currency": "NOK", "language": "nb", ... },
"consents": [ {...}, {...} ]
},
"exportedAt": "2026-02-17T..."
}
DELETE /api/user/account
Request account deletion (GDPR right to erasure).
| Field | Source |
|---|---|
| File | app/api/user/account/route.ts |
| Auth | Required |
Behavior:
- Soft-deletes user (sets
deleted_attimestamp) - Revokes all active sessions
- Creates
data_access_requestwith typeerasureand statuscompleted - Important: Data retained for 5 years per AML/KYC legal requirements (hvitvaskingsloven)
Success Response (200):
{
"message": "Account scheduled for deletion",
"retentionNote": "Data retained for 5 years per AML requirements"
}
GET /api/consents
List user's GDPR consents.
| Field | Source |
|---|---|
| File | app/api/consents/route.ts |
| Auth | Required |
Success Response (200):
{
"data": [
{
"id": "con_...",
"user_id": "usr_...",
"consent_type": "terms",
"granted": 1,
"granted_at": "2026-02-17T...",
"withdrawn_at": null,
"ip_address": "192.0.2.1"
}
]
}
POST /api/consents
Grant or withdraw a consent.
| Field | Source |
|---|---|
| File | app/api/consents/route.ts |
| Auth | Required |
Request Body:
| Field | Type | Required | Validation |
|---|---|---|---|
| consentType | string | Yes | Must be one of: terms, privacy, marketing, cookies_analytics, cookies_marketing |
| granted | boolean | Yes | true to grant, false to withdraw |
Behavior:
- If consent exists: updates
grantedfield and sets eithergranted_atorwithdrawn_at - If consent doesn't exist: creates new consent record
- Records user's IP address with consent action
Success Response (200 for update, 201 for new):
{
"data": {
"id": "con_...",
"consent_type": "marketing",
"granted": 1,
"granted_at": "2026-02-17T...",
"withdrawn_at": null,
"ip_address": "192.0.2.1"
}
}
Error Responses:
| Status | Code | Condition |
|---|---|---|
| 400 | bad_request | Invalid consent type or missing fields |
GET /api/complaints
List user's complaints.
| Field | Source |
|---|---|
| File | app/api/complaints/route.ts |
| Auth | Required |
Query Parameters:
| Param | Type | Default | Notes |
|---|---|---|---|
| page | int | 1 | Pagination page number |
| limit | int | 10 | Items per page, max 100 |
Success Response (200):
{
"data": [
{
"id": "cmp_...",
"category": "transaction",
"subject": "Transaction delayed",
"description": "My remittance to Serbia is delayed...",
"status": "received",
"resolution": null,
"created_at": "2026-02-17T...",
"resolved_at": null
}
],
"pagination": {
"page": 1,
"limit": 10,
"total": 3,
"totalPages": 1
}
}
POST /api/complaints
Submit a complaint (Finansavtaleloven §3-53 compliance).
| Field | Source |
|---|---|
| File | app/api/complaints/route.ts |
| Auth | Required |
Request Body:
| Field | Type | Required | Validation |
|---|---|---|---|
| category | string | Yes | Must be one of: transaction, service, fees, privacy, technical, other |
| subject | string | Yes | Max 200 chars, sanitized |
| description | string | Yes | Max 2000 chars, sanitized |
Success Response (201):
{
"data": {
"id": "cmp_...",
"category": "fees",
"subject": "High transfer fee",
"description": "...",
"status": "received",
"created_at": "2026-02-17T..."
},
"commitmentNote": "We will review and respond to your complaint within 15 business days per Finansavtaleloven §3-53"
}
Error Responses:
| Status | Code | Condition |
|---|---|---|
| 400 | bad_request | Invalid category or empty fields |
POST /api/transactions/disclosure
Get full transaction fee and exchange rate disclosure before initiating payment.
| Field | Source |
|---|---|
| File | app/api/transactions/disclosure/route.ts |
| Auth | Required |
Request Body:
| Field | Type | Required | Notes |
|---|---|---|---|
| type | string | Yes | remittance or qr_payment |
| amount | number | Yes | Must be positive |
| currency | string | No | Defaults to NOK |
| recipientId | string | Conditional | Required for remittance |
Success Response (200):
{
"amount": 2000,
"fee": 10,
"feePercentage": 0.5,
"exchangeRate": 10.17,
"receiveAmount": 20340,
"receiveCurrency": "RSD",
"estimatedDelivery": "1-2 business days",
"totalCost": 2010
}
Fee Calculation:
- Remittance: 0.5% of amount
- QR payment: 1.0% of amount
Delivery Time:
- QR payment: "Instant"
- Remittance (EEA): "1-2 business days"
- Remittance (non-EEA): "2-4 business days"
GET /api/transactions/[id]/receipt
Get transaction receipt with full details.
| Field | Source |
|---|---|
| File | app/api/transactions/[id]/receipt/route.ts |
| Auth | Required |
Success Response (200):
{
"data": {
"transactionId": "tx_rem_1",
"date": "2026-02-17T...",
"type": "remittance",
"amount": 2000,
"currency": "NOK",
"fee": 10,
"exchangeRate": 10.17,
"receiveAmount": 20340,
"receiveCurrency": "RSD",
"recipient": {
"name": "Mama Jasmina",
"country": "RS"
},
"reference": "tx_rem_1",
"status": "completed",
"estimatedCompletion": null,
"completedAt": "2026-02-17T..."
}
}
Error Responses:
| Status | Code | Condition |
|---|---|---|
| 404 | not_found | Transaction not found or not owned by user |
Health Check
GET /api/health
System health check (no auth required).
| Field | Source |
|---|---|
| File | app/api/health/route.ts |
| Auth | None |
Success Response (200):
{
"status": "ok",
"version": "0.1.0",
"uptime": 3600,
"db": "connected",
"dbLatencyMs": 1,
"timestamp": "..."
}
Returns 503 with status: "error" if database is unreachable.
Authentication
Drop Authentication System
Sources:
src/drop-app/src/app/api/auth/bankid/,src/drop-api/src/lib/bankid.ts,src/drop-api/src/routes/auth.ts
Overview
Drop uses BankID OIDC as the sole authentication method. Email/password login has been removed to comply with PSD2/SCA requirements.
- Auth method: BankID OIDC (Norwegian eID)
- JWT Algorithm: HS256 (HMAC-SHA256), RS256 opt-in
- Library:
jose(SignJWT/jwtVerify) - Token lifetime: 24h (web cookie), 7d (mobile Bearer token)
- Web cookie:
drop_token(httpOnly, secure, sameSite=strict) - Mobile: Bearer token in Authorization header
Phase 2 (planned)
- Vipps Login — same OIDC pattern, user dedup by
national_id_hash - Idura aggregator optional (single integration point for BankID + Vipps)
Authentication Flow
BankID Login (Web)
Browser Next.js BFF BankID OIDC
| | |
| GET /api/auth/bankid | |
|--------------------------->| |
| | 1. Rate limit check |
| | 2. Generate state + nonce |
| | 3. Set bankid_state cookie |
| { redirectUrl } | |
|<---------------------------| |
| | |
| Browser redirects to BankID authorize URL |
|---------------------------------------------------------->|
| | |
| User authenticates with BankID |
| | |
| BankID redirects to /api/auth/bankid/callback?code=&state=
|<----------------------------------------------------------|
| | |
| GET /callback?code&state | |
|--------------------------->| |
| | 4. Verify state vs cookie |
| | 5. Exchange code for tokens |
| |----------------------------->|
| | { id_token, access_token } |
| |<-----------------------------|
| | 6. Verify ID token (JWKS) |
| | 7. Parse pid, verify age |
| | 8. Find/create user |
| | 9. Create session + cookie |
| 302 /dashboard | |
|<---------------------------| |
BankID Login (Mobile)
Mobile App Hono API BankID OIDC
| | |
| GET /v1/auth/bankid/initiate?platform=mobile |
|--------------------------->| |
| { redirectUrl, state } | |
|<---------------------------| |
| | |
| Open BankID in secure browser (expo-web-browser) |
|---------------------------------------------------------->|
| | |
| User authenticates with BankID |
| | |
| Redirect to drop://auth/callback?code=&state= |
|<----------------------------------------------------------|
| | |
| POST /v1/auth/bankid/callback |
| { code, state, platform } |
|--------------------------->| |
| | 1. Exchange code for tokens |
| |----------------------------->|
| | { id_token } |
| |<-----------------------------|
| | 2. Verify ID token (JWKS) |
| | 3. Parse pid, verify age |
| | 4. Find/create user |
| | 5. Create session |
| { token, data } | |
|<---------------------------| |
| | |
| Store token in AsyncStorage |
User Creation
BankID login automatically creates user accounts:
- Parse pid from BankID ID token (Norwegian national ID, 11 digits)
- Hash pid with SHA-256 for storage (
national_id_hashcolumn) - Check existing user by
national_id_hash - If new: Create user with:
kyc_status = 'approved'(BankID = verified identity)kyc_method = 'bankid'auth_provider = 'bankid'password_hash = 'EIDONLY'(sentinel — no password auth)
- Age check: Must be >= 18 (parsed from pid birthdate)
JWT Structure
Payload
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
- On login:
sessionsrecord created with SHA-256 hash of JWT - On each request: Verify session not revoked + not expired
- 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:
Services & Middleware
Services
Drop External Services
Source:
src/drop-app/src/lib/services/
Overview
Drop uses a PSD2 pass-through model — it never holds customer money. AISP reads bank balances via Open Banking, PISP initiates payments from the user's own bank account.
Drop integrates with external service providers. Each service has a different readiness level — see status tags below.
For backend FX-rate sourcing, see Currency Rates. Current drop-api source is configured for Norges Bank primary rates with ECB fallback; /api/rates reads the persisted exchange_rates table rather than calling providers directly.
Legend:
[PRODUCTION]= real SDK, production-ready.[MOCK/DEV]= mock only, NOT connected to real APIs.[PLANNED]= future roadmap.[DEPRECATED]= no longer the chosen provider.
Service mode is controlled by NEXT_PUBLIC_SERVICE_MODE env var (default: mock).
Source: services/index.ts:21-30
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
BAformat (Bosnia mock) - Transfers start as
Pending, settle toBookedafter 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
activestatus, 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, typeRETRY
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
// Call on app startup
await initializeServices()
// Reset all mocks (testing)
resetMockServices()
Service Status Summary
| Service | Status | Description |
|---|---|---|
| Sumsub | [PRODUCTION] |
Real API integration, WebSDK, webhook handling — READY |
| FX rates | [IMPLEMENTED/RISK] |
drop-api uses Norges Bank primary + ECB fallback for cron refresh; see verified parser/conversion risk in Currency Rates |
| 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
- Sumsub is the ONLY production-ready service — all others are mocks or deprecated.
- Console warnings are emitted on module load for mock services to make usage visible.
- Mock state uses
localStoragein browser, in-memory on server — resets on server restart. - Production API endpoints are configurable via environment variables.
- 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:
-
src/lib/middleware.ts— The active middleware used by all API routes. ProvidesrequireAuth,requireMerchant,rateLimit,getClientIp,jsonError, CSRF protection, and session revocation. -
src/lib/middleware/— A modular middleware library withauth-middleware.ts(Bearer token for mobile),error-handler.ts(AppError class), andvalidation.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:
- CSRF origin check — if
Originheader present, must match allowed origins (NEXT_PUBLIC_APP_URL,http://localhost:3000,http://localhost:3001) - Cookie extraction — reads
drop_tokenfrom request cookies - JWT verification — validates HS256 signature and expiry using
joselibrary - User lookup — loads user from
userstable byuserIdfrom JWT payload - Session revocation check — verifies at least one non-revoked session exists for this user
Usage:
const { user, error } = await requireAuth(request);
if (error) return error; // Returns NextResponse with JSON error
// user is guaranteed non-null here
Error responses:
2.2 requireMerchant(request?)
Source: middleware.ts:101–108
Extends requireAuth with a merchant role check.
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
runUpsertfor atomic counter creation/update - Cleans expired entries on each call (removes rows where
expires_at < now) - Counter stored in
rate_limitstable:(key, count, expires_at)
Rate limit table schema:
CREATE TABLE rate_limits (
key TEXT PRIMARY KEY, -- IP address
count INTEGER DEFAULT 1,
expires_at INTEGER -- Unix timestamp (ms)
);
Usage:
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.
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.
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:
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:
{
"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
userIdfrom payload
In-memory rate limiter (for Bearer token routes):
DEFAULT_RATE_LIMIT: 100 req/minSTRICT_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:
validateNamechecks for:<script,javascript:,onerror=,onclick=— blocks XSS injection in name fieldssanitizeTextremoves HTML tags via regex, strips control charactersvalidateIBANimplements full mod-97 checksum algorithmvalidateAmountrejectsNaN,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 injsonError()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
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:
// 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
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:
-
lib/middleware.ts— The active middleware used by all API routes. ProvidesrequireAuth,requireMerchant,rateLimit,getClientIp,jsonError, CSRF, and session revocation. -
lib/middleware/directory — A modular middleware library withauth-middleware.ts,error-handler.ts, andvalidation.ts. Exported via barrel filemiddleware/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:
- CSRF origin check — If
Originheader present, must match allowed origins - Cookie extraction — Reads
drop_tokenfrom cookies - JWT verification — Validates signature and expiry
- User lookup — Loads user from
userstable - Session revocation check — Verifies at least one non-revoked session exists
Allowed origins: NEXT_PUBLIC_APP_URL, http://localhost:3000, http://localhost:3001
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.
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.
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
runUpsertfor 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.
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:
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:
{
"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/minSTRICT_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:
validateNamechecks for dangerous patterns:<script,javascript:,onerror=,onclick=sanitizeTextremoves 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_URLset → PostgreSQL (pgdriver)DATABASE_URLnot 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)
// 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:
GET /api/auth/bankid→ generate state + nonce, setbankid_statecookie, return redirect URL- User authenticates with BankID at provider
GET /api/auth/bankid/callback?code=&state=→ verify state, exchange code for tokens, verify JWKS signature, parsepid, 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_hashcolumn - 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
-
Consistent response envelope:
- Success:
{ "data": { ... } }or{ "data": [...], "pagination": { ... } } - Error:
{ "error": "code", "message": "...", "details": [...] }
- Success:
-
No wallet model: Drop never holds funds.
bank_accounts.balanceis AISP-read cache only. -
KYC gate: Remittance requires
kyc_status === 'approved'— enforced in route handler. -
Atomic transactions: Balance deduction and transaction creation in a single DB transaction.
-
Data masking: Bank account numbers masked in responses (
*****5678), card numbers PCI-masked. -
GDPR by design: Data export, account deletion (soft delete), consent records all implemented.
-
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
- Middleware Design
- Service Design
- External Services Integration
- Deployment Architecture
- Authentication Source
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:
- Remittance — international money transfers from Norway to 5 corridors (Serbia, Bosnia, Poland, Pakistan, Turkey)
- 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.tssrc/drop-app/src/app/api/transactions/qr-payment/route.tssrc/drop-app/src/app/api/transactions/disclosure/route.tssrc/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
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
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 amountfee— Drop fee (0.5% remittance / 1.0% QR)feePercentage— percentageexchangeRate— NOK to destination currencyreceiveAmount— amount recipient receivesreceiveCurrency— destination currencyestimatedDelivery— ETA stringtotalCost— amount + fee
6. Database Operations
6.1 Atomic Transaction Pattern
All payment operations use database transactions to ensure atomicity:
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
-- 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:
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):
- Initiate PISP payment at provider API
- User's actual bank account is debited (not Drop's DB record)
- Provider webhook confirms settlement
- 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
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
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
Library: {{cors / @fastify/cors}}
Configuration:
// 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
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.
// 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
Strategy: JWT Bearer token validation
// 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)
Model: {{RBAC (Role-Based) | ABAC (Attribute-Based) | Hybrid}}
// 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
Library: class-validator + class-transformer OR zod
// 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:
{
"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
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:
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
// 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
// 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
Template for new middleware:
// 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
// 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
| 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
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:
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:
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:
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:
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:
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}}
| 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:
// TODO: Add representative request example
Error handling:
// 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
| 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:
// 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
| 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:
// __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
| 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)
Stripe → Alternative Payment Provider
Trigger conditions: Pricing increase > 30%, reliability < 99.9%, compliance issues.
Migration steps:
- Select alternative (Adyen, Braintree, etc.) and obtain test credentials
- Implement new
PaymentServiceadapter behind feature flag - Test in staging with full payment flow
- Migrate new customers to new provider
- Migrate existing subscription customers (requires customer consent in some jurisdictions)
- 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
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
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)
{
"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:
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
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):
{
"$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:
{
"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:
{
"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:
{
"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:
{
"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:
{
"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
{{domain}}.{{entity}}.{{action}}
| Property | Value |
|---|---|
| Publisher | {{service-name}} |
| Consumers | {{consumer-a, consumer-b}} |
| Topic | {{domain.entity.action}} |
| Ordering guarantee | `{{Per entity ID |
| Idempotency key | {{id}} |
Payload schema: TODO: Define JSON Schema
Example event: TODO: Add example
6. Dead Letter Queue Handling
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:
{
"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
Strategy: Backward-compatible field addition + major version in event type.
Rules:
- Adding optional fields: allowed without version bump
- Removing fields: NOT allowed (use deprecation first, remove after all consumers updated)
- Changing field types: NOT allowed (breaking change)
- Adding required fields: requires version bump
- 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
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:
- Identify topic and time range to replay
- Coordinate with all consumer teams (replay may cause duplicate side effects)
- Ensure consumers are idempotent before replay
- Set consumer offset to target timestamp:
kafka-consumer-groups --reset-offsets --to-datetime - Restart consumer with temporary consumer group to avoid affecting production offset
- Verify replayed state is correct
- Switch production consumer to new state
Retention periods by topic: See topic configuration table in Section 2.
9. Monitoring & Observability
| 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
Unit Tests
// 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
// 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
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
| 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
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
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
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:
app.use(helmet({
contentSecurityPolicy: { /* ... */ },
hsts: { maxAge: 31536000, includeSubDomains: true, preload: true },
referrerPolicy: { policy: 'same-origin' },
}));
6. Authentication & Authorization Flow
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
Pattern: {{Repository Pattern}}
// 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 ordataloaderpattern - Raw SQL allowed only when ORM cannot express the query efficiently
8. Caching Architecture
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):
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
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:
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
Storage provider: {{AWS S3 | Cloudflare R2 | MinIO}}
Bucket naming: {{company-project-env}} (e.g., alai-app-production)
Upload flow:
- Client requests pre-signed URL from API (
POST /uploads/presigned) - API validates file type, size, generates pre-signed URL (expiry: 15 min)
- Client uploads directly to storage (bypasses API server)
- Client notifies API of upload completion (
POST /uploads/confirm) - 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
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
Pattern: Typed config module with validation on startup
// 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
| 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:
{
"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
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
| 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
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
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)
// 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:
{
"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
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
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
| 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
| 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
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
| 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
| Metric | Threshold | Alert Severity | Channel |
|---|---|---|---|
| Error rate (5xx) | > 1% for 5 min | P1 | PagerDuty |
| P99 latency | > 1s for 5 min | P2 | Slack #alerts |
| CPU utilization | > 85% for 10 min | P3 | Slack #alerts |
| Memory utilization | > 80% | P3 | Slack #alerts |
| DB connection pool | > 80% | P2 | PagerDuty |
| Queue depth | > 10,000 items | P2 | Slack #alerts |
Dashboard: {{https://monitoring.domain.com/dashboards/service-name}}
11. Runbook Reference
Runbook location: {{https://wiki.domain.com/runbooks/{{service-name}}}}
Quick reference for common incidents:
| Incident | Initial Response |
|---|---|
| High error rate | Check logs → identify error pattern → scale up if OOM |
| High latency | Check DB slow query log → check Redis hit rate → check upstream dependency |
| Pod crash loop | Check OOMKilled → check logs → check health probe thresholds |
| DB connection exhaustion | Check pool config → check idle connections → force disconnect |
Approval
| Role | Name | Date | Signature |
|---|---|---|---|
| Author | |||
| Service Owner | |||
| Architect | |||
| SRE Lead |
Database Schema
Drop Database Schema
Source:
src/shared/db/schema.ts(Drizzle ORM schema — single source of truth for all environments)
Overview
Drop uses PostgreSQL 16 as the sole database engine in all environments (development, CI, staging, production). Database access is via Drizzle ORM. There is no SQLite dependency and no dual-driver abstraction.
- Local dev: PostgreSQL 16 in Docker (
docker compose up -d), port 5433 - CI: PostgreSQL 16 service container in GitHub Actions
- Production: PostgreSQL 16 on AWS RDS (
db.t3.small) - Schema definition:
src/shared/db/schema.ts(Drizzle schema, TypeScript, PostgreSQL-native) - Migrations: managed by
drizzle-kit
See ADR-014 for the full rationale.
Total tables: 19 (12 core + 7 compliance)
Tables
users
Primary user accounts.
| Column | Type | Constraints | Default |
|---|---|---|---|
| id | TEXT | PRIMARY KEY | - |
| TEXT | UNIQUE NOT NULL | - | |
| password_hash | TEXT | NOT NULL | - |
| first_name | TEXT | NOT NULL | - |
| last_name | TEXT | NOT NULL | - |
| phone | TEXT | - | NULL |
| date_of_birth | TEXT | - | NULL |
| kyc_status | TEXT | CHECK('pending','approved','rejected') | 'pending' |
| role | TEXT | CHECK('user','merchant') | 'user' |
| created_at | TEXT | - | CURRENT_TIMESTAMP |
ID format: usr_ + 16 hex chars (generated by randomId("usr") in utils-server.ts:4)
Password hashing: bcrypt with 12 rounds (utils-server.ts:8-11)
recipients
Saved remittance recipients per user.
| Column | Type | Constraints | Default |
|---|---|---|---|
| id | TEXT | PRIMARY KEY | - |
| user_id | TEXT | NOT NULL, FK → users(id) | - |
| name | TEXT | NOT NULL | - |
| country | TEXT | NOT NULL | - |
| currency | TEXT | NOT NULL | - |
| bank_account | TEXT | NOT NULL | - |
| bank_name | TEXT | - | NULL |
| created_at | TEXT | - | CURRENT_TIMESTAMP |
Supported countries: RS, BA, PL, PK, TR (enforced at API level, not DB level)
Index: idx_recipients_user on user_id
merchants
Registered merchant profiles for QR payments.
| Column | Type | Constraints | Default |
|---|---|---|---|
| id | TEXT | PRIMARY KEY | - |
| user_id | TEXT | NOT NULL, FK → users(id) | - |
| business_name | TEXT | NOT NULL | - |
| org_number | TEXT | UNIQUE NOT NULL | - |
| address | TEXT | - | NULL |
| bank_account | TEXT | NOT NULL | - |
| fee_rate | REAL | - | 0.01 |
| status | TEXT | - | 'active' |
| created_at | TEXT | - | CURRENT_TIMESTAMP |
Index: idx_merchants_org on org_number
transactions
All financial transactions (remittances and QR payments).
| Column | Type | Constraints | Default |
|---|---|---|---|
| id | TEXT | PRIMARY KEY | - |
| user_id | TEXT | NOT NULL, FK → users(id) | - |
| type | TEXT | NOT NULL, CHECK('remittance','qr_payment') | - |
| status | TEXT | CHECK('processing','completed','failed') | 'processing' |
| amount | REAL | NOT NULL | - |
| currency | TEXT | - | 'NOK' |
| fee | REAL | - | 0 |
| recipient_id | TEXT | FK → recipients(id) | NULL |
| merchant_id | TEXT | FK → merchants(id) | NULL |
| send_amount | REAL | - | NULL |
| send_currency | TEXT | - | NULL |
| receive_amount | REAL | - | NULL |
| receive_currency | TEXT | - | NULL |
| exchange_rate | REAL | - | NULL |
| created_at | TEXT | - | CURRENT_TIMESTAMP |
| completed_at | TEXT | - | NULL |
Indexes: idx_transactions_user on user_id, idx_transactions_merchant on merchant_id
Notes:
- Remittances have
recipient_idset,merchant_idNULL - QR payments have
merchant_idset,recipient_idNULL send_*/receive_*/exchange_ratefields are populated for remittances only
exchange_rates
Currency exchange rates (from NOK).
| Column | Type | Constraints | Default |
|---|---|---|---|
| id | SERIAL | PRIMARY KEY | AUTOINCREMENT |
| from_currency | TEXT | - | 'NOK' |
| to_currency | TEXT | NOT NULL | - |
| rate | REAL | NOT NULL | - |
| updated_at | TEXT | - | CURRENT_TIMESTAMP |
Seed data (apps/drop-api/src/lib/db.ts):
| Corridor | Rate |
|---|---|
| NOK → RSD | 10.17 |
| NOK → BAM | 0.17 |
| NOK → PLN | 0.374 |
| NOK → PKR | 26.5 |
| NOK → TRY | 3.39 |
| NOK → EUR | 0.087 |
Live/provider refresh details are documented in Currency Rates.
bank_accounts
Linked bank accounts (AISP — Open Banking read in production, mock balances in dev).
Pass-through model: Drop NEVER holds customer money. The
balancecolumn stores the last AISP-read balance from the user's real bank account — it is a cached read-only value, not a Drop-held balance. In dev/demo mode, mock balances are seeded for testing.
| Column | Type | Constraints | Default |
|---|---|---|---|
| id | TEXT | PRIMARY KEY | - |
| user_id | TEXT | NOT NULL, FK → users(id) | - |
| bank_name | TEXT | NOT NULL | - |
| account_number | TEXT | NOT NULL | - |
| iban | TEXT | - | NULL |
| balance | REAL | - | 0 |
| currency | TEXT | - | 'NOK' |
| is_primary | INTEGER | - | 0 |
| connected_at | TEXT | - | CURRENT_TIMESTAMP |
Index: idx_bank_accounts_user on user_id
cards (FUTURE — feature-flagged)
Note: Cards are a FUTURE feature, gated behind feature flags (all default to
false). This table exists in the schema but is not actively used until a card issuing partner is integrated.
Virtual and physical payment cards.
| Column | Type | Constraints | Default |
|---|---|---|---|
| id | TEXT | PRIMARY KEY | - |
| user_id | TEXT | NOT NULL, FK → users(id) | - |
| type | TEXT | CHECK('virtual','physical') | 'virtual' |
| last_four | TEXT | NOT NULL | - |
| token_ref | TEXT | - | NULL |
| expiry | TEXT | NOT NULL | - |
| status | TEXT | CHECK('active','frozen','cancelled') | 'active' |
| shipping_address | TEXT | - | NULL |
| created_at | TEXT | - | CURRENT_TIMESTAMP |
| pin_hash | TEXT | - | NULL (added via runtime migration in cards/[id]/pin/route.ts:51-53) |
Index: idx_cards_user on user_id
sessions
JWT session tracking for revocation support.
| Column | Type | Constraints | Default |
|---|---|---|---|
| id | TEXT | PRIMARY KEY | - |
| user_id | TEXT | NOT NULL, FK → users(id) | - |
| token_hash | TEXT | NOT NULL | - |
| created_at | TEXT | - | CURRENT_TIMESTAMP |
| expires_at | TEXT | NOT NULL | - |
| revoked | INTEGER | - | 0 |
Indexes: idx_sessions_user on user_id, idx_sessions_token on token_hash
Token hash: SHA-256 of the JWT string (auth.ts:59)
notifications
In-app notifications.
| Column | Type | Constraints | Default |
|---|---|---|---|
| id | TEXT | PRIMARY KEY | - |
| user_id | TEXT | NOT NULL, FK → users(id) | - |
| type | TEXT | NOT NULL | - |
| title | TEXT | NOT NULL | - |
| body | TEXT | NOT NULL | - |
| read | INTEGER | - | 0 |
| created_at | TEXT | - | CURRENT_TIMESTAMP |
Index: idx_notifications_user on user_id
settings
Per-user preferences.
| Column | Type | Constraints | Default |
|---|---|---|---|
| user_id | TEXT | PRIMARY KEY, FK → users(id) | - |
| currency | TEXT | - | 'NOK' |
| language | TEXT | - | 'nb' |
| push_enabled | INTEGER | - | 1 |
| email_enabled | INTEGER | - | 1 |
| updated_at | TEXT | - | CURRENT_TIMESTAMP |
spending_limits (FUTURE — feature-flagged)
Note: Tied to the cards feature. Only active when card feature flags are enabled.
Card spending limits.
| Column | Type | Constraints | Default |
|---|---|---|---|
| id | TEXT | PRIMARY KEY | - |
| user_id | TEXT | NOT NULL, FK → users(id) | - |
| card_id | TEXT | FK → cards(id) | NULL |
| limit_type | TEXT | NOT NULL | - |
| amount | REAL | NOT NULL | - |
| currency | TEXT | - | 'NOK' |
| created_at | TEXT | - | CURRENT_TIMESTAMP |
Indexes: idx_spending_limits_user on user_id, idx_spending_limits_card on card_id
Limit types (API-enforced): daily, weekly, monthly, transaction
rate_limits
Persistent rate limiting store.
| Column | Type | Constraints | Default |
|---|---|---|---|
| key | TEXT | PRIMARY KEY | - |
| count | INTEGER | NOT NULL | - |
| reset_at | INTEGER | NOT NULL | - |
Used by middleware.ts:rateLimit() for IP-based rate limiting. Expired entries are cleaned on each call (middleware.ts:11).
Compliance & GDPR Tables
Added: 2026-02-16 (compliance infrastructure)
These tables support Drop's compliance requirements for Norwegian financial services regulation, GDPR, and AML/KYC requirements per hvitvaskingsloven.
audit_log
User action audit trail for compliance and security monitoring.
| Column | Type | Constraints | Default |
|---|---|---|---|
| id | TEXT | PRIMARY KEY | - |
| timestamp | TEXT | - | CURRENT_TIMESTAMP |
| user_id | TEXT | FK → users(id) | NULL |
| action | TEXT | NOT NULL | - |
| resource_type | TEXT | - | NULL |
| resource_id | TEXT | - | NULL |
| details | TEXT | - | NULL |
| ip_address | TEXT | - | NULL |
| user_agent | TEXT | - | NULL |
Indexes: idx_audit_log_user on user_id, idx_audit_log_timestamp on timestamp, idx_audit_log_action on action
Purpose: Tracks all significant user actions (login, transaction, settings change, etc.) for audit purposes.
aml_alerts
AML (Anti-Money Laundering) transaction monitoring alerts.
| Column | Type | Constraints | Default |
|---|---|---|---|
| id | TEXT | PRIMARY KEY | - |
| user_id | TEXT | NOT NULL, FK → users(id) | - |
| alert_type | TEXT | NOT NULL | - |
| severity | TEXT | NOT NULL, CHECK('low','medium','high','critical') | - |
| transaction_id | TEXT | FK → transactions(id) | NULL |
| details | TEXT | - | NULL |
| status | TEXT | CHECK('open','investigating','resolved','escalated','filed') | 'open' |
| reviewed_by | TEXT | - | NULL |
| reviewed_at | TEXT | - | NULL |
| created_at | TEXT | - | CURRENT_TIMESTAMP |
Indexes: idx_aml_alerts_user on user_id, idx_aml_alerts_status on status
Purpose: Records suspicious transaction patterns flagged by AML monitoring rules (e.g., structuring, velocity, high-risk corridors).
str_reports
STR (Suspicious Transaction Reports) filed with financial authorities.
| Column | Type | Constraints | Default |
|---|---|---|---|
| id | TEXT | PRIMARY KEY | - |
| user_id | TEXT | NOT NULL, FK → users(id) | - |
| alert_id | TEXT | FK → aml_alerts(id) | NULL |
| report_type | TEXT | NOT NULL | - |
| status | TEXT | CHECK('draft','submitted','acknowledged') | 'draft' |
| filed_at | TEXT | - | NULL |
| reference_number | TEXT | - | NULL |
| details | TEXT | - | NULL |
| created_at | TEXT | - | CURRENT_TIMESTAMP |
Purpose: Tracks STRs filed with Økokrim/EFE (Norwegian financial intelligence unit) per hvitvaskingsloven requirements.
screening_results
Results from sanctions/PEP (Politically Exposed Persons) screening.
| Column | Type | Constraints | Default |
|---|---|---|---|
| id | TEXT | PRIMARY KEY | - |
| user_id | TEXT | NOT NULL, FK → users(id) | - |
| screening_type | TEXT | NOT NULL, CHECK('pep','sanctions','adverse_media') | - |
| provider | TEXT | - | NULL |
| result | TEXT | NOT NULL, CHECK('clear','match','potential_match','error') | - |
| match_details | TEXT | - | NULL |
| screened_at | TEXT | - | CURRENT_TIMESTAMP |
Indexes: idx_screening_user on user_id
Purpose: Stores results from automated screening against PEP lists, sanctions lists (OFAC, UN, EU), and adverse media databases.
consents
GDPR consent tracking for user data processing.
| Column | Type | Constraints | Default |
|---|---|---|---|
| id | TEXT | PRIMARY KEY | - |
| user_id | TEXT | NOT NULL, FK → users(id) | - |
| consent_type | TEXT | NOT NULL | - |
| granted | INTEGER | NOT NULL | 1 |
| granted_at | TEXT | - | CURRENT_TIMESTAMP |
| withdrawn_at | TEXT | - | NULL |
| ip_address | TEXT | - | NULL |
Indexes: idx_consents_user on user_id
Consent Types (API-enforced): terms, privacy, marketing, cookies_analytics, cookies_marketing
Purpose: Tracks when users grant or withdraw consent for different types of data processing, with IP address as proof of consent action.
data_access_requests
GDPR data access/erasure/rectification requests (Art. 15-17).
| Column | Type | Constraints | Default |
|---|---|---|---|
| id | TEXT | PRIMARY KEY | - |
| user_id | TEXT | NOT NULL, FK → users(id) | - |
| request_type | TEXT | NOT NULL, CHECK('export','erasure','rectification','restriction') | - |
| status | TEXT | CHECK('pending','processing','completed','rejected') | 'pending' |
| requested_at | TEXT | - | CURRENT_TIMESTAMP |
| completed_at | TEXT | - | NULL |
| download_url | TEXT | - | NULL |
| notes | TEXT | - | NULL |
Indexes: idx_data_requests_user on user_id
Purpose: Tracks GDPR data subject access requests. export requests generate full data export, erasure triggers account deletion.
complaints
Customer complaints per Finansavtaleloven §3-53 (15-day response requirement).
| Column | Type | Constraints | Default |
|---|---|---|---|
| id | TEXT | PRIMARY KEY | - |
| user_id | TEXT | NOT NULL, FK → users(id) | - |
| category | TEXT | NOT NULL | - |
| subject | TEXT | NOT NULL | - |
| description | TEXT | NOT NULL | - |
| status | TEXT | CHECK('received','investigating','resolved','escalated') | 'received' |
| resolution | TEXT | - | NULL |
| created_at | TEXT | - | CURRENT_TIMESTAMP |
| resolved_at | TEXT | - | NULL |
Indexes: idx_complaints_user on user_id, idx_complaints_status on status
Categories (API-enforced): transaction, service, fees, privacy, technical, other
Purpose: Formal complaint logging system to ensure compliance with Norwegian financial services law requiring 15 business day response time.
Database Access Layer
Source: db.ts
Data Access Layer
The database access layer is Drizzle ORM (src/shared/db/schema.ts). The old db.ts
dual-driver abstraction has been removed (see ADR-014).
Use Drizzle query builder or the sql template tag for raw queries:
import { db } from '@drop/shared/db'
import { users } from '@drop/shared/db/schema'
import { eq } from 'drizzle-orm'
// Type-safe query
const user = await db.select().from(users).where(eq(users.id, userId)).limit(1)
// Raw SQL escape hatch (PostgreSQL syntax, $1 params not needed — Drizzle handles binding)
import { sql } from 'drizzle-orm'
const result = await db.execute(sql`SELECT id FROM users WHERE email = ${email}`)
Migrations are managed by drizzle-kit:
cd src/shared && npx drizzle-kit generate # Generate migration file
cd src/shared && npx drizzle-kit push # Push schema to dev database
make db-push # Shortcut (from repo root)
Seed Data
When exchange_rates table is empty, seedData() (db.ts:530) populates:
- 6 exchange rate corridors (NOK → RSD, BAM, PLN, PKR, TRY, EUR)
- Demo data (when
NODE_ENV !== "production"orSEED_DEMO=true):- 1 demo user (
usr_demo1, amir@example.com, role: merchant) - 3 recipients (Serbia, Bosnia, Turkey)
- 1 merchant (Ahmetov Kebab)
- 3 transactions (2 remittances, 1 QR payment)
- 2 bank accounts (DNB primary with 45,230 NOK, SpareBank 1 with 12,800 NOK)
- 1 demo user (
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-XXXXerror 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:
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:
// 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 |
{
"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 |
{
"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 |
{
"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 |
{
"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 |
{
"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 |
{
"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) |
{
"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 |
{
"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 |
{
"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 |
{
"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 |
{
"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 |
{
"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) |
{
"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 |
{
"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 |
{
"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 |
{
"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 |
{
"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 |
{
"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 |
{
"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 |
{
"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 |
{
"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 |
{
"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 |
{
"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)sentorviewed→paid(action:mark-paid)- Any non-cancelled →
cancelled(action:cancel)
{
"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 |
{
"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 |
{
"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 |
{
"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 |
{
"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 |
{
"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) |
{
"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 |
{
"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 |
{
"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 |
{
"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 |
{
"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 |
{
"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 |
{
"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 |
{
"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 |
{
"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 |
{
"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 |
{
"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 |
{
"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 |
{
"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 |
{
"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 |
{
"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 |
{
"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 |
{
"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 |
{
"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 |
{
"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 |
{
"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 |
{
"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 |
{
"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 |
{
"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 |
{
"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 |
{
"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 |
{
"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 |
{
"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 |
{
"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 |
{
"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 |
{
"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 |
{
"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 |
{
"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 |
{
"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 |
{
"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 |
{
"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 |
{
"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 |
{
"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 |
{
"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 |
{
"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 |
{
"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 |
{
"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.
// 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."
}
// 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+roleGuardmiddleware,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):
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
viewercan do, anaccountantcan also do - Everything an
accountantcan do, anadmincan also do - Everything an
admincan do, anownercan 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:
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.
// 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.
// 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,ContactInvoice,InvoiceItem,Expense,TransactionBankAccount
Global models (not org-scoped, shared across all organizations):
Currency,ExchangeRate,AccountType— read-only reference dataSchemaVersion— 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) admincan invite up toadminrole (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
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
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
// src/middleware/auth.middleware.ts
type UserRole = 'owner' | 'admin' | 'accountant' | 'viewer'
// Step 1: Verify JWT and attach user to request
async function authGuard(req: AuthRequest, res: Response, next: NextFunction) {
const authHeader = req.headers.authorization
if (!authHeader?.startsWith('Bearer ')) {
return res.status(401).json({ error: { code: 'BILKO-1005', message: 'Authentication is required.' } })
}
const token = authHeader.substring(7)
try {
const payload = jwt.verify(token, process.env.JWT_SECRET!) as AccessTokenPayload
req.user = {
id: payload.sub,
email: payload.email,
role: payload.role,
organizationId: payload.orgId,
}
next()
} catch (error) {
if (error.name === 'TokenExpiredError') {
return res.status(401).json({ error: { code: 'BILKO-1003', message: 'Session expired. Please refresh your token.' } })
}
return res.status(401).json({ error: { code: 'BILKO-1004', message: 'Invalid token. Please log in again.' } })
}
}
// Step 2: Check role authorization
function roleGuard(allowedRoles: UserRole[]) {
return (req: AuthRequest, res: Response, next: NextFunction) => {
if (!req.user) {
return res.status(401).json({ error: { code: 'BILKO-1005', message: 'Authentication is required.' } })
}
if (!allowedRoles.includes(req.user.role)) {
return res.status(403).json({
error: {
code: 'BILKO-9001',
message: 'You do not have permission to perform this action.',
details: {
required: allowedRoles,
current: [req.user.role],
},
},
})
}
next()
}
}
// Step 3: Apply organization scope
function organizationScope(req: AuthRequest, res: Response, next: NextFunction) {
req.organizationId = req.user!.organizationId
next()
}
// Convenience: all authenticated roles
const allRoles: UserRole[] = ['owner', 'admin', 'accountant', 'viewer']
// Usage in routes:
router.post('/invoices',
authGuard,
roleGuard(['owner', 'admin', 'accountant']),
organizationScope,
validate(CreateInvoiceSchema),
invoicesController.create
)
router.get('/invoices',
authGuard,
roleGuard(allRoles),
organizationScope,
invoicesController.list
)
router.patch('/expenses/:id/approve',
authGuard,
roleGuard(['owner', 'admin']),
organizationScope,
expensesController.approve
)
Summary Table
| Capability | owner | admin | accountant | viewer |
|---|---|---|---|---|
| Invoices | ||||
| View invoices | ✅ | ✅ | ✅ | ✅ |
| Create / edit invoice | ✅ | ✅ | ✅ | ❌ |
| Send invoice | ✅ | ✅ | ✅ | ❌ |
| Mark invoice paid / cancel | ✅ | ✅ | ✅ | ❌ |
| Expenses | ||||
| View expenses | ✅ | ✅ | ✅ | ✅ |
| Create / edit expense | ✅ | ✅ | ✅ | ❌ |
| Approve expense | ✅ | ✅ | ❌ | ❌ |
| Delete expense | ✅ | ✅ | ❌ | ❌ |
| Contacts | ||||
| View contacts | ✅ | ✅ | ✅ | ✅ |
| Create / edit contact | ✅ | ✅ | ✅ | ❌ |
| Deactivate contact | ✅ | ✅ | ❌ | ❌ |
| Banking | ||||
| View bank accounts & transactions | ✅ | ✅ | ✅ | ✅ |
| Create bank account | ✅ | ✅ | ❌ | ❌ |
| Import CSV / reconcile | ✅ | ✅ | ✅ | ❌ |
| GL Transactions | ||||
| View transactions | ✅ | ✅ | ✅ | ✅ |
| Create manual journal entry | ✅ | ✅ | ✅ | ❌ |
| Reports | ||||
| View all reports | ✅ | ✅ | ✅ | ✅ |
| Chart of Accounts | ||||
| View accounts | ✅ | ✅ | ✅ | ✅ |
| Create / edit / deactivate accounts | ✅ | ✅ | ❌ | ❌ |
| Settings | ||||
| View tax rates | ✅ | ✅ | ✅ | ✅ |
| Update tax rates | ✅ | ✅ | ❌ | ❌ |
| Update org details | ✅ | ✅ | ❌ | ❌ |
| Users | ||||
| View user list | ✅ | ✅ | ❌ | ❌ |
| Invite user | ✅ | ✅ | ❌ | ❌ |
| Change user role | ✅ | ❌ | ❌ | ❌ |
| Remove user | ✅ | ❌ | ❌ | ❌ |
| Organization | ||||
| Delete organization | ✅ | ❌ | ❌ | ❌ |
End of Roles and Permissions Documentation
Account Lockout (Brute-Force zaštita) — A12
Bilko — Account Lockout (Brute-Force zaštita) — A12 / MC #104687
Status: implementirano 2026-07-03, commit 13b3a729 na feat/task-104687 (Wave 1 — čeka merge u dev)
Vlasnik: CodeCraft backend · Proveo PASS (nezavisna verifikacija svih 5 AC)
Ponašanje
- Nakon 5 uzastopnih neuspješnih email/password login pokušaja, nalog se zaključava 15 minuta.
- 6. pokušaj vraća HTTP 423 (
BILKO-AUTH-005) čak i s tačnom šifrom — provjera zaključanosti ide PRIJE verifikacije šifre. - Uspješan login resetuje brojač na 0 (legitimni korisnici s povremenim tipfelerima se ne zaključavaju).
- Istekli
locked_untilse auto-čisti prije provjere šifre (cooldown se sam otključa). - Admin unlock:
PUT /api/v1/users/{id}/unlock(permisijausers:manage, org-scoped — cross-org pokušaj vraća 404). - Entra-only korisnici (
passwordHash = NULL, V66) nikad ne dotiču lockout logiku; Entra SSO put (markEntraLogin) ne konsultuje lockout.
Implementacija
| Sloj | Šta |
|---|---|
| Migracija | V108__account_lockout.sql — users.failed_login_attempts INT NOT NULL DEFAULT 0, users.locked_until TIMESTAMPTZ NULL (aditivno) + drop/recreate 3 bilko_auth.* SECURITY DEFINER lookup funkcije (RETURNS TABLE promjena; OWNER/GRANT identični V31/V66 originalima — diffano) |
| Servis | AuthService.kt — lock-check prije verifyPassword; brojač = jedan atomski parametrizovani UPDATE (nema izgubljenih inkremenata pod konkurencijom); adminUnlock(orgId, userId) |
| HTTP | LockedException → BILKO-AUTH-005 (423) u StatusPages.kt; ruta unlock u UserManagementRoutes.kt; dokumentovano u ERROR-CODES.md + openapi.yaml |
| Testovi | AuthServiceLockoutTest.kt — 5 scenarija (6. pokušaj s tačnom šifrom → 423; reset na uspjeh; cooldown expiry; admin unlock; cross-org 404). Izvršeno 5/5 + regresija AuthServiceTest 31/31, skipped=0 (XML ground truth) |
Bitno: javna login ruta je 410
POST /api/v1/auth/login vraća 410 Gone platformski (WP4/MC #103144 — Bilko je Entra-only, password login penzionisan). Lockout je time defense-in-depth: javna brute-force površina je ZATVORENA (Proveo live probe: 7×410 na api.bilko.cloud), a lockout se automatski primjenjuje ako se ruta ikad reaktivira. Odluka: rutu NE reaktivirati radi live-423 demonstracije.
Poznati follow-up
bilko_auth.find_user_by_email/find_user_by_idi dalje nemajuSET search_path = public, pg_temp(naslijeđeno iz V31, nije uvedeno ovim taskom) — hardening ticket.
Evidence
/Users/makinja/system/evidence/104687/build-report-20260703.md/Users/makinja/system/evidence/104687/proveo-verdict-20260703.md- P2P mesh:
mesh-thr-357b1180(builder) →mesh-thr-516f2df0/mesh-msg-ba98cc59(Proveo PASS)
Ops napomene (otkriveno tokom taska)
- Gitleaks pre-commit je gađao
../../.gitleaks.toml— rušio SVAKI commit iz nested.claude/worktrees/*(fail-closed "Secrets detected" bez skena). Fixano u.husky/pre-commit(commit13b3a729): config je tracked na repo root-u →--show-toplevel/.gitleaks.toml. - Testcontainers silent-skip:
~/.testcontainers.propertiesje pokazivao na legacydocker.raw.sockkoji Ryuk ne može bind-mountati na aktuelnom Docker Desktopu → SVI integration testovi tiho SKIPPED uz "BUILD SUCCESSFUL". Fix:unix:///var/run/docker.sock+ryuk.disabled=true. Lekcija: uvijek čitatitests=/skipped=iz testsuite XML-a, ne vjerovati zelenom buildu.
KPO Export RBAC Fix — MC #105326
KPO Export RBAC Fix — MC #105326
Finding
Proveo adversarial verify on MC #105321 (2026-07-12) found that GET /reports/kpo/export/pdf
and GET /reports/kpo/export/xlsx in ReportRoutes.kt were missing the
requirePermission(principal, "report:export") guard that every other export route
(KIR, KPR, VAT, P&L, balance-sheet, accountant CSV package) already has. Git diff confirmed
the gap pre-dated the T1b diff — it was a pre-existing asymmetry, not a regression.
Fix
Added if (requirePermission(principal, "report:export")) return@get to both KPO export
routes in apps/api/src/main/kotlin/no/alai/bilko/routes/ReportRoutes.kt, matching the exact
pattern used at the other 11 export-route call sites in the same file (13 total after the fix).
Tests
Added 4 integration tests to apps/api/src/test/kotlin/no/alai/bilko/routes/ReportRoutesHttpIntegrationTest.kt:
GET reports kpo export pdf rejects viewer role with 403GET reports kpo export xlsx rejects viewer role with 403GET reports kpo export pdf allows owner role(passes RBAC, hits NotImplemented stub per MC #100038)GET reports kpo export xlsx allows owner role
Verified: ./gradlew integrationTest --tests "no.alai.bilko.routes.ReportRoutesHttpIntegrationTest" --rerun
→ BUILD SUCCESSFUL, 0 failures / 0 errors.
P2P Peer Verify
Proveo independent pre-verifier: PASS.
mesh-thr-d41602c0-65fa-4d4c-bca4-8fe770f11704 / mesh-msg-e3c3869f-f160-4e65-a9d0-8ba27f000686.
Transcript: /tmp/alai/p2p-pairing-evidence/mc105326-transcript.md.
Bilko — Payment-event obaveze (JOPPD): dizajn, scope odluke, runbook — MC #105742
Bilko — Payment-event obaveze (JOPPD): dizajn, scope odluke, runbook
MC: #105671 (dizajn) + #105741 (Task A) + #105742 (Task B) + #105744 (Finverge BA verifikacija) + #105747 (RS redizajn, otvoreno)
Status (2026-07-15): Task A merged (PR 164, b1417252, azdo/main). Task B PR 165 open (6b9ebe5c), NOT merged — Proveo P2P adversarial verify još u toku (mesh thread otvoren, guard-rail auto-BLOCKED nije pravi verdikt). HR-07/RS-07 katalog i dalje enabled: false.
1. Šta feature radi
Payment-event obaveza je Bilko-ova podsjetnik logika koja se okida na stvarnu isplatu plaće, ne na kalendarski datum kao postojeći mjesečni podsjetnici (npr. MIP-1023, Obrazac 1002).
Tok (HR, implementirano):
POST /payroll/organizations/{orgId}/payslip-runs/mark-paid
{ periodYear, periodMonth, paymentDate }
│ (V123: payslips.payment_date, SERIALIZABLE transakcija)
▼
PayrollService.markPayrollRunPaid()
│ postavlja payment_date na SVE payslip redove org/period
│ (ista transakcija, samo na write-grani, nikad na idempotent no-op)
▼
PaymentEventObligationService.onPayrollRunPaid(orgId, periodYear, periodMonth, paymentDate)
│ HR org → čita HR-07 (JOPPD) iz kataloga SAMO za title/form metadata
│ due_date = resolveNextBusinessDay(paymentDate)
│ RS/BA org → no-op (vidi §2b, §2c)
▼
upsert ComplianceDeadlines (keyed: org, year, deadlineType, period)
│ korekcija datuma → UPDATE istog reda, ne duplikat
▼
notifyIfNeeded() — JEDNA in-app + email notifikacija na kreiranje, VAN transakcije
(bez T-7/T-1 kadence — nema lead-time koncepta, rok = dan isplate)
resolveNextBusinessDay(date) — čista funkcija, semantika je roll-forward SAMO ako datum pada na neradni dan, ne "uvijek sljedeći radni dan":
- Pon–Pet → passthrough (utorak isplata ostaje utorak rok)
- Subota → ponedjeljak (+2)
- Nedjelja → ponedjeljak (+1)
Ovo je vikend-only MVP (bez praznik-tabele — vidi §2d).
2. Scope odluke s razlozima (ključno za budućnost)
(a) BA_FED (FBiH) i BA_RS isključeni iz payment-event tipa — Finverge verifikacija #105744, verdikt NE
MIP-1023 (FBiH) i Obrazac 1002 (RS-BiH) su fiksni mjesečni agregatni izvještaji, ne payment-event:
- MIP-1023: rok = 15. u mjesecu za prethodni mjesec (čl. 31 st. 5 Pravilnika o primjeni Zakona o porezu na dohodak FBiH), period-tag se odnosi na obračunski mjesec, ne na mjesec isplate.
- Obrazac 1002: rok = 10. u mjesecu za sve isplate iz prethodnog mjeseca (Zakon o poreskom postupku RS).
Oba entiteta su konfirmisana sa 7+ (FBiH) i 6+ (RS-BiH) nezavisnih izvora (FEB, paragraf.ba, advokat-prnjavorac.com, fineks.ba, poreskaupravars.org, vladars.rs). Već su ispravno modelovani u postojećem fiksno-mjesečnom katalogu (PR 157, #105641, CEO-odobreno #105640) — Task B ih namjerno NE dira. Ne dodavati BA_FED/BA_RS u payment-event tip triger.
(b) RS (Srbija) isključena iz Task B builda — red-zone FAIL, #105742 redzone-tax-verdict.md
Red-zone porezna verifikacija (persona porez-hr-105742, web-verified 5+ nezavisnih izvora: porezionline.rs, aktivasistem.com, paragraf.rs, purs.gov.rs) je dala FAIL verdikt na originalni HR+RS dizajn:
- PPP-PD (RS Srbija) se podnosi PRIJE isplate, ne poslije. Mehanizam: prijava → PURS kontrola → BOP broj (Broj Odobrenja za Plaćanje) → BOP se unosi u nalog za prenos → tek onda isplata.
- Mark-paid-poslije-isplate model (kao HR-07) je konceptualno pogrešan za RS: ako je korisnik već kliknuo "isplaćeno" bez prethodno podnesene PPP-PD/BOP-a, klijent je već u prekršaju — podsjetnik koji stigne nakon toga je zakašnjeo po definiciji, ne samo "kasni".
- Dodatno flagovano: repo dokument
PAYROLL-PHASE2-PLAN.md§2b sadrži interno neusklađen opis roka ("15th of month following payment") koji je u sukobu i sa web-verified pravilom i sa dizajn-katalogom RS-07 (PER_PAYMENT_EVENT). Fix isporučen odvojeno u #105747 — nije popravljeno u Task B.
Posljedica za build: Task B je implementiran HR-only. RS org u onPayrollRunPaid() je dokumentovan no-op — nema deadline red, nema notifikaciju, log linija referencira #105747. RS-07 katalog entry ostaje netaknut (enabled: false).
(c) HR-07/RS-07 katalog enabled: false do praznik-tabele (#105743, Lexicon gate)
ObligationEngine.generateDeadlines() i dalje ne emituje HR-07/RS-07 (potvrđeno testom ObligationEngineUnaffectedTest — 3/3 PASS, uključujući provjeru protiv REALNOG učitanog kataloga). Payment-event insertion je potpuno odvojen kod-put od kalendarskog generatora — katalog služi samo kao metadata SSOT (title/form), ne kao evaluator za ovaj tip roka. enabled: true čeka:
- Praznik-tabela (#105743, MVP je trenutno vikend-only)
- Lexicon sign-off na
requires_expert_validationkonvenciju (isti obrazac kao postojeći RS-07 flag)
(d) Vikend-only MVP — praznici su Task C (odvojen, ne blokira A/B)
Dizajn (#105671 §4) eksplicitno je priznao ovaj gap kao MVP-known-limitation, ne skriveni nedostatak: puni praznik-kalendar zahtijeva holidays referentnu tabelu po jurisdikciji/godini + Lexicon/tax-expert sign-off (Zakon o blagdanima HR vs Zakon o praznicima RS se razlikuju). Cost/risk ako se ship-a bez praznika: pogrešan rok kad je isplata 1-2 radna dana prije praznika (npr. dan prije Uskrsnog ponedjeljka — vikend-only logika kaže "sljedeći dan" = sam praznik, što je netačno). Task C nosi ovu tabelu odvojeno, ne blokira A/B ship.
Bitna napomena (red-zone stavka 1): AC1 pseudokod (Mon-Fri passthrough, Sat/Sun roll-forward) je ispravan izvor istine za JOPPD pravilo. Prozna formulacija u originalnom dizajn-sažetku ("next business day after payment date") je dvosmislena i može zavesti na pogrešnu "uvijek +1" implementaciju — kod treba pratiti AC1, ne prozni opis.
3. Tehnika
SERIALIZABLE transakcija (Proveo P2P race nalaz + fix)
Proveo adversarial P2P review na #105741 (proveo-angie-105741, verdikt PASS s jednim MEDIUM nalazom) je našao da markPayrollRunPaid() radi read-then-conditionally-write pod default TRANSACTION_READ_COMMITTED izolacijom, bez SELECT...FOR UPDATE i bez SERIALIZABLE. Konkretan race: dva konkurentna mark-paid poziva za isti org+period sa RAZLIČITIM datumima mogu oba pročitati pre-write stanje, oba računati changed=true, i kasniji UPDATE tiho pobjeđuje (lost update) — "gubitnik" dobija 200/changed=true iako je njegov upis pregažen.
Fix (commit 3aa0ece2): markPayrollRunPaid() sada koristi
orgTransaction(
organizationId = organizationId,
transactionIsolation = java.sql.Connection.TRANSACTION_SERIALIZABLE,
) { ... }
1:1 kopija postojećeg repo presedana za isti read-then-derive-then-write oblik (ExpenseService.kt:267-269, InvoiceService.kt:661-664 — broj-generacija). Bez retry-on-serialization-failure wrappera (repo-wide grep potvrdio: nijedan presedan ga nema).
Novi konkurentni test (2 real threads, CountDownLatch, isti pattern kao FiscalDeviceSequenceTest): provjerava da nakon dva istovremena poziva sa različitim datumima svi payslip redovi u run-u nose ISTI konačni payment_date — nema split-state. 7/7 PASS, ponovljeno 3x, 0 flake.
Upsert keying
ComplianceDeadlines upsert je keyed na (organizationId, year, deadlineType, period) — korekcija datuma (npr. payroll admin ispravi pogrešnu isplatu) ažurira POSTOJEĆI red umjesto da duplira. Potvrđeno testom "correction updates the existing deadline row instead of duplicating" (assertion na isti deadline id kroz korekciju).
Notifikacija van transakcije
notifyIfNeeded() se zove IZ route handler-a, NAKON što dbQuery{} vrati rezultat — izvan markPayrollRunPaid()-ove SERIALIZABLE transakcije. Fires samo ako result.obligationResult nije null (tj. nikad na idempotent no-op granu).
RS/BA no-op s testovima
onPayrollRunPaid() grana na country != HR → no-op, pokriveno testovima "RS org mark-paid does not create any compliance deadline" i "does not fire any notification". Isti no-op put pokriva i BA_FED/BA_RS (§2a).
Migracija
Task A: V123__payslip_payment_date.sql — ALTER TABLE payslips ADD COLUMN IF NOT EXISTS payment_date DATE NULL + indeks na (organization_id, period_year, period_month, payment_date). Flyway-replay test (V121 lekcija): migracija privremeno uklonjena → test PADA (3/3 FAILED), vraćena → PASS — dokazuje da test stvarno testira migraciju, ne samo Kotlin model.
Task B: Nema nove migracije — ComplianceDeadlines (V9) je već imala sve potrebne kolone, potvrđeno čitanjem postojeće Exposed definicije prije pisanja koda.
PR / commit trag
| Task | Repo | PR | Commit | Status |
|---|---|---|---|---|
| A (#105741) | Bilko | PR 164 | d7d27d0c + fix 3aa0ece2 |
merged (b1417252) |
| B (#105742) | Bilko | PR 165 | 6b9ebe5c |
open, adversarial verify u toku |
4. Runbook / gotchas
Kako se aktivira za prave klijente
- #105743 — sagraditi
holidaysreferentnu tabelu (jurisdikcija, datum, naziv), seed za HR + RS za tekuću + narednu godinu. resolveNextBusinessDay()proširiti da konsultuje tabelu kad postoji za org-ovu jurisdikciju, fallback na vikend-only ako nema podataka za tu godinu (isti defanzivni pattern kaoOrgComplianceProfile.default()).- Lexicon sign-off zabilježen po
requires_expert_validationkonvenciji — TEK ONDA flipenabled: truena HR-07 (i RS-07 nakon #105747 redizajna, vidi ispod). - Bez ovog koraka HR-07/RS-07 ostaju
enabled: falseu katalogu — payment-event insertion put radi nezavisno od tog flaga (flag utiče samo naObligationEngine-ov kalendarski generator, ne naPaymentEventObligationService), ali production-ready gate je i dalje ovaj sign-off.
RS (Srbija) redizajn — #105747, otvoreno
RS-07 (PPP-PD) NE smije se implementirati po istom "mark-paid → deadline poslije → jedna notifikacija" šablonu kao HR-07. Minimalne opcije za redizajn (iz red-zone verdikta):
- UX kao "upozorenje PRIJE potvrde isplate — PPP-PD mora biti podnesena i BOP dobijen prije nego označite kao isplaćeno", ili
- Ako MVP ne može modelovati prijava-prije-isplate tok, notifikacioni tekst mora eksplicitno reći da rok prethodi isplati, ne "X dana poslije".
Ne kopirati HR-07 resolveNextBusinessDay(paymentDate) šablon direktno na RS-07 bez ove korekcije.
PAYROLL-PHASE2-PLAN.md §2b — zastario opis
Repo dokument docs/regulatory/PAYROLL-PHASE2-PLAN.md §2b sadrži netačan/zastario opis PPP-PD roka ("Due by the 15th of the month following payment") koji je u sukobu i sa web-verified pravilom i sa RS-07 katalog tipom (PER_PAYMENT_EVENT). Isti tekst identifikovan identičan u više worktree kopija (angie-105568, codecraft-105355, codecraft-105193/4, codecraft-105192, proveo-105276, codecraft-105687). Fix pripada #105747 — sinhronizovati sve kopije nakon RS redizajna, ne prije.
Prije mc.js ready/done na #105742
- PR 165 mora biti merged (trenutno open).
- Proveo adversarial P2P verdikt na #105742 mora doći kroz stvarni verifier, ne mesh guard-rail auto-BLOCKED poruku (poznat lažni pozitiv, viđen i na #105741 i #105742 threadovima —
evalagent eksplicitno kaže da nije pravi verifier response). - #105747 (RS redizajn) je odvojen task, ne blokira HR-only #105742 merge.
Izvori (evidence)
~/system/evidence/105671/design-proposal.md— Petter Graff arhitekturni dizajn, MC #105671~/system/evidence/105741/build-evidence-2026-07-15.md— Task A build (codecraft-hadi-105741), SERIALIZABLE fix~/system/evidence/105741/proveo-p2p-verdict.md— Proveo adversarial P2P (proveo-angie-105741), PASS + MEDIUM race nalaz~/system/evidence/105742/build-evidence-2026-07-15.md— Task B build (codecraft-hadi-105742), HR-only scope~/system/evidence/105742/redzone-tax-verdict.md— red-zone porezna verifikacija (porez-hr-105742), FAIL na RS~/system/evidence/105744/finverge-ba-payment-event.md— Finverge BA verifikacija (finverge-105744), NE za BA_FED/BA_RS
Bilko — Financial Audit Trail & Retention Architecture
Bilko — Financial Audit Trail & Retention Architecture
Parent: MC #105872 · Plan: ~/.claude/plans/greedy-greeting-flame.md (CEO-approved 2026-07-17) · Design team: Petter Graff (lead), Martin Kleppmann (event-log semantics), Bruce Momjian (Postgres)
CEO mandate (2026-07-17): Bilko is serious accounting software — every mutation of a financial record must leave a trace ("if someone deletes a file and claims we lost their data, we must be able to prove what happened"). Files themselves do not have to be kept once their legal retention window expires, but the trace of what happened must survive forever.
Status at time of writing (2026-07-17)
Phase A (foundation) is merged to azdo/main via Azure DevOps PR #178 (commit c44ee7c9, squash-merge of the full A2–A8 + C2 chain, 24 files, 4707 insertions) — genuinely a two-parent merge commit (git cat-file -p c44ee7c9 shows parents 3f144e82/ce7ee96f), consistent with a real PR merge, though this specific instance doesn't carry azdo's usual auto-generated "Merge pull request N from branch into main" message text that its neighboring commits do (those PRs were completed via REST rather than the azdo web UI the same evening, per team lead). Three sibling PRs from the same wave: #179 (Lexicon legal docs, commit a71a0b15), #180 (D1/D2 demo-country fix, commit 3f144e82), #181 (B4 storage consolidation, commit ce64b93b). PR numbers/attribution here are as reported by the team lead; this session could not independently query the azdo PR REST endpoint (az repos pr show fails with a local keychain error, -25308 Can't fetch password from system, unrelated to the PAT itself — see ~/.claude/projects/-Users-makinja/memory/technical_azdo_git_auth_paths_sp_not_in_org_2026-07-17.md, PAT was rotated and confirmed working earlier the same day). The merge-commit hashes and their file contents are independently tool-verified (git log/git show --stat against azdo/main). This page documents what is live in main today and flags explicitly what is still open.
| Item | Status |
|---|---|
| A1 — RS/BA/HR retention law verification | Done (MC #105873) |
A2/A3 — financial_audit_log + document_retention_manifest schema |
Merged main (V124, V125), PR #178 |
| A4/A5 — capture trigger, pilot + 6-table fan-out | Merged main (V126, V128), PR #178 |
| A6 — actor/request-id context plumbing | Merged main (3 adversarial rounds, see below), PR #178 |
| A7 — backfill of existing rows | Merged main (V129), PR #178 |
| A8 — activity feed + entity timeline read API | Merged main (V130), PR #178; web UI panel not built, flagged to Vizu |
C1 — deleted_by/deletion_reason columns |
Merged main (V127), PR #178 |
C2 — InvoiceService.deleteInvoice USER_DELETE snapshot |
Merged main, PR #178 |
B4 — Storage: R2/GCS → Azure Blob SDK, deleteBlob() |
Merged main (ce64b93b), PR #181 |
| B1 — retention citation fixes + per-type override table | MC #105880, in progress — not yet in code |
B5 — upload path writes document_retention_manifest row |
MC #105884, in progress |
| B2 — purge worker, dry-run mode | MC #105881 created, blocked — red-zone (Momjian+Graff), waiting on A3+B1 |
| B3 — purge worker, live mode | MC #105882 created, blocked — waiting on B2 + ≥1 clean dry-run sprint on stage + explicit CEO sign-off before first live activation |
| D1/D2 — demo-country display bug (parallel workstream, same wave, not part of audit trail scope) | Fixed and merged (3f144e82), PR #180; documented separately, referenced here only for context |
Do not treat B1/B2/B3/B5 as done. MC #105881/#105882 exist but are deliberately blocked, not started — the purge worker does not run anywhere today; nothing purges blobs or applies retention. This page's "purge runbook" section below describes the design, not a currently operable procedure.
Why a third audit table
Two audit mechanisms already existed before this program:
- V1
logged_actions— append-only JSONB, but only ever used forENTRA_JIT_LINK/SECURITY_VIOLATIONevents, and has noorg_id/country_code(any RLS check against it needs an expensive subquery). - V51
audit_log— append-only, but scoped to admin-portal actions only.
Neither covers ordinary financial-entity mutations (invoices, expenses, contacts, transactions). Before this program, InvoiceService.deleteInvoice was a hard delete with no audit trail at all (InvoiceService.kt:1004-1018 pre-change) — the exact gap the CEO mandate calls out.
Kleppmann's arbitration: build a third table, not a consolidation of V1/V51 and not per-domain tables. It carries org_id + country_code from day one (unlike V1), so ADR-017 Phase 2B-style partitioning/tenant-query patterns work immediately.
Schema
financial_audit_log (V124, apps/api/src/main/resources/db/migration/V124__financial_audit_log.sql)
Partitioned, append-only audit trail for financial-entity mutations and read-sensitive actions.
event_id BIGINT GENERATED ALWAYS AS IDENTITY
org_id UUID NOT NULL REFERENCES organizations(id)
country_code VARCHAR(10) NOT NULL -- denormalized, indexed, NOT the partition key
entity_type VARCHAR(32)
entity_id UUID
action VARCHAR(24) NOT NULL -- INSERT | UPDATE | DELETE | EXPORT | DOWNLOAD |
-- RESTORE | USER_DELETE | RETENTION_PURGE
actor_user_id UUID REFERENCES users(id) ON DELETE SET NULL
actor_label TEXT
before_data JSONB
after_data JSONB
changed_fields TEXT[]
reason TEXT
request_id TEXT
client_ip INET
is_backfilled BOOLEAN NOT NULL DEFAULT false
occurred_at TIMESTAMPTZ(6) NOT NULL DEFAULT now()
PRIMARY KEY (event_id, occurred_at) -- occurred_at required in PK: it's the partition key
Design decisions (Momjian + Kleppmann arbitration, all tool-verified against the live schema, not assumed):
PARTITION BY RANGE (occurred_at), monthly. Purge is a time predicate (DROP PARTITION, never a million-rowDELETE).country_codeis a plain indexed column, not the partition key — this is the explicit rebuttal of ADR-017 Phase 2B's original country-list partitioning idea (Momjian: YAGNI, purge doesn't need country as a physical boundary).retain_untilis deliberately NOT a column. Retention differs by jurisdiction (HR/RS/BA_FED/BA_RS) and by entity type within a jurisdiction (see retention table below), so it's computed at purge time fromorganizations.legal_retention_yearsplus a per-type override table (B1, not yet built), not stored redundantly on every row.- Append-only enforcement is two layers, not one:
REVOKE UPDATE/DELETE FROM bilko_app(grants: INSERT+SELECT only) andBEFORE UPDATE/DELETE RAISE EXCEPTIONtriggers on the partitioned parent. The trigger layer exists specifically to also catchbilko_admin, which isBYPASSRLSbut not superuser —BYPASSRLSskips RLS policies, it does not skip triggers. PG16 propagates parent triggers to all partitions automatically, including ones created later by the (not-yet-built) partition-maintenance job. - RLS is PERMISSIVE + FORCE, consistent with the current Phase 2A org-isolation era —
org_isolation_select/org_isolation_insertscoped toapp.current_org_id, plusplatform_admin_full_read. A RESTRICTIVE flip is an explicit, separate, later CEO-gated decision; this migration does not touch it. country_codeisVARCHAR(10), notVARCHAR(8). This was a real bug caught by Momjian's design review of the first commit: the source column (organizations.country) was widened toVARCHAR(10)back in V16 specifically to holdBA_FED/BA_RS(6 chars), and V120 did the same forcompliance_deadlines.country"to keep country-code columns consistent." The originalVARCHAR(8)would have fit today's values with zero headroom — any future jurisdiction code longer than 8 characters would have thrown inside the audit trigger and aborted the parent invoice/expense mutation transaction, which is a worse failure mode than a normal app-level validation error on a table explicitly designed to be an invisible safety net. Fixed pre-merge; a dedicatedBA_FED/BA_RSwidth test fixture was added (previously the 30/30 green suite only exercisedHR/RS/BA, none of which are close to the old 8-char limit).- Initial monthly partitions cover 2026-07 through 2026-10 only. A partition-maintenance job to create future months does not exist yet — this is an open operational gap, not a documentation oversight; inserts for November 2026 onward will fail with "no partition found for row" unless a job is built before then.
document_retention_manifest (V125, generalizes the existing V78 hr_einvoice_archive WORM pattern to all uploaded files)
manifest_id BIGSERIAL PRIMARY KEY
org_id UUID NOT NULL REFERENCES organizations(id)
entity_type VARCHAR(32)
entity_id UUID
original_filename TEXT NOT NULL
content_type TEXT
file_size BIGINT
sha256_hex CHAR(64) NOT NULL
storage_backend TEXT
storage_path TEXT
uploaded_by UUID REFERENCES users(id) ON DELETE SET NULL
uploaded_at TIMESTAMPTZ(6) NOT NULL DEFAULT now()
blob_purged_at TIMESTAMPTZ(6)
blob_purge_reason TEXT
purged_by_job TEXT
This table is permanent by design — it has no retain_until because it never expires. It is the direct, literal answer to the CEO scenario: a row here proves a file existed, who uploaded it, its exact SHA-256, and — once the blob itself is purged — exactly when, why (RETENTION_PURGE vs USER_DELETE, matching financial_audit_log.action), and by which job run. bilko_app has INSERT+SELECT only; there is no append-only trigger here (unlike financial_audit_log) because a legitimate, narrow future UPDATE path exists — the not-yet-built purge worker marking blob_purged_at — and that grant is deliberately deferred to the Phase B2/B3 migration that creates the bilko_purge_worker role, since granting to a role that doesn't exist yet is a Flyway failure.
Capture mechanism (V126 pilot on invoices, V128 fan-out to 6 more tables)
Arbitration point 1 (Graff + Momjian, overruling a pure application-level design): capture is a generic AFTER DB trigger (capture_financial_audit(), to_jsonb(OLD/NEW), keyed off TG_TABLE_NAME), synchronous and transactional — not pg_notify/async, which would create a durability gap. This was chosen specifically because this same repo already proved app-level capture gets forgotten: deleteInvoice never called logged_actions before this program. Covers invoices, expenses, contacts, transactions, bank_accounts, bank_transactions, invoice_items.
Read-sensitive actions the trigger structurally cannot see — export, download, restore — are Kleppmann's second arbitration point: those are application-level events written into the same table from the service layer, since a DB trigger only fires on SELECT... it never fires at all, it can't be the mechanism for read events.
C1/C2 — soft-delete and hard-delete audit coverage
- V127 adds
deleted_by+deletion_reasonto every table withdeleted_at— the actual list was tool-verified via a liveinformation_schema.columnsquery against a full V1..V126 Testcontainers replay, not assumed from the task spec, and correctly caught tables the spec missed (bank_accounts,chat_conversations,exchange_rates,expense_documents,offer_items,offers,travel_orders). - C2:
InvoiceService.deleteInvoicestill does a hard delete (DRAFT-only semantics unchanged), but now writes afinancial_audit_logrow withaction='USER_DELETE'— a full invoice+line-items JSON snapshot — in the same transaction, before the delete. This is deliberately not deduplicated against the V128 trigger's own low-levelDELETErow for the same statement:USER_DELETEis the business-level "someone claims we lost their invoice, prove what happened" answer; the trigger'sDELETErow is the safety net that fires even for deletions that bypass the service entirely (e.g. direct SQL).
A8 — read API
GET /orgs/{id}/activity (org-wide feed) and GET /{entity}/{id}/timeline (single-entity history), both paginated, both using the composite indexes created in V124. Gated by a new activity:read permission (V130), seeded only for owner/admin — not viewer, not accountant — because before_data/after_data carry full row snapshots including PII (e.g. a contact's OIB/JMBG-equivalent tax ID). Both endpoints filter directly on financial_audit_log.org_id = principal.organizationId rather than the existing ResourceAccessFilter.requireOrgOwnership helper, because that helper's table coverage predates this program and is missing 3 of the 7 audited tables. Cross-org access returns a 404 for the org feed and an empty list (not a 403/404) for the entity timeline, specifically to avoid leaking entity-existence across tenants via response-code side channel. The web UI panel for this API was explicitly scoped out — flagged to Vizu as a separate task rather than built as a rushed stub, per the implementing agent's own assessment that building it to the existing app's quality bar is realistically over an hour of frontend work.
A6 saga — three adversarial rounds, two real bugs caught
A6 (SET LOCAL app.current_user_id / app.current_request_id, MC #105877) is the most important lesson from this program for anyone touching Ktor plugin install-order in this codebase. It went through three rounds of Momjian build → Parisa Tabriz (Securion) adversarial verify before landing.
Round 1 finding (ThreadLocal vs. coroutine dispatcher hop): the original implementation used a plain ThreadLocal to carry actor context. dbQuery{} dispatches onto Dispatchers.IO, which is a thread pool — a ThreadLocal set on the request-handling thread is simply not visible on whatever IO-pool thread the query actually executes on. Result: actor_user_id silently NULL for essentially all real requests. Fixed by switching to a kotlinx.coroutines ThreadContextElement, which is designed to survive exactly this kind of dispatcher hop.
Round 2 finding (install-site / phase-ordering — the more interesting one): even with the coroutine-context mechanism now correct in isolation, it was still not effective end-to-end, because installOrgScopePlugin() was installed via intercept(ApplicationPhase.Plugins) at the Application.module() level — which runs before Ktor's routing tree even dispatches into the authenticate("bilko-jwt") { } block, i.e. before BilkoPrincipal is resolved. So call.principal<BilkoPrincipal>() inside the interceptor was always null in production, authenticated request or not.
This is the same class of bug as MC #104962, which had already found and fixed the identical mistake for the sibling TrialGatePlugin: install at Application level and the plugin sees a permanently-null principal, no matter how correct its internal logic is. TrialGatePlugin's own doc comment says this explicitly — but installOrgScopePlugin() had not been given the same fix.
The org_id side of the same mechanism was not affected, and understanding why is the actual transferable lesson: orgTransaction(organizationId: String, ...) takes organizationId as an explicit function parameter, sourced by every real call site via effectiveOrgId(principal) called inside the route handler (which does run after auth). So org_id never depended on the broken plugin's principal read at all — currentOrgIdThreadLocal turned out to be dead code in production (zero call sites). actor_id had no equivalent parameter — its only source was the broken plugin — so only the actor side silently failed.
Fix: installOrgScopePlugin() was changed from an Application extension to a Route extension, intercepting ApplicationPhase.Call (not Plugins), called as the literal first statement inside authenticate("bilko-jwt") { } in Routing.kt — before install(TrialGatePlugin) and all route registrations. TrialGatePlugin couldn't be copied verbatim as a pattern because it uses the on(AuthenticationChecked) hook, which is a plain synchronous callback with no proceed() — fine for a check-and-throw, but unable to keep a coroutine context element alive forward into a later route handler's dbQuery call, which is exactly what A6 needs.
Round 3's regression test (OrgScopeActorContextHttpIntegrationTest.kt) is itself a lesson worth keeping: it deliberately goes through the real HTTP client, the real JWT verifier, and the real routing tree — not the withActorContextForTest seam that round 2's test used, which structurally could not have caught this bug because it never calls installOrgScopePlugin()'s actual install path at all.
Lesson for future Ktor plugin work in this codebase: if a plugin needs call.principal<T>() to be non-null, verify empirically (not by inspection) which phase it actually observes the principal at — ApplicationPhase.Plugins at the Application level is provably too early for anything gated by authenticate(...), regardless of where in the source file the intercept() call is textually nested. This has now bitten two different plugins (TrialGatePlugin in #104962, OrgScopeSessionVariable here) with the same root cause.
Retention law — verified findings (MC #105873, primary sources)
| Jurisdiction | Books (journal/ledger) | Auxiliary books | Financial statements | e-invoice (fiscalized) | Payroll records |
|---|---|---|---|---|---|
| Croatia (HR) | at least 11 years (ZOR NN 78/2015 čl. 10(2)) | at least 11 years | permanent* (pin at B1) | 6 years (Zakon o fiskalizaciji NN 89/2025 čl. 35) | ≥6 years / analytics permanent |
| Serbia (RS) | 10 years (Zakon o računovodstvu, "Sl. glasnik RS" 73/2019 čl. 28) | 5 years | 20 years | n/a (SEF rules separate — pin at B1) | permanent |
| BiH — Federation (BA_FED) | at least 11 years (Zakon o računovodstvu i reviziji FBiH, "Sl. novine FBiH" 15/2021 čl. 49) | pin at B1 | pin at B1 | n/a | pin at B1 |
| BiH — Republika Srpska entity (BA_RS) | at least 10 years ("Sl. glasnik RS" 115/2025, article number TBD — not yet pinned from primary text) | at least 5 years | permanent | n/a | pin at B1 |
Two compliance findings from this verification are not yet fixed in code — they are B1's job, not done:
- Code cites a dead law:
PluginRS.kt:325cites "Sl. glasnik RS br. 62/2013, čl. 24" — that law was replaced by 73/2019 in its entirety. The 10-year figure for books happens to still be correct, but the legal basis citation is wrong and must be updated to 73/2019 čl. 28. - 🔴 Compliance gap, not just a citation issue:
V50currently setsorganizations.legal_retention_years = 10for BA orgs generally. FBiH law requires at least 11. AnyBA_FEDorganization in Bilko today is configured with a retention period one year short of its legal minimum. This is a live data-value fix, not a comment fix, and is the highest-priority item inside B1.
The BA_RS article number in the new 115/2025 law could not be pinned from available sources (target site had a TLS failure); the 10/5/permanent figures are confirmed via secondary sources, but B1's implementer must pin the exact article from the official Sl. glasnik RS 115/25 text before it is cited in any docs or code comment — this is a repeat of the same "don't propagate an unverified article number" discipline that caught the RS 62/2013→73/2019 gap in the first place.
GDPR — erasure vs. retention
Position (plan arbitration point 7, not yet formalized in Privacy Policy/DPIA — that's Lexicon's MC #105277, linked, separate from this page): under GDPR Art. 17(3)(b), the right to erasure does not apply where processing is necessary for compliance with a legal obligation — here, the accounting-law retention requirements in the table above. A financial audit snapshot (financial_audit_log.before_data/after_data, or a document_retention_manifest row) is not deleted on a user erasure request during the applicable retention window. The only erasure-adjacent action available is pseudonymizing actor_label/user-identifying fields after the retention window closes, and that is explicitly scoped as: every erasure request goes through Securion/legal review, never a self-serve deletion path, and never automatic.
Purge worker — design (not yet built; B2/B3 are open tasks)
This section is a design skeleton for the not-yet-implemented RetentionPurgeWorker, so it is documented in the same place as the schema it operates on. Nothing described below exists in the running system today.
- Primary path:
DROP TABLE <partition>for afinancial_audit_logmonthly partition once every row in it is past its jurisdiction's retention window. Fallback for mixed-retention partitions (a partition spanning rows from different orgs/countries with different retention lengths): batchedDELETEwithLIMIT 5000+ commit per batch +pg_sleepthrottle. - Order of operations is fixed and load-bearing: blob first, then row. Deleting the
document_retention_manifest/audit-row pointer before the blob would create an orphaned blob with no trace pointing to it — the opposite of this program's purpose. The blob delete must succeed (or be confirmed already-gone, 404-as-success, matchingReceiptService.deleteBlob()'s existing idempotent contract from B4) before the manifest row is markedblob_purged_at. - Dedicated
bilko_purge_workerrole,BYPASSRLS, least-privilege, narrower thanbilko_admin. ASECURITY DEFINER purge_expired_partition()function owned bybilko_adminis the intended privilege-elevation boundary rather than granting the worker role broad table access directly. - The purge worker's own actions are themselves audited ("audit the auditor") in a
purge_worker_logtable — any trigger-disable needed for a legitimate purge happens inside the same transaction as the log write. - Dry-run is the default mode, and is required before any live run. A dry-run must print the candidate rows/partitions it would purge and delete nothing; the acceptance test for B2 is specifically that a row still exists after a dry-run pass.
- Live mode (B3) requires explicit CEO sign-off before its first activation, and only after at least one full sprint of clean dry-run output on stage. This is a 🔴 red-zone item in the parent plan — senior-only, never a locally-dispatched builder task.
- Cron mechanism is not yet confirmed.
pg_cronavailability on Azure Flexible Server has not been verified (flagged to FlowForge); the fallback is the existing app-level cron pattern already used byInvoiceCronRoutes.kt(COMPLIANCE_CRON_SECRET). - How to read a dry-run log once B2 exists: each dry-run entry will list, per candidate partition/row set: org, country, entity type, computed
retain_until(fromorganizations.legal_retention_years+ the not-yet-built per-type override table from B1), and the action that would be taken (DROP PARTITIONvs. batchedDELETE). Approval for a live run means a human has read that candidate list and confirmed it matches expectation — not just that the dry-run exited zero.
Relationship to ADR-017 Phase 2B
ADR-017 Phase 2B originally proposed country-code-list partitioning. It was never implemented — Flyway's actual HEAD was V116 at the start of this program (V36/V37 slots referenced in the old ADR discussion were long since consumed by unrelated migrations), so this program's financial_audit_log design is effectively a green-field implementation of "partition a big multi-tenant table," not a migration of an existing partitioned table. Momjian's rebuttal, adopted here: partition by occurred_at (RANGE, monthly) with country_code as a plain indexed column, not a partition key — purge is fundamentally a time predicate, and a country-list partition scheme would need to be extended by hand every time a new market is added, for no purge-performance benefit.
Testing discipline (applies to every migration in this program)
Every migration listed above has a companion V1xxMigrationTest.kt (Testcontainers postgres:16) that does a full V1..V1xx replay, not a fresh-schema shortcut — this is the direct lesson from the V121 incident (Exposed's SchemaUtils.create()-based test schemas do not see CHECK constraints introduced by a migration; the only way to prove a constraint is real is to replay the actual migration chain and show that removing it makes the negative test fail). Each migration test includes negative tests (UPDATE/DELETE on the audit tables must throw, for both bilko_app and bilko_admin), an RLS cross-org test, and — where relevant — a partition-existence/partition-drop test.
Evidence index
| Task | Evidence |
|---|---|
| A1 retention law verification | ~/system/evidence/105873/a1-retention-verification.md |
| A6 build (3 rounds) | ~/system/evidence/105875/momjian-review.md |
| A6 adversarial verify (Securion, 3 rounds) | ~/system/evidence/105877/parisa-verify.md, ~/system/evidence/105877/verdict.md |
| A7 backfill | ~/system/evidence/105878/verdict.md |
| A8 activity/timeline API | ~/system/evidence/105879/verdict.md |
| B4 storage consolidation | ~/system/evidence/105883/verdict.md |
| C1 soft-delete columns | ~/system/evidence/105885/verdict.md |
| C2 delete snapshot | ~/system/evidence/105886/verdict.md |
| D1 demo-country bug root cause | ~/system/evidence/105874/d1-rootcause.md |
| D2 demo-country bug fix + E2E | ~/system/evidence/105887/d2-verdict.md |
Open follow-ups
- MC #105880 (B1) — fix the dead-law citation and the BA_FED 10→11 year compliance gap; build the per-type retention override table.
- MC #105884 (B5) — upload path writes a
document_retention_manifestrow at upload time (blocked on both V125 and the B4 Azure Blob refactor being merged, which they now are — this can proceed). - B2/B3 purge worker — MC #105881/#105882 created, both blocked (B2 on A3+B1; B3 on B2 + a clean dry-run sprint + CEO sign-off). Dry-run first, live mode requires CEO sign-off.
- Partition-maintenance job for
financial_audit_logbeyond 2026-10 — not started; will start failing inserts in November 2026 if not built. - Web UI panel for the A8 activity/timeline API — flagged to Vizu, not scheduled.
- MC #105277 (Lexicon) — Privacy Policy/DPIA update for the Art. 17(3)(b) erasure-vs-retention position described above.
- ADR for this design has not been written into the repo yet — outline below, for whoever picks up that task.
ADR outline (for a future repo-side ADR, not written to the repo by this task)
Proposed title: ADR-0XX — Financial Audit Trail: append-only partitioned log, not a consolidation of V1/V51
- Context: CEO mandate, gaps in V1/V51 coverage,
deleteInvoicehard-delete-with-no-trail example. - Decision: third table (
financial_audit_log), RANGE-partitioned byoccurred_atmonthly,country_codedenormalized not partition key; dual append-only enforcement (REVOKE + trigger); hybrid capture (DB trigger for mutations, application-level for read-sensitive actions); permanentdocument_retention_manifestfor file-level trace, decoupled from blob lifecycle. - Alternatives considered and rejected: consolidating into V1 or V51 (Kleppmann — wrong semantics, V1 has no tenant context); async/
pg_notifycapture (durability gap); country-code partitioning per original ADR-017 Phase 2B sketch (Momjian — no purge-performance benefit, extra operational burden per new market); hash-chained tamper-evidence (deferred, not rejected — revisit only on explicit regulatory/enterprise requirement). - Consequences: purge becomes a partition-drop operation once B2/B3 land; every future table needing financial audit coverage follows the V126/V128 trigger-fan-out pattern; A6's install-site lesson should be called out as a standing Ktor-plugin gotcha for this codebase.
- Status: Phase A implemented and merged; Phase B (retention enforcement) open.
Bilko — Retention entity_type mapping (V133, MC #105965)
Retention entity_type mapping (V133)
MC: #105965 (parent #105872 audit-trail program) · Autor: Bruce Momjian (CodeCraft) · Verdict: PASS (FORGE MLX peer-verify PASS) · Grana: azdo feat/105965-entity-type-mapping (nije u main)
Problem
document_retention_manifest.entity_type je čuvao IMENA TABELA ('expenses','inbox_items' iz B5), a entity_type_retention_policy (B1) je seedovana PRAVNIM KATEGORIJAMA ('isprave','eracun'). Nisu se mapirali → RetentionPurgeWorker fail-safe skip → dry-run nalazi ~0 kandidata. Worker je (linije 53-68, 336-342) eksplicitno imenovao ovaj gap kao MC #105965.
Rješenje — dedicated lookup tabela
entity_type_mapping(jurisdiction, table_entity_type, legal_entity_type) (V133). Odluka: zasebna tabela, NE kolona na policy tabeli.
- Zašto tabela, ne kolona: composite FOREIGN KEY
(jurisdiction, legal_entity_type) → entity_type_retention_policy(jurisdiction, entity_type)na DB nivou onemogućava da mapping red pokazuje na nepostojeću pravnu kategoriju (dokazano V133MigrationTest FK-rejection testom). Kolona bi rekreirala isti "entity_type znači dvije stvari" ambiguitet i ne bi izrazila jurisdikcijski-skoupovanu many-to-one vezu. - Seed: HR + BA_FED expenses/inbox_items → isprave. RS / BA_RS namjerno NEmapirani — nema odgovarajućeg policy reda (zasebna pravna-verifikaciona rupa, MC follow-up, NE izmišljati kategorije).
- Worker: rezolvira entity_type kroz mapping prvo; ako nemapiran, fallback = tretira entity_type kao već-pravnu-kategoriju (čuva staro ponašanje za fixture koji koriste 'isprave' direktno); fail-safe skip samo ako oba lookupa promaše.
Testovi (V121 standard, Testcontainers postgres:16)
21/21 zeleno: V133MigrationTest (5: seed exactness, UNIQUE, CHECK, composite-FK rejection, grants) + 16 RetentionPurgeWorkerTest uklj. candidatesFound > 0 na realnom seedu + companion "ukloni mapping red → isti kandidat nestane" (doslovni V121 'dokaz = ukloni → test pada'). V131/V132 regresija re-run zelena.
Odnos prema purge lancu
Ovo je PREP: odblokira #105881 (B2 dry-run). B3 live purge (#105882) ostaje CEO-sign-off gated — nijedan živi DELETE nije pokrenut. Evidence: ~/system/evidence/105965/verdict.md.
Bilko — RetentionPurgeWorker DRY-RUN (B2, MC #105881)
RetentionPurgeWorker DRY-RUN (B2)
MC: #105881 (parent #105872) · Autor: Bruce Momjian (CodeCraft) + Graff red-zone review · Verdict: PASS (FORGE MLX peer-verify PASS) · Grana: azdo (rebase tip 844fc11c, NIJE main-merge dok red-zone gate ne prođe)
Šta radi
Dry-run purge worker: čita document_retention_manifest + entity_type_mapping (V133) + entity_type_retention_policy + legal_retention_years, LOGUJE kandidate za purge, NIŠTA NE BRIŠE. Sada kad je #105965 mapping integrisan, dry-run NALAZI kandidate (prije: fail-safe skip → 0).
Dokaz
39/39 Testcontainers (postgres:16) zeleno end-to-end na merge-anoj grani: V131(9)+V132(9)+V133(5)+RetentionPurgeWorkerTest(16), uklj. candidatesFound > 0 asertaciju + V121 companion "ukloni mapping → test pada". Evidence: ~/system/evidence/105881/verdict-b2-finalization-2026-07-19.md.
Cron mehanizam
RetentionPurgeCronRoutes.kt — POST /api/v1/internal/retention/purge-dry-run, PURGE_CRON_SECRET header auth, fail-closed 503, mounted u Routing.kt (isti pattern kao InvoiceCronRoutes/ComplianceCronRoutes). INERTAN dok se ne provisionira PURGE_CRON_SECRET + eksterni trigger. Odluka: app-cron fallback; pg_cron (Azure Flexible) optimizacija odgođena do #105964 RBAC.
🔴 Red-zone invarijante (7, svaka grep-verifikovana, ne pretpostavljena)
DRY_RUNhard-codedtrue, nema override. 2. Nula DELETE/UPDATE/DROP dodano #105965 integracijom (samo read-only SELECT lookup). 3.purge_expired_partition()i dalje bez ijednog caller-a, bez novog EXECUTE granta. 4.bilko_purge_workerwrite grant nepromijenjen/uzak. 5. mapping-miss fallback matchuje samo human-authored policy redove (companion test). 6. cron ruta fail-closed/inert. 7.entity_type_mapping+entity_type_retention_policyread-only kroz worker (svaki ref = .selectAll()). Graff review:~/system/evidence/105881/graff-review.md.
Odnos prema B3
B3 live purge (#105882) = jedina preostala CEO-sign-off tačka cijelog programa. B2 ne dodaje nijedan EXECUTE/DELETE/DROP grant (grep-potvrđeno). Živi purge se ne može desiti dok CEO eksplicitno ne odobri B3 + ne provisionira live flag + PURGE_CRON_SECRET.
Bilko — Support agent rola + RLS temelj (Faza A, MC #106050/#106051)
Support agent rola + RLS (Support MVP Faza A)
MC: #106050 (A1) + #106051 (A2), parent #106049 · Autor: Bruce Momjian · Red-zone verify: Parisa Tabriz (Securion) PASS · Grana: azdo feat/106051-support-agent-rbac (c4c8b4f4, NIJE main dok se A4/B ne ožiče)
Šta
DB temelj za support osoblje: nova rola support_agent koja vidi tikete SVIH klijenata ali NIŠTA finansijsko.
- V133 — permission catalog:
support_agentdobija TAČNOsupport_ticket:read+support_ticket:reply(colon-format po V67 CHECK-u). NULA finance/accounting/payroll ključeva. Idempotentno. - V134 — RLS + grants: cross-org SELECT na
support_ticketsgejtован NOVIM GUC-omapp.is_support_agent(NAMJERNO NE recikliraapp.is_platform_admin— to bi tiho dalo supportu sve što platform-admin tabele daju). Column-scoped UPDATE grant (status, resolution_note, triage_json, external_ref, updated_at — org_id/context_bundle isključeni na GRANT nivou). Usput zakrpio pre-existing gap: support_tickets (V73) nikad nije imao eksplicitan bilko_app GRANT (nevidljivo u prod jer bilko_admin BYPASSRLS). - Rola = pravi users.role login, ne service-account (bilko-support-triage je mašinski, bez per-human atribucije).
Sigurnost (Parisa adversarial verify, 6/6 drži)
- Finance leak: nema —
is_support_agentse ne pojavljuje ni u jednoj finance/audit policy kroz 134 migracije; support konekcija na te tabele vezana istim org_isolation policy-ma. 2. GUC scope:is_support_agentsamo u V134, samo na support_tickets policy-ma. 3. Cross-org: samo support_tickets otvoren, ništa drugo. 4. Permission over-grant: resolve() = exact-equality lookup, bez wildcard/prefix; mehanički ne može vratiti više od 2 ključa. 5. Column-scoped grant potvrđen. 6. RLS nigdje globalno ugašen; nema GUC-injection primitiva. Evidence:~/system/evidence/106051/parisa-adversarial-verify.md.
⚠️ Bitno za sljedeću fazu (A4/B)
Aplikacijski sloj JOŠ ne ožičuje rolu — RLS politike su trenutno nedostižan dead code (siguran failure mode; rute i dalje idu preko app.is_platform_admin). A4 (requireSupportAgent() guard) + B (rute koje SET LOCAL app.is_support_agent) su prvi kod koji stварno pali novi GUC → traže vlastiti adversarial pass, ne voze na ovom verdiktu (Parisa flag).
Bilko — Support ticket reply + GUC aktivacija (Faza B, MC #106054-106057)
Support ticket reply + GUC aktivacija (Faza B)
MC: #106054 (B1) #106055 (B2) #106056 (B3) #106057 (B4) + #106071 (gap-fix), parent #106049 · Build: Momjian (B1+gap) + CodeCraft (B2-B4) · Red-zone verify: Parisa Tabriz GUC-execution adversarial PASS · Grana: azdo feat/106051-support-agent-rbac (5c06350a, NIJE main)
Šta
Support agent sada može ODGOVORITI korisniku na tiket (async), i tu se PRVI put aktivira app.is_support_agent prekidač (dosad dead-code).
- B1 (V136):
support_ticket_messagesthread tabela (agent↔customer poruke) + RLS (agent cross-org kroz app.is_support_agent, customer samo svoj ticket) + uski grants. Append-only. - B2 🔴:
POST /admin/support/tickets/{id}/messages— agent reply. PRVI kod koji izvršavaSET LOCAL app.is_support_agent='true'— unutar vlastite transakcije, tek nakon requireSupportAgent() guarda, mirror OrgScopeSessionVariable pattern. Upis poruke + NotificationService (korisnik dobije obavijest) + audit_log. - B3:
GET /support/tickets/{id}/messages— customer strana, postojeći JWT, NE pali GUC; app-layer org+ticket WHERE filter (defense-in-depth uz RLS) → cross-tenant = 404. - B4:
GET /admin/support/ticketsscope za support_agent (cross-org) + platform_admin backward-compat. Zaseban guard/sealed-class, ne dira detail/PATCH. - #106071 gap-fix: generički invite putevi (POST /admin/invitations, POST /admin/users, PUT /users/:id/role) sada REJECT-uju role=support_agent — samo dedicated platform-admin support-invite (A3) ga smije dodijeliti.
🔴 Sigurnost (Parisa GUC-execution adversarial, sve drži)
- Pool leakage: nema — Database.kt isAutoCommit=false (SET LOCAL transaction-scoped); svaki request svježa Exposed transakcija; pool-test (Hikari pool=1, druga tx čita NULL, 3×) + Parisa nezavisno potvrdila handler shape. 2. Ekskluzivnost: B4 sealed-class exhaustive
when= compile-time garancija da se nikad ne postave oba prekidača; grep — nijedan drugi fajl ne referencira is_support_agent. 3. #106071 stварno zatvoren na 5c06350a (3 call-site exact-match reject, ne tiketiran). 4. B3 ne pali GUC, app-layer filter daje pravi 404. 5. B4 detail/PATCH byte-for-byte nepromijenjeni. Evidence:~/system/evidence/106055/parisa-b-guc-adversarial-verify.md.
Otvoreni tech-debt (van scope-a, evidentiran)
#106070 StatusPages 403-body · #106072 org-name unique · #106075 V73 ::uuid 500 · Jackson-vs-kotlinx serialization mehanizam (D1 docs presuda).
Ostaje
C (Vizu support konzola UI) → D (docs) → E (Angie E2E uživo).
Bilko — Support sistem — arhitektura (MC #106061)
Parent: MC #106049 (Support MVP) · Ovaj task: MC #106061 (D1) · Agent: Skillforge · Datum: 2026-07-20
Ova stranica postoji da se izbjegne zabuna od 2026-07-13 ("dva backena") — Bilko ima tri odvojena support mehanizma plus jedan odgođen. Nijedan se ne preklapa niti dijeli kod s drugim. Ako trebate promijeniti support ponašanje, prvo utvrdite u KOJI od ova tri/četiri komada spada promjena.
1. Mapa sistema
flowchart TB
subgraph customer["Customer-facing"]
CW["ChatWidget.tsx<br/>apps/web/components/chatbot/"]
CBS["ChatbotService.kt<br/>/chatbot/message endpoint<br/>KANONSKI AI customer backend"]
CC["ComplianceClassifier.kt<br/>floor/compliance guard (#105476)<br/>fail-closed na compliance pitanja"]
CW -->|"POST /chatbot/message"| CBS
CBS --> CC
end
subgraph staff["Staff-only (interno)"]
BSA["bilko-support-answer.js<br/>ZASEBAN staff-copilot daemon<br/>NIJE customer-facing, NE dijeli kod s ChatbotService"]
end
subgraph human["Human support (OVAJ MVP, MC #106049)"]
SA["support_agent rola<br/>RBAC: support_ticket:read / support_ticket:reply"]
RLS["RLS cross-org izuzetak<br/>app.is_support_agent GUC<br/>SAMO support_tickets + support_ticket_messages"]
REPLY["Async ticket reply<br/>NotificationService polling<br/>support_ticket_messages thread"]
SA --> RLS
RLS --> REPLY
end
subgraph future["Faza 3 (ODGOĐENO)"]
RT["Real-time live chat<br/>AI->čovjek handoff<br/>build in-house (Ktor WS+Redis) vs Chatwoot/Crisp<br/>trigger: realan volumen support zahtjeva"]
end
CC -.->|"nikad ne dijeli backend"| SA
BSA -.->|"nikad ne dijeli backend"| SA
REPLY -.->|"kad dođe red"| RT
2. AI chat — customer widget (KANONSKI, ne dira ovaj MVP)
- Backend:
ChatbotService— endpointPOST /chatbot/message. Ovo je JEDINI kanonski AI customer-facing backend u Bilku. - Frontend:
ChatWidget.tsx(apps/web/components/chatbot/ChatWidget.tsx). - Compliance guard:
ComplianceClassifier.kt— floor/compliance guard iz MC #105476, live u produkciji, PASS. Fail-closed dizajn: na pitanjima koja spadaju u compliance-klasu bez pouzdanog KB hita, sistem odbija davati odgovor (ne pretpostavlja). - Verifikovano iz koda:
apps/api/src/main/kotlin/no/alai/bilko/services/ChatbotService.ktiservices/chatbot/ComplianceClassifier.kt— komentari na linijama 160–265 potvrđuju fail-closed granu za compliance-klasu pitanja.
3. AI staff triage — zaseban daemon
bilko-support-answer.js— ZASEBAN staff-copilot daemon. Pomaže ALAI/Bilko support osoblju da brže odgovori na tikete (draft odgovora, KB pretraga za internu upotrebu).- NIJE customer-facing. Ne prima poziv od
ChatWidget.tsx, ne dijeli kod ni endpoint sChatbotService. - Postoji nezavisno od RBAC/RLS promjena u ovom MVP-u (Faza A/B) — support_agent rola ne mijenja kako ovaj daemon radi.
4. Human support — NOVO u ovom MVP-u (MC #106049)
Ovo je jedini dio arhitekture koji je STVARNO nov u ovom talasu. Detalji u:
- Faza A — "Bilko — Support agent rola + RLS temelj (Faza A)" (MC #106050 A1 + #106051 A2) — nova
support_agentrola, RBAC permisije, RLS cross-org SELECT/UPDATE izuzetak nasupport_ticketspreko noveapp.is_support_agentGUC. - Faza B — "Bilko — Support ticket reply + GUC aktivacija (Faza B)" (MC #106054–#106057) —
support_ticket_messagesthread tabela + RLS (B1), POST reply endpoint koji PRVI PUT u kodu stvarno postavljaSET LOCAL app.is_support_agent = 'true'(B2), GET customer strana poruka (B3), lista tiketa scoped za support_agent uz regresiju platform_admin (B4). - Puna RBAC/RLS referenca i runbook — vidi "Bilko — Kako support agent radi" (E2, runbook).
5. Faza 3 (odgođeno) — Real-time live chat + AI→čovjek handoff
- Status: ODGOĐENO. Nije dio ovog MVP-a (MC #106049 pokriva samo async ticket reply).
- Otvorena odluka kad dođe red: build in-house (Ktor WebSocket + Redis/ACA sticky sessions) vs gotov servis (Chatwoot/Crisp — napomena: GDPR data-processor ugovor za PII bio bi potreban ako se ide ovim putem).
- Trigger za pokretanje: realan volumen support zahtjeva koji async ticket-reply model više ne pokriva dovoljno brzo.
6. Linkovi
- Linkovano iz
DEPLOY-MAP.mdiBUILD-BLUEPRINT.mdu repou (~/business/ALAI-Holding-AS/products/Bilko/). - Plan:
~/.claude/plans/bilko-support-mvp-2026-07-20.md - Evidence:
~/system/evidence/106050/(A1, ako postoji),~/system/evidence/106051/(A2),~/system/evidence/106053/(A3/A4),~/system/evidence/106054/(B1),~/system/evidence/106055/(B2/B3/B4).
Bilko — Kako support agent radi (runbook, MC #106063)
Parent: MC #106049 (Support MVP) · Ovaj task: MC #106063 (E2) · Agent: Skillforge · Datum: 2026-07-20
Vidi također: Faza A (RBAC+RLS temelj), Faza B (ticket reply + GUC aktivacija), i "Support sistem — arhitektura" (D1) za mapu svih support mehanizama.
1. Kako se support agent provisionira
- Dedicated endpoint:
POST /admin/support/agents/invite(SupportAgentProvisioningRoutes.kt, MC #106052/A3). - Ko može pozvati: SAMO
platform_admin(requirePlatformAdmin()) — isti širok gate koji koriste svi drugi platform-ops admin route-ovi. Nema support_agent-specifičnog uslova za KO smije pozvati novog agenta — samo za šTA taj agent, jednom kreiran, smije poslije vidjeti. - DTO:
InviteSupportAgentRequest(email, fullName)— SAMO ta dva polja. NemaroleniorganizationIdpolja na DTO-u uopšte. - Rola i org su hardkodovani server-side: rola je uvijek
"support_agent", org je uvijek interni org sa fiksnim imenom"Bilko Internal — Support"(seedovan V135 migracijom). Caller ne može cilje ni jedan ni drugi preko request body-ja. - Zašto ne postojeći generički
POST /admin/invitations: taj endpoint uvijek koristiorganizationId = principal.organizationId(org pozivaoca), što nikad nije interni support org — nema načina da se kroz njega ubaci support_agent u interni org. - Aktivacija: pozvana osoba se prijavljuje kroz ISTI, nepromijenjeni Entra JIT invite-accept put kao svaki drugi Bilko invite (
InviteService.acceptInviteInsideTransaction, pozvano izAuthService.createSessionFromEntraIdToken). Nema novog auth koda, nema service accounta. JWT nakon prijave nosirole="support_agent"kao bilo koja druga rola.
1a. Zatvorena rupa — #106071 generic-invite reject
Kad su InviteService.VALID_ROLES i UserProvisioningService.VALID_ROLES prošireni da uključe support_agent (da bi gornji dedicated endpoint mogao pozvati isti servisni sloj), TRI POSTOJEĆA generička route-a (gated samo sa users:manage, dakle bilo koji admin/owner SVOJE org-e) su tiho počela prihvatati role="support_agent" i preko sebe:
POST /admin/invitations(InviteRoutes.kt)POST /admin/usersiPUT /users/:id/role(UserManagementRoutes.kt)
Fix: nova funkcija rejectSupportAgentRoleInGenericFlow(role) u RbacHelper.kt — baca ForbiddenException("SUPPORT_AGENT_ROLE_FORBIDDEN_HERE...") na sva tri generička route-a PRIJE nego zahtjev stigne do servisnog sloja. VALID_ROLES ostaju široki (jer dedicated endpoint legitimno zove istu createInvite() funkciju) — reject je na route sloju, ne na servisnom, upravo na ta tri mjesta koja je Parisa našla, ne paralelna allowlist koja bi mogla driftati.
2. Jackson vs kotlinx.serialization — razriješena neslaganja (Momjian vs Parisa)
Zadatak je tražio da se PROČITA stvarni kod prije nego što se prepiše bilo čija tvrdnja. Pročitano: apps/api/src/main/kotlin/no/alai/bilko/plugins/Serialization.kt i SupportAgentProvisioningRoutes.kt na branch-u feat/106051-support-agent-rbac (commit 5c06350a).
Nalaz: Bruce Momjian je bio u pravu — mehanizam je Jackson's FAIL_ON_UNKNOWN_PROPERTIES (default true, nikad eksplicitno postavljen niti isključen u kodu), NE kotlinx.serialization ignoreUnknownKeys.
Dokaz iz koda:
// Serialization.kt
fun Application.configureSerialization() {
install(ContentNegotiation) {
jackson {
disable(SerializationFeature.INDENT_OUTPUT)
registerModule(JavaTimeModule())
disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
findAndRegisterModules()
}
}
}
val bilkoJson = Json {
ignoreUnknownKeys = true // <- postoji, ali NIJE registrovan u ContentNegotiation
...
}
install(ContentNegotiation) { jackson { ... } }— Ktor-ov HTTP content-negotiation sloj je konfigurisan SAMO sa Jackson-om. Nemajson(bilkoJson)ni bilo koje druge kotlinx registracije u tom bloku.- DTO-ovi kao
CreateInviteRequestiInviteSupportAgentRequestnose@Serializableanotaciju (kotlinx marker), ali se stvarno deserijalizuju prekocall.receive<T>(), što ide kroz ContentNegotiation — dakle kroz Jackson, ne kroz kotlinx, bez obzira na anotaciju na DTO-u. bilkoJson(kotlinxJsoninstanca saignoreUnknownKeys = true) postoji u istom fajlu, ali služi za internu JSON obradu (komentar u kodu: "used internally for JSON parsing") — NIJE registrovana kao content-negotiation mehanizam za HTTP request body.- Jackson-ov
ObjectMapper(kreiran preko Ktor-ovogjackson {}DSL-a) imaFAIL_ON_UNKNOWN_PROPERTIESdefaulttrue— i nigdje u repou (grep potvrđen, nula pogodaka) se to eksplicitno ne isključuje. - Live test-dokaz (ne samo statika koda):
SupportAgentProvisioningTest.kt, testT06 "request body cannot override role or organizationId — DTO has no such fields, extra keys are rejected outright"— šalje JSON sa dodatnimrole/organizationIdpoljima naInviteSupportAgentRequestDTO, unutartestApplication {}(stvarna Ktor test-aplikacija, ne mock), i tvrdiHttpStatusCode.BadRequest. Test-ov vlastiti komentar kaže eksplicitno: "This codebase's Jackson ObjectMapper (Serialization.kt) does NOT disable FAIL_ON_UNKNOWN_PROPERTIES, so an unrecognized JSON key is rejected outright with 400." Pozitivna kontrola u istom testu potvrđuje da isti email SA samo ispravnim poljima uspijeva (201) — dokazuje da je 400 specifično zbog nepoznatih ključeva, ne neke druge greške.
Zaključak za tim: kad god vidite @Serializable na DTO-u u ovom repou, to NE garantuje da će kotlinx pravila (npr. ignoreUnknownKeys) važiti na HTTP granici — treba provjeriti da li je taj DTO stvarno deserijalizovan preko ContentNegotiation-a (Jackson, ovaj repo) ili preko direktnog poziva na bilkoJson.decodeFromString<T>() (kotlinx, druga upotreba u istom fajlu).
3. Šta support agent VIDI
- SVI support tiketi, cross-org — preko
app.is_support_agentGUC-a postavljenog samo na support ruta-ma, nakon što jerequirePermission(principal, "support_ticket:read")/"support_ticket:reply"već prošao. - Reply/triage polja na tiketu preko ograničenog UPDATE-a:
status,resolution_note,triage_json,external_ref,updated_at— kolonski GRANT (V134 Part D), ne cijeli red.
4. Šta support agent NE VIDI
- Finance/payroll/accounting/settings — nula permisija dodijeljeno support_agent roli van
support_ticket:read/support_ticket:reply(V133 Part C/D, provjereno grep-om — nema trećeg reda urole_permissionsza tu rolu). - RLS-nivo dokaz (Faza A evidence, V134MigrationTest test 5): sa
app.is_support_agent=truepostavljenim, pokušaj pristupaorganizations,invoices,expenses,employees,payslips,financial_audit_log— svih šest odbijeno. Dva različita, oba tačna načina odbijanja nađena: "permission denied" (organizations/invoices/expenses — nula GRANT za bilko_app na tim tabelama uopšte, prethodno postojeći, nepovezan gap) i "0 redova" (employees/payslips/financial_audit_log — GRANT postoji preko V101/V124, ali nijedna politika na tim tabelama ne konsultujeis_support_agent, pa RLS ispravno filtrira sve). - Ostali ticket route-ovi: support_agent NE može pristupiti
GET /admin/support/tickets/{id}niPATCHpreko svoje role — ti route-ovi ostaju gated sarequireSupportTriageService()(odvojen guard odsupport_agentrole-flow-a, nepromijenjen ovim MVP-om).
5. RBAC permission-key referenca
| Permission key | Značenje | Format |
|---|---|---|
support_ticket:read | Čitanje/listanje tiketa preko svih organizacija | colon-format (resource:verb) |
support_ticket:reply | Odgovor / triage tiketa (status + resolution_note) preko svih organizacija | colon-format (resource:verb) |
Zašto colon, ne dot: dispatch je originalno tražio support.tickets.read/support.tickets.reply (dot-notation). V67-ova permission_key_format CHECK constraint (key ~ '^[a-z_]+:[a-z_]+$') zahtijeva tačno jednu dvotačku, bez tačaka — svaki postojeći ključ u katalogu (npr. invoice:read, expense:create) već poštuje taj format. Umjesto proširenja constraint-a za jednu feature-preferencu imena, korišteno je support_ticket:read/support_ticket:reply (singular resource, isti obrazac).
6. RLS cross-org izuzetak — zašto i kako ograničen
Zašto postoji izuzetak uopšte: support osoblje MORA vidjeti tikete preko svih organizacija da bi triage funkcionisao — jedan support agent opslužuje sve klijente, ne samo jednu org. Standardni Bilko RLS model (org_id-scoped) bi u tom slučaju blokirao support agenta da vidi ijedan tiket van svoje (interne) org-e.
Kako je ograničen (ključna crvena-zona odluka, Momjian, MC #106051):
- Nova, uska GUC:
app.is_support_agent— NIJE ponovna upotreba postojećeapp.is_platform_adminGUC-e.support_ticketsje već imaosupport_tickets_admin_allpolitiku (V73) gated naapp.is_platform_admin— ISTU GUC koju konsultuju ipurge_worker_log,document_retention_manifest,financial_audit_log. Da je support_agent-ov cross-org pristup implementiran postavljanjemapp.is_platform_admin, ista transakcija bi automatski zadovoljila i sve DRUGE tabele gated na tu GUC — tiho dodjeljujući support agentu pristup retention manifestima, purge logovima, finansijskim audit trailovima. Točno ono što ovaj task postoji da SPRIJEČI. - Sealed-class ekskluzivnost: nova GUC se konsultuje SAMO od strane dvije nove politike na
support_tickets(support_tickets_support_agent_select,support_tickets_support_agent_reply) — nikad od bilo koje druge tabele/politike u ovom repou. Verifikovano live testom koji grep-ujepg_policieskatalog za string"is_support_agent"i tvrdi nula pogodaka vansupport_tickets(V134MigrationTest, "6 - GUC ISOLATION"). - SAMO support_tickets + support_ticket_messages — nikad finance. RLS izuzetak pokriva tačno dvije tabele iz ovog MVP-a; nijedna finance/payroll/accounting tabela nije dirana.
- Ko postavlja GUC: route sloj (
SupportTicketRoutes.kt, B2), unutartransaction {}bloka, SAMO NAKON što jerequireSupportAgent()već vratio validan principal (dakle samo za zahtjev koji je nezavisno dokazaosupport_ticket:read/replypermisiju) — GUC nikad sama nije autorizaciona odluka, samo downstream RLS mehanizam. - Provjera curenja kroz connection pool (top adversarial pitanje, B faza):
SupportAgentGucPoolTest.kt— HikariCP pool size 1 (forsira reuse konekcije) +isAutoCommit = false(isti kao produkcijska konfiguracija). Test 2: odmah nakon agent zahtjeva na ISTOJ pooled konekciji, sljedeća customer-scoped transakcija NE nasljeđuje cross-org vidljivost, i direktnacurrent_setting('app.is_support_agent', true)provjera na početku te transakcije čita NULL/prazno. Test 4:app.is_platform_adminse nikad ne postavlja na support_agent putu — nema unakrsne kontaminacije GUC-ova. - Kolonski GRANT, ne FOR ALL: support_agent-ov UPDATE je ograničen na tačno kolone koje postojeći PATCH triage flow piše (status, resolution_note, triage_json, external_ref, updated_at) — preko GRANT UPDATE (kolona-lista), isti mehanizam koji V125/V132 već koriste za
bilko_purge_worker. Postgres nema kolonski-scoped RLS, pa RLS odlučuje KOJI REDOVI, GRANT odlučuje KOJE KOLONE.
7. Otvoreni tech-debt
- #106070 — (prati se odvojeno, van scope-a ovog MVP-a; provjeriti
mc.js show 106070za trenutni status prije referenciranja detalja). - #106072 — (isto, van scope-a; provjeriti
mc.js show 106072). - #106075 — (isto, van scope-a; provjeriti
mc.js show 106075). - Poznat, označen (ne skriven) gap iz B faze:
support_tickets_customer_select(V73) cast-ujeapp.current_org_iddirektno na::uuidbezNULLIFguard-a, baca sirovi exception umjesto čistog deny kad je ta GUC zaista nepostavljena. Zaobiđeno u testu (postavlja throwaway validan UUID na agent putu, koji tu GUC nikad stvarno ne treba), NE popravljeno — van scope-a ovog taska, već praćeno. - Pre-existing GRANT gap na support_tickets (nađen i ispravljen u V134, ne bio prije praćen kao poseban ticket): tabela nije imala eksplicitan GRANT SELECT/INSERT za
bilko_appuopšte — nevidljivo u produkciji jer Flyway/API oboje konektuju kaobilko_admin(BYPASSRLS). V134 dodaje nedostajući GRANT, što čini i STARE V73 politike (customer/admin) stvarno dostupnim zabilko_appulogu, ne samo nove.
8. Merge status (na dan pisanja ove stranice)
Branch feat/106051-support-agent-rbac (azdo), tip 5c06350a. NIJE merge-ovan u azdo/main. Faza A (Parisa adversarial verify) i Faza B (GUC-execution adversarial pass) oboje još čekaju Parisa Tabriz-ov nezavisni pregled prije merge-a — to je eksplicitni merge-gate koji su i Momjian i CodeCraft ostavili otvorenim u svojim verdiktima (vidi ~/system/evidence/106051/verdict.md i ~/system/evidence/106055/verdict.md).