Architecture
Detailed architecture documentation — HLD, LLD, ADRs, Integration, Database
- Overview
- High-Level Design (HLD)
- System Context
- Container Diagram
- Component Overview
- Deployment Architecture
- Security Architecture
- Data Architecture
- Low-Level Design (LLD)
- Login & Authentication Flow
- Login & Authentication (Backend)
- Registration & Onboarding Flow
- KYC & AML Flow
- Bank Account Linking Flow
- QR Payment Flow
- Remittance Flow
- Merchant Onboarding Flow
- Transaction History Flow
- Profile & Settings Flow
- Notifications Flow
- Low-Level Design Document
- LLD: Withdrawal Flow
- LLD: Middleware Lifecycle Flow
- Architecture Decision Records (ADR)
- Architecture Decision Record — ADR-013
- ADR-001: Consolidate Backends
- ADR-002: Separate FonTelePay
- ADR-003: PSD2 Pass-Through Model
- ADR-004: JWT HTTPOnly Cookies
- ADR-005: Monolith First
- ADR-006: SQLite to PostgreSQL
- ADR-007: BankID OIDC Auth
- ADR-008: Hono API Framework
- ADR-009: Feature Flag System
- ADR-010: Dual Database Driver
- ADR-011: Expo Mobile Framework
- ADR-012: AWS App Runner Deploy
- ADR Overview
- Integration Specifications
- BankID OIDC Integration
- Open Banking (AISP/PISP)
- Payment Processing
- Sentry Observability
- Sumsub KYC Integration
- AISP Registration Serbia — NBS Response (Tok)
- Database Architecture
- High-Level Design Document
- Low-Level Design (LLD)
- Architecture Decision Record (ADR)
- Module Design Document
- Integration Design Document
- Data Flow Document
- API Specification
- Database Schema Document
- High-Level Design (HLD)
- Module Design
- Integration Design
Overview
Architecture documentation index
Architecture Overview
Drop Architecture Documentation
Project: Drop -- Fintech Payment App (Remittance + QR Payments) Model: PSD2 Pass-through (AISP + PISP) -- Drop never holds customer funds Last updated: 2026-02-21
Reading Guides by Audience
Executive Summary
Start here for a high-level understanding of Drop's architecture and regulatory position.
- System Context (C4 Level 1) -- Drop and all external actors (banks, BankID, regulators)
- Security Architecture -- Security controls and threat model
- ADR-003: PSD2 Pass-through Model -- Why Drop never holds money
Architect Track
For a complete understanding of system design and key decisions.
- High-Level Design (C4)
- System Context (C4 Level 1) -- External actors and trust boundaries
- Container Diagram (C4 Level 2) -- Internal containers and data flow
- Component Overview (C4 Level 3) -- Module-level breakdown
- Cross-Cutting Concerns
- Security Architecture -- Auth, encryption, input validation
- Data Architecture -- Data flow, storage, and processing
- Deployment Architecture -- AWS App Runner, Cloudflare, CI/CD
- Architecture Decision Records
- ADR Index -- All 12 ADRs with template
- Integration Specifications
- Open Banking AISP/PISP -- PSD2 banking integration
- BankID OIDC Integration -- Norwegian eID authentication
- Sumsub KYC Integration -- Identity verification
- Payment Processing -- Remittance and QR payment flows
- Sentry Observability -- Error tracking and monitoring
Developer Track
For implementing features and understanding code flows.
- Low-Level Designs (User Flows)
- Login Authentication -- BankID login flow (frontend)
- Login Authentication Backend -- Auth backend implementation
- Registration & Onboarding -- New user registration
- Bank Account Linking -- AISP consent and account linking
- Remittance -- International money transfer flow
- QR Payment -- Merchant QR payment flow
- Transaction History -- Transaction listing and filtering
- Merchant Onboarding -- Business registration flow
- Profile & Settings -- User preferences management
- Notifications -- Push notification flow
- Database
- Database Design -- Schema rationale and entity relationships
- Migration Strategy -- SQLite to PostgreSQL migration
- Data Lifecycle -- Retention, archival, and deletion policies
- Audit Architecture -- Compliance audit trail design
- Indexing Strategy -- Database performance optimization
- Key ADRs for Developers
- ADR-004: JWT httpOnly Cookies -- Token storage security
- ADR-006: SQLite to PostgreSQL -- SUPERSEDED by ADR-014
- ADR-008: Hono API Framework -- Mobile API choice
- ADR-009: Feature Flag System -- Runtime feature toggles
- ADR-010: Dual Database Driver -- SUPERSEDED by ADR-014
- ADR-014: PostgreSQL-Only Architecture -- PostgreSQL 16 + Drizzle ORM (all environments)
Compliance Track
For regulatory compliance review and audit preparation.
- Security Architecture -- Authentication, authorization, data protection
- KYC/AML Flow -- Customer due diligence, transaction monitoring, STR filing
- Data Lifecycle -- GDPR retention policies, right to erasure
- Audit Architecture -- Audit trail for regulatory reporting
- Registration & Onboarding -- Consent collection, age verification
- ADR-003: PSD2 Pass-through -- Regulatory model decision
- ADR-007: BankID OIDC Auth -- SCA compliance
Document Index
High-Level Design (HLD)
| Document | Description | Key Diagrams |
|---|---|---|
| System Context (C4 L1) | Drop and all external actors, trust boundaries, compliance zones | C4 context diagram, trust boundary diagram |
| Container Diagram (C4 L2) | Internal containers: Next.js BFF, Hono API, databases, edge | C4 container diagram, data flow diagram |
| Component Overview (C4 L3) | Module-level breakdown: auth, transactions, merchants, compliance | Component diagrams per module |
| Security Architecture | Auth, encryption, input validation, rate limiting, headers | Security layer diagram, threat model |
| Data Architecture | Data flow, storage tiers, processing pipeline | Data flow diagram, storage topology |
| Deployment Architecture | AWS App Runner, Cloudflare, CI/CD pipeline, environments | Deployment topology, CI/CD pipeline |
Architecture Decision Records (ADR)
| ADR | Title | Category | Status |
|---|---|---|---|
| ADR-001 | Consolidate to Single Backend | Architecture | Accepted |
| ADR-002 | Separate FontelePay from Drop | Architecture | Accepted |
| ADR-003 | PSD2 Pass-through Model (No Wallet) | Architecture | Accepted |
| ADR-004 | JWT in httpOnly Cookies | Security | Accepted |
| ADR-005 | Monolith-First Architecture | Architecture | Accepted |
| ADR-006 | SQLite Dev, PostgreSQL Prod | Database | Superseded by ADR-014 |
| ADR-007 | BankID as Sole Auth Provider | Security | Accepted |
| ADR-008 | Hono v4 for Mobile API | Backend | Accepted |
| ADR-009 | Custom Feature Flag System | Backend | Accepted |
| ADR-010 | Dual Database Driver Abstraction | Database | Superseded by ADR-014 |
| ADR-011 | Expo SDK 54 for Mobile | Mobile | Accepted |
| ADR-012 | AWS App Runner Deployment | Infrastructure | Accepted |
| ADR-014 | PostgreSQL-Only Architecture (Drizzle ORM) | Database | Accepted |
Integration Specifications
| Document | Description | External System |
|---|---|---|
| Open Banking AISP/PISP | PSD2 account access and payment initiation | Nordic banks (DNB, SpareBank1, Nordea) |
| BankID OIDC Integration | Norwegian eID authentication and SCA | BankID Norge |
| Sumsub KYC Integration | Document verification and screening | Sumsub |
| Payment Processing | Remittance and QR payment orchestration | Banks, SEPA, SWIFT |
| Sentry Observability | Error tracking and performance monitoring | Sentry |
Low-Level Design (LLD) -- User Flows
| Document | Description | Key Endpoints |
|---|---|---|
| Login Authentication | BankID OIDC login (frontend) | /api/auth/bankid, /api/auth/bankid/callback |
| Login Auth Backend | Auth backend: JWT, sessions, revocation | lib/auth.ts, middleware/auth.ts, middleware/rate-limit.ts |
| Registration & Onboarding | New user registration, consent, bank linking | /api/auth/bankid, /api/consents |
| Bank Account Linking | AISP consent and account sync | Open Banking AISP APIs |
| Remittance | International money transfer | POST /api/transactions/remittance |
| QR Payment | Merchant QR scan and pay | POST /api/transactions/qr-payment |
| Transaction History | Transaction listing with filters | GET /api/transactions |
| KYC/AML | KYC verification and AML monitoring | Sumsub webhooks, aml_alerts, str_reports |
| Merchant Onboarding | Business registration and QR setup | POST /api/merchants/register |
| Profile & Settings | User preferences and account management | GET/PATCH /api/settings |
| Notifications | Push notification delivery | GET/PATCH /api/notifications |
Database Architecture
| Document | Description |
|---|---|
| Database Design | Schema rationale, entity relationships, 19-table overview |
| Migration Strategy | Historical SQLite to PostgreSQL migration record (completed; see ADR-014) |
| Data Lifecycle | GDPR retention, AML record keeping, deletion policies |
| Audit Architecture | audit_log table design and compliance trail |
| Indexing Strategy | Performance indexes for queries, compliance, and reporting |
Related Documentation
| Document | Location | Description |
|---|---|---|
| Main Architecture Document | project/architecture/ |
Original architecture overview (v1.1) |
| API Reference | docs/backend/ |
All 24+ API endpoints |
| Database Schema | docs/backend/ |
Full table definitions |
| Authentication System | docs/backend/ |
BankID OIDC implementation |
| Middleware | docs/backend/ |
Auth, rate limiting, validation |
| Feature Flags | docs/backend/ |
Runtime feature toggles |
| Security Architecture | docs/security/ |
Security controls reference |
| Compliance Status | docs/security/ |
Regulatory compliance tracking |
| Roadmap | Root | Product roadmap and phases |
Architecture Principles
- Pass-through model: Drop never holds customer funds. All payments initiated via PISP from user's bank.
- Security-first: httpOnly cookies, parameterized SQL, BankID SCA, rate limiting on all public endpoints.
- Compliance by design: Audit logging, consent tracking, AML monitoring built into the data model.
- Monolith-first: Simple deployment, extract services only when scaling demands it.
- PostgreSQL-only: PostgreSQL 16 in all environments (dev, CI, staging, production) via Drizzle ORM. See ADR-014.
- Feature-flagged: Unreleased features (cards) safely gated; critical features can be killed instantly.
High-Level Design (HLD)
System-level architecture diagrams and design
System Context
System Context Diagram (C4 Level 1)
Document: HLD-001 Status: Approved Last updated: 2026-02-21 Author: Standards Architect Applies to: Drop v1.0 (PSD2 pass-through model)
Overview
This document describes the C4 Level 1 system context for Drop, showing Drop as the central system and all external actors, systems, and regulatory bodies it interacts with. Drop operates as a PSD2 pass-through payment application -- it never holds customer funds. User money remains in their bank account at all times.
System Context Diagram
graph TB
subgraph actors["External Actors"]
sender["Sender<br/>(Norwegian Resident, 18+)<br/>Sends money abroad via PISP"]
receiver["Receiver<br/>(30+ countries)<br/>Receives remittance"]
merchant["Merchant<br/>(Norwegian Business)<br/>Accepts QR payments"]
end
subgraph drop_system["Drop Payment System"]
drop["Drop<br/>Next.js 15 + Hono v4<br/>PSD2 Pass-through App<br/>(AISP + PISP)"]
end
subgraph banking["Banking & Open Banking"]
bankid["BankID Norway<br/>OIDC Identity Provider<br/>Strong Customer Authentication"]
nordic_banks["Nordic Banks<br/>(DNB, SpareBank1, Nordea)<br/>Open Banking APIs<br/>AISP: Read balance<br/>PISP: Initiate payment"]
payment_rails["Payment Rails<br/>SEPA (EEA)<br/>SWIFT (non-EEA)<br/>Remittance corridors"]
end
subgraph compliance["Compliance & KYC"]
sumsub["Sumsub<br/>KYC/AML Provider<br/>Document verification<br/>PEP/sanctions screening"]
finanstilsynet["Finanstilsynet<br/>Norwegian FSA<br/>PISP/AISP registration<br/>Regulatory oversight"]
okokrim["Okokrim / EFE<br/>Financial Intelligence Unit<br/>STR/SAR filing"]
end
subgraph infrastructure["Infrastructure"]
aws["AWS App Runner<br/>Container hosting<br/>Auto-scaling"]
cloudflare["Cloudflare<br/>CDN, DDoS protection<br/>DNS, TLS termination"]
sentry["Sentry<br/>Error tracking<br/>Performance monitoring"]
end
%% Actor interactions
sender -->|"BankID login\nView balance (AISP)\nSend money (PISP)\nQR payments"| drop
receiver -.->|"Receives funds\n(via bank transfer)"| payment_rails
merchant -->|"Register business\nView dashboard\nGenerate QR code"| drop
%% Banking integrations
drop -->|"OIDC authorize\nID token verification\nAge/identity check"| bankid
drop -->|"AISP: GET /accounts\nAISP: GET /balances\nPISP: POST /payments"| nordic_banks
drop -->|"PISP payment routing\nSEPA for EEA\nSWIFT for non-EEA"| payment_rails
%% Compliance integrations
drop -->|"Applicant creation\nDocument upload\nWebhook results"| sumsub
drop -.->|"License registration\nRegulatory reporting\nCompliance audits"| finanstilsynet
drop -.->|"STR filing\n(hvitvaskingsloven)"| okokrim
%% Infrastructure
drop -->|"Deploy containers\nAuto-scale"| aws
drop -->|"DNS routing\nTLS, WAF\nDDoS protection"| cloudflare
drop -->|"Error events\nPerformance traces"| sentry
%% Bank to payment rails
nordic_banks -->|"Execute transfers"| payment_rails
classDef actorStyle fill:#E3F2FD,stroke:#1565C0,stroke-width:2px,color:#0D47A1
classDef systemStyle fill:#0B6E35,stroke:#064E25,stroke-width:3px,color:#FFFFFF
classDef bankingStyle fill:#FFF3E0,stroke:#E65100,stroke-width:2px,color:#BF360C
classDef complianceStyle fill:#FCE4EC,stroke:#C62828,stroke-width:2px,color:#B71C1C
classDef infraStyle fill:#F3E5F5,stroke:#6A1B9A,stroke-width:2px,color:#4A148C
class sender,receiver,merchant actorStyle
class drop systemStyle
class bankid,nordic_banks,payment_rails bankingStyle
class sumsub,finanstilsynet,okokrim complianceStyle
class aws,cloudflare,sentry infraStyle
Trust Boundaries
graph TB
subgraph tb_user["TRUST BOUNDARY: User Device (Untrusted)"]
browser["Web Browser<br/>(Next.js SSR + CSR)"]
mobile["Mobile App<br/>(Expo SDK 54)"]
end
subgraph tb_drop["TRUST BOUNDARY: Drop Application (Controlled)"]
subgraph dmz["DMZ — Edge"]
cf["Cloudflare<br/>WAF + CDN + DDoS"]
end
subgraph app["Application Layer"]
nextjs["Next.js BFF<br/>Web auth, SSR"]
hono["Hono API<br/>Mobile auth, REST"]
end
subgraph data["Data Layer"]
pg["PostgreSQL<br/>(production)"]
sqlite["SQLite<br/>(development)"]
end
end
subgraph tb_banking["TRUST BOUNDARY: Banking Partners (External Trusted)"]
bankid_tb["BankID OIDC"]
openbanking["Open Banking APIs"]
end
subgraph tb_compliance["TRUST BOUNDARY: Compliance Partners (External Trusted)"]
sumsub_tb["Sumsub KYC"]
end
subgraph tb_regulator["TRUST BOUNDARY: Regulatory (Government)"]
fsa["Finanstilsynet"]
efe["Okokrim / EFE"]
end
browser --> cf
mobile --> cf
cf --> nextjs
cf --> hono
nextjs --> pg
nextjs --> sqlite
hono --> pg
hono --> sqlite
nextjs --> bankid_tb
hono --> bankid_tb
nextjs --> openbanking
hono --> openbanking
nextjs --> sumsub_tb
hono --> sumsub_tb
nextjs -.-> fsa
nextjs -.-> efe
classDef untrusted fill:#FFCDD2,stroke:#C62828,stroke-width:2px
classDef controlled fill:#C8E6C9,stroke:#2E7D32,stroke-width:2px
classDef external fill:#FFF9C4,stroke:#F9A825,stroke-width:2px
classDef regulator fill:#E1BEE7,stroke:#6A1B9A,stroke-width:2px
class browser,mobile untrusted
class cf,nextjs,hono,pg,sqlite controlled
class bankid_tb,openbanking,sumsub_tb external
class fsa,efe regulator
External Actors
End Users
| Actor | Description | Authentication | Data Exchanged |
|---|---|---|---|
| Sender | Norwegian resident (18+) who sends money abroad or pays merchants via QR | BankID OIDC (SCA) | Personal data, bank account info (AISP), payment instructions (PISP) |
| Receiver | Person in 30+ countries who receives remittance | None (indirect) | Receives bank transfer via payment rails |
| Merchant | Norwegian business accepting QR payments | BankID OIDC + merchant registration | Business details, org number, transaction data, payout info |
Banking & Payment Systems
| System | Protocol | Data Flow | Trust Level |
|---|---|---|---|
| BankID Norway | OIDC 2.0 (authorize, token, JWKS endpoints) | ID tokens with pid (national ID), name, DOB |
High -- Norwegian government-backed eID |
| Nordic Banks (DNB, SpareBank1, Nordea) | PSD2 Open Banking REST APIs | AISP: account list, balances, transactions; PISP: payment initiation, status | High -- regulated financial institutions |
| SEPA (Single Euro Payments Area) | SEPA Credit Transfer (SCT) | EEA remittance transfers (1-2 business days) | High -- ECB-regulated |
| SWIFT | SWIFT gpi | Non-EEA remittance transfers (2-4 business days) | High -- SWIFT-regulated |
Compliance & Regulatory
| System | Integration | Data Flow | Cadence |
|---|---|---|---|
| Sumsub | REST API + Webhooks | Applicant data, document images, verification results, PEP/sanctions matches | On registration + ongoing monitoring |
| Finanstilsynet | Regulatory portal | License applications, compliance reports, incident notifications | Quarterly + ad hoc |
| Okokrim / EFE | AltInn reporting | STR/SAR filings per hvitvaskingsloven | As triggered by AML alerts |
Infrastructure
| System | Role | Protocol | Data Flow |
|---|---|---|---|
| AWS App Runner | Container hosting and auto-scaling | HTTPS, Docker | Application containers, environment variables, logs |
| Cloudflare | Edge security and CDN | DNS, HTTPS, WebSocket | HTTP traffic, TLS termination, DDoS filtering, WAF rules |
| Sentry | Error tracking and APM | HTTPS (SDK) | Error events, performance traces, session replays |
Compliance Zone Mapping
PSD2 (Betalingstjenesteloven)
| Requirement | Drop Component | External System | Status |
|---|---|---|---|
| Strong Customer Authentication (SCA) | Auth flow (/api/auth/bankid/) |
BankID OIDC | Implemented |
| Dynamic linking (amount + payee tied to auth) | Payment confirmation screen | BankID SCA challenge | Phase 2 |
| AISP consent and access | Bank account linking flow | Nordic bank Open Banking APIs | Phase 2 |
| PISP payment initiation | Remittance + QR payment flows | Nordic bank Open Banking APIs | Phase 2 |
| Framework agreement (vilkar) | landing/pages/vilkar.html |
-- | Draft exists |
| Pre-transaction fee disclosure | POST /api/transactions/disclosure |
-- | Implemented |
GDPR (Personopplysningsloven)
| Requirement | Drop Component | Implementation |
|---|---|---|
| Lawful basis for processing | consents table |
Consent tracking with IP + timestamp |
| Right to access (Art. 15) | GET /api/user/data-export |
Full data export in JSON |
| Right to erasure (Art. 17) | DELETE /api/user/account |
Soft delete, 5yr AML retention |
| Data minimization (Art. 5) | Schema design | Only necessary fields stored |
| Data portability (Art. 20) | GET /api/user/data-export |
Machine-readable JSON export |
| Processing register (Art. 30) | data_access_requests table |
Tracks all DSAR requests |
| DPIA (Art. 35) | legal/dpia-vurdering.md |
Draft completed |
AML / KYC (Hvitvaskingsloven)
| Requirement | Drop Component | External System |
|---|---|---|
| Customer Due Diligence (CDD) | User registration + KYC flow | Sumsub (document verification) |
| Enhanced Due Diligence (EDD) | screening_results table |
Sumsub (PEP/sanctions screening) |
| Transaction monitoring | aml_alerts table |
Internal rules engine |
| Suspicious Transaction Reporting | str_reports table |
Okokrim / EFE via AltInn |
| Record keeping (5 years) | All compliance tables | PostgreSQL with retention policies |
| Risk assessment | users.risk_level field |
Sumsub risk scoring |
DORA (Digital Operational Resilience Act)
| Requirement | Drop Component | Implementation |
|---|---|---|
| ICT risk management | legal/ikt-sikkerhetspolicy.md |
Policy drafted |
| Incident reporting | legal/hendelseshaandtering.md |
Incident handling procedure |
| Resilience testing | Planned penetration test | Phase 3 |
| Third-party risk management | legal/utkontraktering-policy.md |
Outsourcing policy drafted |
| Business continuity | legal/beredskapsplan.md |
BCP drafted |
Data Flow Summary
| Flow | Source | Destination | Data | Protocol | Encryption |
|---|---|---|---|---|---|
| User authentication | Browser/Mobile | BankID | OIDC auth request, state, nonce | HTTPS | TLS 1.3 |
| Identity verification | Drop | BankID | Authorization code exchange | HTTPS | TLS 1.3 |
| Balance read (AISP) | Drop | Nordic Bank | Account ID, consent token | PSD2 Open Banking API | TLS 1.3 + OAuth2 |
| Payment initiation (PISP) | Drop | Nordic Bank | Amount, recipient, consent | PSD2 Open Banking API | TLS 1.3 + OAuth2 + SCA |
| KYC verification | Drop | Sumsub | Applicant data, documents | REST API + Webhooks | TLS 1.3 + API key |
| STR filing | Drop | Okokrim | Suspicious transaction report | AltInn portal | TLS 1.3 + certificate |
| Error tracking | Drop | Sentry | Error events, stack traces | HTTPS SDK | TLS 1.3 + DSN token |
| Web traffic | User | Cloudflare -> Drop | HTTP requests/responses | HTTPS | TLS 1.3 (edge + origin) |
Cross-References
- Container Diagram (C4 Level 2) -- Internal container breakdown
- Security Architecture -- Detailed security controls
- BankID OIDC Integration -- Authentication integration spec
- Open Banking AISP/PISP -- Banking integration spec
- Sumsub KYC Integration -- KYC provider integration
- ADR-003: PSD2 Pass-through Model -- Foundational architecture decision
- ADR-007: BankID OIDC Auth -- Authentication provider decision
Container Diagram
C4 Level 2 — Container Diagram
Drop fintech platform container architecture showing all runtime containers, their responsibilities, communication patterns, and the middleware chain that governs every API request.
Container Diagram
C4Container
title Drop — Container Diagram (C4 Level 2)
Person(user, "End User", "Norwegian resident 18+, authenticated via BankID")
Person(merchant, "Merchant", "Business owner receiving QR payments")
System_Boundary(drop, "Drop Platform") {
Container(web, "drop-web", "Next.js 15, React 19, Tailwind v4", "Server-side rendered web application. Handles login redirect, dashboard, send money, QR scan, bank accounts, transaction history, notifications, settings, merchant dashboard.")
Container(api, "drop-api", "Hono v4, Node.js 22", "REST API server. 26+ endpoints under /v1/. BankID OIDC callback, transaction processing, recipient management, merchant registration, GDPR compliance, admin operations.")
Container(mobile, "drop-mobile", "Expo SDK 54, React Native", "Native mobile app for iOS and Android. BankID auth via expo-web-browser deep linking. AsyncStorage for token persistence. No offline support.")
ContainerDb(db, "Database", "PostgreSQL 16 (all environments)", "19 tables: 12 core (users, transactions, bank_accounts, sessions, merchants, recipients, etc.) + 7 compliance (audit_log, aml_alerts, str_reports, screening_results, consents, data_access_requests, complaints). Drizzle ORM.")
}
System_Ext(bankid, "BankID OIDC", "Norwegian eID provider. OIDC authorize/token/JWKS endpoints for Strong Customer Authentication.")
System_Ext(sumsub, "Sumsub", "KYC/AML identity verification. WebSDK (web), React Native SDK (mobile), webhooks for status updates.")
System_Ext(openbanking, "Open Banking APIs", "PSD2 AISP (read balances) and PISP (initiate payments) via licensed provider.")
System_Ext(sepa, "SEPA/SWIFT Networks", "International payment rails for remittance settlement to 30+ countries.")
Rel(user, web, "HTTPS", "Browser")
Rel(user, mobile, "HTTPS", "Native app")
Rel(merchant, web, "HTTPS", "Merchant dashboard")
Rel(web, api, "HTTPS REST", "/v1/* endpoints, JSON, Bearer token or httpOnly cookie")
Rel(mobile, api, "HTTPS REST", "/v1/* endpoints, JSON, Bearer token")
Rel(api, db, "SQL", "Type-safe queries via Drizzle ORM (src/shared/db/)")
Rel(api, bankid, "OIDC", "Authorization code flow, JWKS token verification")
Rel(api, sumsub, "REST + Webhooks", "Applicant creation, document checks, status webhooks")
Rel(api, openbanking, "PSD2 API", "AISP balance reads, PISP payment initiation with SCA")
Rel(api, sepa, "ISO 20022", "Remittance settlement via banking partner")
Container Responsibilities
| Container | Technology | Responsibilities | Port |
|---|---|---|---|
| drop-web | Next.js 15, React 19, Tailwind v4 | SSR web app, BankID redirect initiation, UI rendering for all 10 screens (Login, Onboarding, Dashboard, SendMoney, BankAccounts, TransactionHistory, ScanQR, Profile, Notifications, MerchantDashboard) | 3000 |
| drop-api | Hono v4, Node.js 22 Alpine | REST API, BankID OIDC callback handling, JWT session management, transaction processing, GDPR endpoints, admin operations, audit logging | 3001 |
| drop-mobile | Expo SDK 54, React Native | iOS/Android native app, BankID via expo-web-browser + deep link (drop://auth/callback), AsyncStorage for token persistence, push notifications |
N/A |
| Database | PostgreSQL 16 (all environments) | 19 tables, foreign keys enforced. Drizzle ORM schema in src/shared/db/schema.ts. Local: Docker port 5433. Production: AWS RDS. |
5432 |
Request Lifecycle
sequenceDiagram
participant Client as Client (Web/Mobile)
participant CORS as CORS Middleware
participant ReqID as Request ID Middleware
participant IP as Client IP Middleware
participant RL as Rate Limiter
participant Auth as Auth Middleware
participant Route as Route Handler
participant DB as Database
participant ErrH as Error Handler
Client->>+CORS: HTTPS Request
CORS->>CORS: Validate Origin against allowlist
CORS->>+ReqID: Pass if origin allowed
ReqID->>ReqID: Extract x-request-id or generate UUID
ReqID->>ReqID: Set x-request-id response header
ReqID->>+IP: Forward request
IP->>IP: Extract IP from x-real-ip / x-forwarded-for
IP->>IP: Set clientIp context variable
alt Rate-limited endpoint
IP->>+RL: Forward to rate limiter
RL->>DB: SELECT count, reset_at FROM rate_limits WHERE key = ?
DB-->>RL: Current count
alt Under limit
RL->>RL: UPDATE count + 1
RL->>+Auth: Forward request
else Over limit
RL-->>Client: 429 Too Many Requests
end
else Non-rate-limited endpoint
IP->>+Auth: Forward request
end
alt Authenticated endpoint
Auth->>Auth: Extract token (Bearer header or drop_token cookie)
Auth->>Auth: Verify JWT (jose, HS256/RS256)
Auth->>DB: SELECT session (check revoked = 0, expires_at > now)
DB-->>Auth: Session record
Auth->>DB: SELECT user WHERE id = ? AND deleted_at IS NULL
DB-->>Auth: User record
Auth->>Auth: Set user context variable
Auth->>+Route: Forward authenticated request
else Public endpoint
IP->>+Route: Forward directly
end
Route->>DB: Business logic queries (parameterized)
DB-->>Route: Query results
Route-->>Client: JSON response { data: {...} }
Note over ErrH: On any unhandled error
Route-->>ErrH: Error thrown
ErrH->>ErrH: Log error, capture in Sentry
ErrH-->>Client: { error: "internal_error", message: "..." }
Middleware Chain
The Hono v4 API (drop-api) applies middleware in the following order for every request:
| Order | Middleware | Source | Purpose |
|---|---|---|---|
| 1 | CORS | hono/cors in app.ts:23-30 |
Validates Origin header against allowlist (localhost:3000, localhost:3001, APP_URL). Sets credentials: true for cookie transport. |
| 2 | Request ID | app.ts:33-38 |
Reads x-request-id header or generates crypto.randomUUID(). Sets on context and response header for distributed tracing. |
| 3 | Client IP | app.ts:41-47 |
Extracts IP from x-real-ip then x-forwarded-for (first in chain), falls back to 127.0.0.1. Stored in context for rate limiting and audit. |
| 4 | Rate Limiter | middleware/rate-limit.ts |
Per-IP rate limiting backed by rate_limits DB table. Configurable limit and window per route. Cleans expired entries every 100 calls. |
| 5 | Auth | middleware/auth.ts |
Extracts JWT from Authorization: Bearer header or drop_token cookie. Verifies signature (jose HS256/RS256), checks session not revoked, loads user record. |
| 6 | Merchant | middleware/auth.ts:21-29 |
Standalone middleware that independently verifies auth (calls extractToken and verifyAndGetUser) and checks user.role === 'merchant'. Does NOT extend or chain authMiddleware. Returns 403 if not merchant. |
| 7 | Global Error Handler | middleware/error-handler.ts |
Catches all unhandled errors. HTTPException returns structured JSON with status. Other errors return 500, log to stdout, and capture in Sentry. |
Rate Limit Configuration
| Endpoint Group | Limit | Window | Source |
|---|---|---|---|
| BankID initiate | 10 req | 60s | routes/auth.ts:19 |
| BankID callback | 10 req | 60s | routes/auth.ts:43 |
| Remittance | 10 req/60s per-IP + 3 req/60s per-user | 60s | routes/transactions.ts |
| QR Payment | 10 req/60s per-IP + 3 req/60s per-user | 60s | routes/transactions.ts |
| Exchange rates | 120 req | 60s | routes/rates.ts |
Communication Patterns
Web Client to API
The Next.js web app communicates with the Hono API over HTTPS REST:
- Authentication: httpOnly cookie (
drop_token) set on BankID callback redirect. Cookie attributes:HttpOnly,Path=/,Max-Age=604800(7 days),SameSite=Lax. - CSRF protection: CORS origin validation +
SameSitecookie attribute. - Content type:
application/jsonfor all request/response bodies. - Error envelope:
{ error: "code", message: "human-readable", details: [...] }.
Mobile Client to API
The Expo mobile app uses Bearer token authentication:
- Token storage:
AsyncStorage(React Native encrypted storage). - Auth header:
Authorization: Bearer <jwt>. - BankID flow:
expo-web-browseropens BankID authorize URL, redirects back via deep linkdrop://auth/callback?code=&state=. - Token refresh:
POST /v1/auth/refresh— revokes old sessions, issues new JWT, sets cookie (web) and returns token in body (mobile).
API to Database
- Abstraction layer:
db.tsprovidesquery(),getOne(),run(),runIgnore(),runUpsert(),transaction(). - Driver detection:
DATABASE_URLenv var present = PostgreSQL viapg.Pool, absent = SQLite viabetter-sqlite3. - SQL compatibility: Automatic conversion of SQLite dialect to PostgreSQL (placeholders
?to$N,datetime('now')toCURRENT_TIMESTAMP,INSERT OR IGNOREtoON CONFLICT DO NOTHING). - Transaction isolation: SQLite uses
BEGIN/COMMIT/ROLLBACKon the single connection. PostgreSQL uses pool client with explicit transaction.
API to External Services
| Service | Protocol | Authentication | Data Flow |
|---|---|---|---|
| BankID OIDC | HTTPS (OpenID Connect) | Client ID + Client Secret | Auth code exchange, JWKS token verification, pid extraction |
| Sumsub KYC | REST + Webhooks | API key + HMAC signature | Applicant creation, document verification, status webhooks |
| Open Banking | PSD2 REST API | OAuth2 (provider-specific) | AISP balance reads (cached in bank_accounts.balance), PISP payment initiation |
| SEPA/SWIFT | ISO 20022 (via banking partner) | Banking partner credentials | Remittance settlement to 30+ countries |
Cross-References
- API endpoints: API-REFERENCE.md — Full endpoint documentation with request/response examples
- Database schema: DATABASE-SCHEMA.md — All 19 tables with column definitions
- Authentication: AUTHENTICATION.md — BankID OIDC flow, JWT structure, session management
- Middleware: MIDDLEWARE.md — Detailed middleware documentation
- Security: SECURITY-ARCHITECTURE.md — Threat model, security headers, input validation
- Deployment: deployment-architecture.md — AWS + Cloudflare topology, CI/CD pipeline
- Feature flags: FEATURE-FLAGS.md — Runtime feature gating system
Component Overview
Component Overview (C4 Level 3)
Document: HLD-002 Version: 1.0 Date: 2026-02-21 Author: Frontend Architect (AI Agent) Status: Draft Scope: Frontend component architecture for web and mobile applications
1. Purpose
This document provides a C4 Level 3 component view of the Drop frontend, covering both the Next.js web application and the Expo mobile application. It maps the component tree, shared component library, page composition patterns, and design system integration.
2. Web Application Component Architecture
The web application is built with Next.js 15 (App Router) and React 19, using Tailwind CSS v4 for styling and shadcn/ui (Radix UI primitives) for the component library.
2.1 Component Diagram — Web App Structure
graph TD
subgraph "Next.js 15 App Router"
RootLayout["RootLayout<br/>(app/layout.tsx)"]
RootLayout --> CookieConsent["CookieConsent"]
RootLayout --> PWARegister["PWARegister"]
subgraph "Public Pages (No Auth)"
Landing["/ Landing<br/>(Server Component)"]
LoginPage["/login LoginPage"]
RegisterPage["/register RegisterPage"]
TermsPage["/terms TermsPage"]
PrivacyPage["/privacy PrivacyPage"]
FeesPage["/fees FeesPage"]
WithdrawalPage["/withdrawal WithdrawalPage"]
end
subgraph "Authenticated Pages (useAuth)"
Dashboard["/dashboard Dashboard"]
SendMoney["/send SendMoney"]
ScanQR["/scan ScanQR"]
Accounts["/accounts BankAccounts"]
Transactions["/transactions TransactionHistory"]
Profile["/profile ProfileHub"]
Notifications["/notifications NotificationCenter"]
Complaints["/complaints ComplaintForm"]
end
subgraph "Profile Sub-Pages"
ProfilePersonal["/profile/personal"]
ProfileSecurity["/profile/security"]
ProfileNotifications["/profile/notifications"]
ProfileLanguage["/profile/language"]
end
subgraph "Feature-Flagged Pages"
Cards["/cards CardManagement<br/>(FUTURE)"]
end
Profile --> ProfilePersonal
Profile --> ProfileSecurity
Profile --> ProfileNotifications
Profile --> ProfileLanguage
end
subgraph "Shared Components"
BottomNav["BottomNav<br/>(5 tabs)"]
DropLogo["DropLogo / DropWordmark /<br/>DropLogoFull / DropAppIcon"]
PrePaymentDisclosure["PrePaymentDisclosure<br/>(PSD2 modal)"]
end
subgraph "shadcn/ui Primitives"
Button["Button"]
Card["Card"]
Dialog["Dialog"]
Tabs["Tabs"]
ScrollArea["ScrollArea"]
Input["Input"]
Select["Select"]
Badge["Badge"]
Skeleton["Skeleton"]
Sheet["Sheet"]
Separator["Separator"]
Avatar["Avatar"]
Alert["Alert"]
Sonner["Sonner (Toast)"]
end
Dashboard --> BottomNav
Dashboard --> DropLogo
Dashboard --> ScrollArea
Transactions --> BottomNav
Transactions --> Tabs
ScanQR --> BottomNav
Accounts --> BottomNav
Accounts --> Card
Profile --> BottomNav
Notifications --> BottomNav
SendMoney --> PrePaymentDisclosure
Landing --> DropLogoFull["DropLogoFull"]
2.2 Page Composition Pattern
Every authenticated page follows a consistent composition:
+----------------------------------+
| Header (back nav + title) |
+----------------------------------+
| |
| Page Content |
| (scrollable area) |
| |
| |
+----------------------------------+
| BottomNav (fixed, 5 tabs) |
+----------------------------------+
| Tab | Label | Route | Icon |
|---|---|---|---|
| 1 | Hjem | /dashboard |
Home (lucide) |
| 2 | Aktivitet | /history |
Clock (lucide) |
| 3 | Skann | /scan |
IconQrScan (custom) |
| 4 | Kontoer | /accounts |
Landmark (lucide) |
| 5 | Profil | /profile |
User (lucide) |
3. Mobile Application Component Architecture
The mobile application is built with React Native (Expo SDK) using Expo Router for file-based navigation.
3.1 Component Diagram — Mobile App Structure
graph TD
subgraph "Expo Router (React Native)"
RootStack["Root Stack Layout<br/>(app/_layout.js)"]
subgraph "Auth Screens (No Auth)"
Welcome["index.js<br/>Welcome Screen"]
MobileLogin["login.js<br/>Login Screen"]
MobileRegister["register.js<br/>Registration (BankID)"]
end
subgraph "Tab Navigator (4 tabs)"
TabLayout["(tabs)/_layout.js"]
MobileDashboard["(tabs)/index.js<br/>Dashboard / Home"]
MobileSend["(tabs)/send.js<br/>Send Money"]
MobileScan["(tabs)/scan.js<br/>QR Scanner"]
MobileProfile["(tabs)/profile.js<br/>Profile & Settings"]
end
subgraph "Modal Screens"
MobileHistory["history.js<br/>Transaction History"]
end
RootStack --> Welcome
RootStack --> MobileLogin
RootStack --> MobileRegister
RootStack --> TabLayout
RootStack --> MobileHistory
TabLayout --> MobileDashboard
TabLayout --> MobileSend
TabLayout --> MobileScan
TabLayout --> MobileProfile
end
subgraph "Shared Libraries"
APIClient["lib/api.js<br/>Fetch wrapper + Bearer auth"]
Theme["lib/theme.js<br/>Colors, fonts, spacing"]
end
MobileDashboard --> APIClient
MobileSend --> APIClient
MobileScan --> APIClient
MobileProfile --> APIClient
MobileHistory --> APIClient
Tab Bar (Mobile):
| Tab | Label | Icon | Screen |
|---|---|---|---|
| 1 | Hjem | House (Unicode) | Dashboard |
| 2 | Send | Arrow (Unicode) | Send money |
| 3 | QR | QR (Unicode) | QR scanner |
| 4 | Profil | Person (Unicode) | Profile |
4. Shared Component Library
4.1 Custom Drop Components
| Component | File (Web) | Mobile Equivalent | Purpose |
|---|---|---|---|
| BottomNav | components/bottom-nav.tsx |
(tabs)/_layout.js Tab Bar |
Primary navigation |
| DropLogo | components/drop-logo.tsx |
Inline SVG in Welcome | Brand mark (green "d" + gold arrow) |
| DropWordmark | components/drop-logo.tsx |
Fraunces <Text> |
"drop" text in Fraunces font |
| DropLogoFull | components/drop-logo.tsx |
N/A | Mark + wordmark combined |
| DropAppIcon | components/drop-logo.tsx |
N/A | App launcher icon |
| CookieConsent | components/cookie-consent.tsx |
N/A (not applicable) | GDPR consent banner |
| PrePaymentDisclosure | components/pre-payment-disclosure.tsx |
N/A (inline) | PSD2 fee disclosure modal |
| PWARegister | components/pwa-register.tsx |
N/A | Service Worker registration |
4.2 Custom Icons (drop-icons.tsx)
| Icon | Usage | Shared Props |
|---|---|---|
| IconSendMoney | Send money action button | { size?: number; className?: string } |
| IconQrScan | QR scan action / BottomNav tab | Same |
| IconVirtualCard | Card feature (FUTURE) | Same |
| IconShield | Trust/security sections | Same |
| IconFastTransfer | Marketing feature highlight | Same |
| IconCorridors | Corridor/globe feature | Same |
4.3 shadcn/ui Components (Web Only)
All shadcn/ui components live in components/ui/ and are built on Radix UI primitives with Tailwind styling via CSS variables in globals.css.
| Component | Radix Primitive | Used By |
|---|---|---|
| Button | @radix-ui/react-slot |
All pages |
| Card | div-based | Accounts, Dashboard |
| Dialog | @radix-ui/react-dialog |
CookieConsent, Cards |
| Tabs | @radix-ui/react-tabs |
Transactions |
| ScrollArea | @radix-ui/react-scroll-area |
Dashboard |
| Input | native input | Login, Register, Send |
| Select | @radix-ui/react-select |
Complaints, Settings |
| Badge | cva variants | Accounts, Profile |
| Skeleton | div + pulse animation | Loading states |
| Sheet | @radix-ui/react-dialog |
Side panels |
| Separator | @radix-ui/react-separator |
Profile sections |
| Avatar | @radix-ui/react-avatar |
Profile, Dashboard |
| Alert | div-based | Accounts (PSD2 banner) |
| Sonner | sonner library | Toast notifications |
5. Design System Integration
5.1 Design Token Reference
Colors
| Token | Hex | Usage |
|---|---|---|
| Primary Green | #0B6E35 |
Buttons, active states, BottomNav active |
| Primary Green Dark | #095C2C |
Hover/pressed states |
| Primary Green Light | #E8F5E9 |
Light backgrounds |
| Gold Accent | #D4A017 |
Logo accent, QR scanner brackets, pending status |
| Text Primary | #1A1A1A (web) / #1E293B |
Headings, body text |
| Text Secondary | #6B7280 (mobile) / #64748B (web) |
Descriptions, labels |
| Text Muted | #9CA3AF (mobile) / #94A3B8 (web) |
Timestamps, hints |
| Background | #FAFCF8 (mobile) / #F8FAFC (web) |
Page backgrounds |
| Card | #FFFFFF |
Card surfaces |
| Border | #E5E7EB (mobile) / #E2E8F0 (web) |
Dividers, input borders |
| Error | #EF4444 |
Error states |
| Success | #10B981 |
Success indicators |
Typography
| Role | Web | Mobile |
|---|---|---|
| Display / Headings | Fraunces (via CSS) | Fraunces_700Bold / Fraunces_600SemiBold |
| Body | System / Inter | DMSans_400Regular |
| Body Medium | System / Inter Medium | DMSans_500Medium |
| Body Bold | System / Inter Bold | DMSans_700Bold |
Spacing (Mobile)
| Token | Value |
|---|---|
| xs | 4px |
| sm | 8px |
| md | 16px |
| lg | 24px |
| xl | 32px |
| xxl | 48px |
Border Radius
| Token | Value |
|---|---|
| sm | 8px |
| md | 12px |
| lg | 16px |
| xl | 24px (rounded-2xl) |
| full | 9999px (circular) |
5.2 Figma-to-Code Pipeline
Figma Design (Make Export)
|
v
Figma Make Export (Vite + React)
mockups/figma-make-export/src/app/screens/
10 screens: Login, Onboarding, Dashboard, SendMoney,
BankAccounts, TransactionHistory, ScanQR, Profile,
Notifications, MerchantDashboard
|
v
Implementation (Next.js / Expo)
- Web: src/drop-app/src/app/
- Mobile: src/drop-mobile/app/
|
v
Visual Validation
Screenshot vs Figma reference comparison
Source of truth: mockups/figma-make-export/src/components/ contains the canonical UI for all 10 core screens. Before any UI change, the corresponding Make component must be read first.
6. Web vs Mobile Feature Matrix
| Feature | Web (Next.js) | Mobile (Expo) |
|---|---|---|
| Auth storage | httpOnly cookie (drop_token) |
Bearer token (in-memory + AsyncStorage) |
| Navigation | App Router (file-based) | Expo Router (Stack + Tabs) |
| Send money flow | 4 steps (recipient, amount, review, success) | 2 steps (recipient+currency, amount+confirm) |
| Registration | BankID-only (auto-creation on first login; POST /register returns 410) | BankID-only (auto-creation on first login) |
| QR scanner | Simulated camera viewfinder | Simulated camera placeholder |
| Bottom nav | 5 tabs (Hjem, Aktivitet, Skann, Kontoer, Profil) | 4 tabs (Hjem, Send, QR, Profil) |
| Cards page | Yes (feature-flagged, default off) | No |
| Merchant dashboard | Yes (role-gated) | No |
| Bank accounts | Dedicated /accounts page |
Balance shown on dashboard |
| Notifications | Dedicated /notifications page |
Not implemented |
| Profile sub-pages | 4 (personal, security, notifications, language) | Inline settings |
| Feature flags | Environment variables | Not implemented |
| Legal pages | Terms, Privacy, Fees, Withdrawal, Complaints | Not implemented (links only) |
| Offline support | PWA Service Worker registration | No offline support |
| Deep linking | N/A | Not configured |
| Push notifications | N/A | Not implemented |
| Biometric auth | N/A | Not implemented |
| UI framework | shadcn/ui (Radix) + Tailwind v4 | React Native StyleSheet |
| State management | useState + useAuth hook | useState + api module |
7. Accessibility Considerations (WCAG 2.1 AA)
| Area | Web Implementation | Mobile Implementation |
|---|---|---|
| Color contrast | Primary green (#0B6E35) on white meets 4.5:1 | Same color tokens, system font rendering |
| Focus management | Radix UI provides built-in focus trapping for Dialog, Sheet | Expo Router handles screen focus |
| Screen reader | Semantic HTML via shadcn/ui, lucide icons with aria-hidden | React Native accessibility props needed |
| Touch targets | Buttons min 44px height (py-3 = 48px) | Tab bar height 60px, buttons styled per platform |
| Motion | Tailwind transition-colors only, no complex animation |
Minimal animation (SplashScreen) |
| Language | lang="nb" on html element (Norwegian) |
Not configured |
| Keyboard nav | Radix handles arrow keys, Escape, Tab | N/A (touch-first) |
8. Cross-References
- API endpoints consumed by frontend: See API Reference
- Database schema behind API responses: See Database Schema
- Authentication flow (BankID OIDC): See Authentication
- Page specifications: See PAGES.md
- Figma exports (UI source of truth):
mockups/figma-make-export/src/app/screens/ - Login authentication flow: See flow-login-authentication.md
- QR payment flow: See flow-qr-payment.md
- Transaction history flow: See flow-transaction-history.md
- Notification flow: See flow-notifications.md
- Merchant onboarding flow: See flow-merchant-onboarding.md
Deployment Architecture
Deployment Architecture
AWS deployment topology, Cloudflare edge layer, Docker multi-stage build, CI/CD pipeline, environment strategy, auto-scaling, health checks, and rollback procedures for the Drop fintech platform.
Deployment Topology
Note: AWS App Runner is the PLANNED production deployment target. Current deployment uses Docker Compose only (
docker-compose.ymlanddocker-compose.production.yml). No CI/CD pipeline, ECR, or GitHub Actions are configured yet.
graph TB
subgraph Internet
User[End Users]
Mobile[Mobile App]
end
subgraph Cloudflare["Cloudflare Edge"]
DNS[DNS<br/>getdrop.no]
CDN[CDN<br/>Static assets cache]
WAF[WAF Rules<br/>Rate limiting, bot protection,<br/>geo-blocking, OWASP rules]
DDoS[DDoS Protection<br/>L3/L4/L7 mitigation]
end
subgraph AWS["AWS eu-north-1 (Stockholm)"]
subgraph AppRunner["AWS App Runner (PLANNED)"]
WebContainer[drop-web<br/>Next.js 15 standalone<br/>Node.js 22 Alpine<br/>Port 3000]
APIContainer[drop-api<br/>Hono v4<br/>Node.js 22 Alpine<br/>Port 3001]
end
subgraph DataLayer["Data Layer"]
RDS[(RDS PostgreSQL 16<br/>db.t3.micro → db.r6g.large<br/>Multi-AZ failover<br/>Automated backups)]
end
subgraph Supporting["Supporting Services"]
ECR[ECR<br/>Container Registry<br/>Image scanning enabled]
SM[Secrets Manager<br/>JWT_SECRET<br/>BANKID_CLIENT_SECRET<br/>DATABASE_URL<br/>SENTRY_DSN]
CW[CloudWatch<br/>Logs + Metrics<br/>Alarm triggers]
end
end
User -->|HTTPS| DNS
Mobile -->|HTTPS| DNS
DNS --> CDN
CDN --> WAF
WAF --> DDoS
DDoS -->|Origin pull| WebContainer
DDoS -->|Origin pull| APIContainer
WebContainer --> RDS
APIContainer --> RDS
AppRunner --> ECR
AppRunner --> SM
AppRunner --> CW
CI/CD Pipeline
flowchart LR
subgraph Trigger
Push[git push]
PR[Pull Request]
end
subgraph Build["Build Stage"]
Checkout[Checkout code]
Deps[npm ci]
TypeCheck[tsc --noEmit]
Lint[eslint]
Test[vitest run]
end
subgraph Package["Package Stage"]
DockerBuild[Docker multi-stage build]
ImageScan[ECR image scan]
PushECR[Push to ECR]
end
subgraph DeployStaging["Deploy: Staging"]
DeployStagingEnv[Deploy to App Runner<br/>staging service]
SmokeTest[Smoke test<br/>GET /v1/health]
E2ETest[E2E test suite]
end
subgraph DeployProd["Deploy: Production"]
Approval[Manual approval gate]
BlueGreen[Blue/green swap<br/>App Runner traffic shift]
HealthVerify[Health check verification<br/>3 consecutive passes]
Rollback{Healthy?}
end
Push --> Checkout
PR --> Checkout
Checkout --> Deps --> TypeCheck --> Lint --> Test
Test --> DockerBuild --> ImageScan --> PushECR
PushECR --> DeployStagingEnv --> SmokeTest --> E2ETest
E2ETest --> Approval --> BlueGreen --> HealthVerify --> Rollback
Rollback -->|Yes| Done[Production Live]
Rollback -->|No| RollbackAction[Revert to previous revision]
Pipeline Stages Detail
| Stage | Tool | Timeout | Failure Action |
|---|---|---|---|
| Checkout | actions/checkout@v4 |
1m | Fail pipeline |
| Install deps | npm ci |
5m | Fail pipeline |
| TypeScript check | tsc --noEmit |
3m | Fail pipeline |
| Lint | eslint . |
2m | Fail pipeline |
| Unit tests | vitest run |
5m | Fail pipeline |
| Docker build | Multi-stage (4 stages: deps, test, builder, runner) | 10m | Fail pipeline |
| Image scan | ECR vulnerability scan | 5m | Warn on HIGH, block on CRITICAL |
| Push to ECR | docker push |
3m | Fail pipeline |
| Deploy staging | App Runner update | 10m | Fail pipeline |
| Smoke test | curl /v1/health |
1m | Rollback staging |
| Manual approval | GitHub environment protection | 24h | Pipeline expires |
| Production deploy | App Runner traffic shift | 10m | Auto-rollback |
| Health verification | 3x GET /v1/health at 10s intervals |
1m | Auto-rollback |
Docker Multi-Stage Build
Source: src/drop-app/Dockerfile
┌─────────────────────────────────────────────┐
│ Stage 1: deps (node:22-alpine) │
│ │
│ • Install python3, make, g++ (native deps) │
│ • COPY package*.json │
│ • npm ci (production + dev deps) │
│ • Output: /app/node_modules │
├─────────────────────────────────────────────┤
│ Stage 2: test (node:22-alpine) │
│ │
│ • COPY node_modules from deps │
│ • COPY source code │
│ • Run vitest + coverage checks │
│ • Mandatory test gate — blocks build on │
│ failure │
├─────────────────────────────────────────────┤
│ Stage 3: builder (node:22-alpine) │
│ │
│ • COPY node_modules from deps │
│ • COPY source code │
│ • npm run build (Next.js standalone output) │
│ • Output: .next/standalone, .next/static │
├─────────────────────────────────────────────┤
│ Stage 4: runner (node:22-alpine) │
│ │
│ • Non-root user: nextjs (UID 1001) │
│ • Install python3, make, g++ (native deps) │
│ • COPY public/ from builder │
│ • COPY .next/standalone from builder │
│ • COPY .next/static from builder │
│ • Data dir: /app/data (owned by nextjs) │
│ • No source code │
│ • CMD: node server.js │
└─────────────────────────────────────────────┘
Security features in runner stage:
- Non-root user
nextjs(UID 1001, GIDnodejs1001) - Note: Runner stage currently includes
python3,make,g++(installed viaapk addfor native dependency rebuilds). These should be removed in a future optimization. - No source code — only compiled standalone output
- Data directory
/app/dataowned bynextjs:nodejs
Environment Configuration
| Variable | Dev | Staging | Production | Source |
|---|---|---|---|---|
NODE_ENV |
development |
production |
production |
Dockerfile ENV |
JWT_SECRET |
Dev fallback (static string 'dev-secret-change-in-production') |
Secrets Manager | Secrets Manager | auth.ts:8 |
DATABASE_URL |
Not set (SQLite) | RDS connection string | RDS connection string | Secrets Manager |
BANKID_CLIENT_ID |
Not set | BankID test env | BankID prod env | Secrets Manager |
BANKID_CLIENT_SECRET |
Not set | BankID test env | BankID prod env | Secrets Manager |
BANKID_MOCK |
true |
false |
false |
App Runner env |
BANKID_CALLBACK_URL |
http://localhost:3000/api/auth/bankid/callback |
https://staging.getdrop.no/... |
https://getdrop.no/... |
App Runner env |
NEXT_PUBLIC_SERVICE_MODE |
demo |
mock or live |
live |
Build-time env |
SEED_DEMO |
implicit (non-prod) | true |
Not set | App Runner env |
SENTRY_DSN |
Not set | Sentry staging project | Sentry prod project | Secrets Manager |
APP_URL |
http://localhost:3000 |
https://staging.getdrop.no |
https://getdrop.no |
App Runner env |
PORT |
3000 |
3000 |
3000 |
App Runner default |
Environment Strategy
| Environment | Purpose | Database | BankID | Data |
|---|---|---|---|---|
| Development | Local development, docker compose up |
SQLite at ./data/drop.db |
Mock (BANKID_MOCK=true) |
Demo seed data |
| Staging | Pre-release validation, QA, E2E tests | RDS PostgreSQL (separate instance) | BankID test environment | Demo seed data (SEED_DEMO=true) |
| Production | Live service | RDS PostgreSQL (Multi-AZ, automated backups) | BankID production | Real user data only |
Scaling Configuration
App Runner Auto-Scaling
| Parameter | Web Container | API Container |
|---|---|---|
| Min instances | 1 | 1 |
| Max instances | 5 | 10 |
| Concurrency target | 50 req/instance | 100 req/instance |
| Scale-up cooldown | 30s | 30s |
| Scale-down cooldown | 300s | 300s |
| CPU | 1 vCPU | 1 vCPU |
| Memory | 2 GB | 2 GB |
Scaling Triggers
| Metric | Threshold | Action |
|---|---|---|
| Concurrent requests per instance | > 80% of target | Scale up |
| Concurrent requests per instance | < 25% of target for 5m | Scale down |
| Response time p95 | > 500ms for 3m | Scale up + CloudWatch alarm |
| Error rate (5xx) | > 5% for 2m | CloudWatch alarm, no auto-scale |
| CPU utilization | > 80% for 3m | Scale up |
RDS PostgreSQL Scaling
| Parameter | Staging | Production |
|---|---|---|
| Instance class | db.t3.micro |
db.t3.medium (initial) → db.r6g.large |
| Storage | 20 GB gp3 | 100 GB gp3, auto-scaling to 500 GB |
| Multi-AZ | No | Yes |
| Read replicas | 0 | 0 (add when needed) |
| Backup retention | 7 days | 30 days |
| Maintenance window | Sunday 03:00 UTC | Sunday 03:00 UTC |
Health Check Endpoints
API Health Check
Endpoint: GET /v1/health (Hono API)
Source: routes/health.ts
// Success (200)
{
"status": "ok",
"version": "0.1.0",
"uptime": 3600,
"db": "connected",
"dbLatencyMs": 1,
"timestamp": "2026-02-21T12:00:00.000Z"
}
// Failure (503)
{
"status": "error",
"db": "disconnected",
"timestamp": "2026-02-21T12:00:00.000Z"
}
Health check performs: SELECT 1 as ok query to verify database connectivity and measure latency.
Health Check Configuration
| Component | Interval | Timeout | Retries | Grace Period |
|---|---|---|---|---|
| App Runner (web) | 10s | 5s | 3 | 30s |
| App Runner (API) | 10s | 5s | 3 | 30s |
| Docker Compose (dev) | 30s | 10s | 3 | 10s |
| Cloudflare origin health | 60s | 10s | 2 | N/A |
Blue/Green Deployment (Aspirational)
Note: App Runner does NOT have built-in blue/green deployment (see ADR-012). The following describes an aspirational traffic-shifting strategy that would need custom implementation. App Runner performs rolling updates by default.
1. New revision deployed alongside current (blue)
2. New revision (green) starts and passes health checks
3. Traffic gradually shifted: 0% → 10% → 50% → 100%
4. If health checks pass for 60s at 100% → old revision drained
5. If health checks fail → immediate rollback to blue
Deployment Checklist
- All CI checks pass (TypeScript, lint, tests)
- Docker image built and scanned (no CRITICAL vulnerabilities)
- Image pushed to ECR
- Staging deployment succeeds
- Smoke tests pass (
GET /v1/healthreturns 200) - Manual approval (production deployments only)
- Production deployment with health verification
- Post-deployment monitoring (15 minutes)
Rollback Procedures
Automatic Rollback
App Runner automatically rolls back if:
- New revision fails health checks within grace period
- Health check failure rate exceeds threshold during traffic shift
- Container crashes on startup (exit code != 0)
Manual Rollback
# List recent revisions
aws apprunner list-operations --service-arn $SERVICE_ARN
# Rollback to previous revision
aws apprunner update-service \
--service-arn $SERVICE_ARN \
--source-configuration '{"ImageRepository":{"ImageIdentifier":"<previous-ecr-image>"}}'
# Verify rollback
curl https://getdrop.no/v1/health
Database Rollback
For database schema changes, migrations are forward-only. In case of issues:
- SQLite (dev/staging): Restore from backup (see DEPLOYMENT.md backup section)
- PostgreSQL (prod): RDS point-in-time recovery to any second within retention window (30 days)
# RDS point-in-time restore
aws rds restore-db-instance-to-point-in-time \
--source-db-instance-identifier drop-prod \
--target-db-instance-identifier drop-prod-restored \
--restore-time "2026-02-21T11:00:00Z"
Cloudflare Configuration
| Feature | Configuration | Purpose |
|---|---|---|
| DNS | getdrop.no → App Runner CNAME (proxied) |
Domain routing |
| SSL/TLS | Full (strict) | End-to-end encryption |
| CDN | Cache static assets (/_next/static/*, /public/*) |
Performance |
| WAF | OWASP Core Rule Set, rate limiting rules | Security |
| DDoS | L3/L4/L7 auto-mitigation | Availability |
| Bot management | Challenge mode for suspicious traffic | Security |
| Geo-blocking | Allow: NO, SE, DK, FI (Scandinavia) + test regions | Compliance |
| Page rules | /* → SSL always, HSTS |
Security |
Cloudflare WAF Rules
| Rule | Action | Purpose |
|---|---|---|
| OWASP Core Rule Set | Block | SQL injection, XSS, path traversal |
Rate limit: /v1/auth/* |
Challenge at 20 req/10s | Auth endpoint abuse prevention |
Rate limit: /v1/transactions/* |
Block at 30 req/10s | Transaction abuse prevention |
| Country block: Sanctioned countries | Block | OFAC/UN sanctions compliance |
| Bot score < 30 | Challenge | Bot traffic mitigation |
Cross-References
- Container diagram: container-diagram.md — C4 Level 2 container architecture
- Deployment guide: DEPLOYMENT.md — Docker compose, backup/restore procedures
- Security architecture: SECURITY-ARCHITECTURE.md — Security headers, CSRF, rate limiting
- Feature flags: FEATURE-FLAGS.md — Environment variable driven feature gating
- Database schema: DATABASE-SCHEMA.md — All 19 tables
Security Architecture
Security Architecture — High-Level Design
Version: 1.0 Date: 2026-02-21 Author: Banking Architecture Team Status: Approved Applies to: Drop — Security Threat Model & Controls
1. Overview
Drop is a PSD2-regulated fintech application that processes financial transactions (remittance, QR payments) without holding customer funds. This document defines the security architecture: trust boundaries, threat model (STRIDE), SCA implementation, fraud detection, AML screening, data classification, and encryption strategy.
Security posture summary:
- All authentication via BankID OIDC (SCA by default)
- All payment SCA delegated to ASPSP (user's bank)
- JWT tokens in httpOnly cookies (web) or AsyncStorage (mobile)
- Parameterized SQL queries (no string concatenation)
- Input sanitization on all user-facing endpoints
- Compliance tables for audit, AML, STR, screening, consents, GDPR
2. Trust Boundaries
graph TB
subgraph Internet["Internet (Untrusted)"]
Browser["Web Browser"]
Mobile["Mobile App (Expo)"]
Attacker["Potential Attacker"]
end
subgraph CDN["CDN / Edge (Cloudflare)"]
WAF["WAF + DDoS Protection"]
TLS["TLS Termination"]
end
subgraph AppTier["Application Tier (AWS App Runner)"]
subgraph NextJS["Next.js BFF"]
WebRoutes["Web API Routes<br/>/api/auth/*, /api/transactions/*"]
Middleware["Auth Middleware<br/>Rate Limiter<br/>CSRF Validator<br/>Input Sanitizer"]
end
subgraph Hono["Hono API"]
MobileRoutes["Mobile API Routes<br/>/v1/auth/*, /v1/transactions/*"]
HonoMiddleware["Auth Middleware<br/>Rate Limiter"]
end
end
subgraph DataTier["Data Tier (Private Subnet)"]
SQLite["SQLite / PostgreSQL<br/>19 tables (12 core + 7 compliance)"]
end
subgraph ExternalServices["External Services (Trusted Partners)"]
BankID["BankID OIDC<br/>(auth.bankid.no)"]
ASPSP["ASPSPs<br/>(DNB, SpareBank 1, Nordea)"]
FX["FX Rate Provider"]
KYC["KYC Provider<br/>(Sumsub - future)"]
end
Browser -->|"HTTPS<br/>TB1: Internet→Edge"| WAF
Mobile -->|"HTTPS<br/>TB1: Internet→Edge"| WAF
Attacker -.->|"Blocked by WAF"| WAF
WAF -->|"TB2: Edge→App"| Middleware
WAF -->|"TB2: Edge→App"| HonoMiddleware
Middleware --> WebRoutes
HonoMiddleware --> MobileRoutes
WebRoutes -->|"TB3: App→Data"| SQLite
MobileRoutes -->|"TB3: App→Data"| SQLite
WebRoutes -->|"TB4: App→External<br/>mTLS"| BankID
WebRoutes -->|"TB4: App→External<br/>eIDAS cert"| ASPSP
MobileRoutes -->|"TB4: App→External"| BankID
style Internet fill:#ff6b6b,stroke:#333,color:#fff
style CDN fill:#ffd93d,stroke:#333
style AppTier fill:#6bcb77,stroke:#333
style DataTier fill:#4d96ff,stroke:#333,color:#fff
style ExternalServices fill:#845ec2,stroke:#333,color:#fff
Trust Boundary Definitions
| Boundary | From | To | Protection |
|---|---|---|---|
| TB1: Internet to Edge | Browser/Mobile | Cloudflare | TLS 1.3, WAF rules, DDoS mitigation |
| TB2: Edge to Application | Cloudflare | Next.js/Hono | HTTPS, auth middleware, rate limiting |
| TB3: Application to Data | API layer | SQLite/PostgreSQL | Parameterized queries, file permissions |
| TB4: Application to External | API layer | BankID/ASPSP | mTLS (eIDAS QWAC), JWKS verification |
3. STRIDE Threat Model
3.1 Threat Matrix
| Component | Spoofing | Tampering | Repudiation | Info Disclosure | DoS | Elevation |
|---|---|---|---|---|---|---|
| BankID Auth | L: BankID handles identity | L: JWKS signature verification | L: Audit log + session tracking | M: pid hash exposure risk | M: Rate limit 10/min | L: Role check on every request |
| JWT Tokens | M: Token theft via XSS | L: HS256 signature | L: Session table tracks all JWTs | M: Payload contains userId | L: 7d expiry | M: Role claim in JWT |
| PISP Payments | L: SCA required per payment | M: Amount/payee tampering | L: Audit log + idempotency_key | L: Disclosure before payment | M: Rate limit 10/min | L: KYC check before remittance |
| AISP Balance | L: Consent required | L: Read-only from ASPSP | L: balance_synced_at tracking | M: Cached balance visible | L: Max 4 reads/day | N/A |
| Database | L: No direct access | M: SQL injection risk | L: audit_log table | H: PII in users table | L: Rate limiting | L: User-scoped queries |
| API Endpoints | M: CSRF on web | M: Input manipulation | L: Audit logging | M: Error message leakage | H: Unthrottled endpoints | M: IDOR if user_id not checked |
Risk levels: L = Low (mitigated), M = Medium (partial mitigation), H = High (needs attention), N/A = Not applicable
3.2 Detailed Threat Analysis
S — Spoofing
| Threat | Attack Vector | Mitigation | Status |
|---|---|---|---|
| Identity spoofing | Stolen credentials | BankID OIDC (SCA: possession + knowledge) | Implemented |
| Session hijacking | Token theft | httpOnly + secure + sameSite=Lax cookies | Implemented |
| CSRF | Forged cross-origin request | State parameter (OIDC), Origin header validation | Implemented |
| Replay attack | Reuse old auth code | Nonce in OIDC flow, one-time code exchange | Implemented |
T — Tampering
| Threat | Attack Vector | Mitigation | Status |
|---|---|---|---|
| SQL injection | Malicious input in queries | Parameterized queries (all 24 endpoints) | Implemented |
| XSS | Script injection in fields | React auto-escaping, CSP headers, sanitizeText() | Implemented |
| Payment amount tampering | Modified request body | Server-side validation, SCA dynamic linking | Implemented |
| JWT modification | Altered token claims | HS256 signature verification | Implemented |
R — Repudiation
| Threat | Attack Vector | Mitigation | Status |
|---|---|---|---|
| Deny transaction | User claims they didn't authorize | BankID SCA log + audit_log table | Partial (audit_log exists, SCA tracking needed) |
| Deny consent | User claims no consent given | consents table with IP address + timestamp | Implemented |
| Admin action denial | Unauthorized changes | audit_log with user_agent and ip_address | Implemented |
I — Information Disclosure
| Threat | Attack Vector | Mitigation | Status |
|---|---|---|---|
| PII exposure | Database breach | Encryption at rest (planned), PID hashed with SHA-256 | Partial |
| Card data exposure | API response leakage | Masked to last 4 digits, CVV hidden | Implemented |
| Bank account exposure | API response leakage | Masked to last 4 digits in recipient list | Implemented |
| Error message leakage | Verbose error responses | Centralized error handler, generic messages | Implemented |
D — Denial of Service
| Threat | Attack Vector | Mitigation | Status |
|---|---|---|---|
| API flooding | High request volume | Rate limiting (10-120/min per endpoint) | Implemented |
| Auth brute force | Repeated login attempts | BankID handles (locks after failures) | Implemented |
| Database exhaustion | Large data queries | Pagination (max 50/page), query limits | Implemented |
| Resource exhaustion | Large payloads | Input length limits (sanitizeText) | Implemented |
E — Elevation of Privilege
| Threat | Attack Vector | Mitigation | Status |
|---|---|---|---|
| IDOR | Access other user's data | AND user_id = ? on all queries |
Implemented |
| Role escalation | Modify role claim | Server-side role check, role in DB not just JWT | Implemented |
| Merchant impersonation | Access merchant dashboard | role = 'merchant' check on merchant routes. Note: merchant role currently grants admin access (audit, screening, STR) via isAdmin(role) === role === 'merchant' in admin.ts |
Implemented |
| KYC bypass | Skip verification | kyc_status = 'approved' check before remittance |
Implemented |
4. SCA Implementation
4.1 Two-Level SCA
Drop implements SCA at two levels:
| Level | Purpose | Provider | Method |
|---|---|---|---|
| App Authentication | Login to Drop | BankID OIDC | BankID app (possession) + code/biometrics (knowledge/inherence) |
| Payment Authorization | Approve PISP payment | ASPSP via BankID | BankID at bank (dynamic linking: amount + payee) |
4.2 SCA Factors
| Factor Type | BankID Implementation |
|---|---|
| Knowledge | Personal code / PIN |
| Possession | Mobile device with BankID app / code generator |
| Inherence | Biometrics (fingerprint/face on mobile BankID) |
PSD2 RTS Art. 4: At least 2 of 3 factors required. BankID provides 2 by default (possession + knowledge or inherence).
4.3 Dynamic Linking (PISP)
For every PISP payment, PSD2 RTS Art. 97(2) requires:
- User sees exact amount and payee name during SCA
- Authentication code is cryptographically bound to amount + payee
- Any change to amount or payee invalidates the authentication
This is handled by the ASPSP's BankID integration — Drop passes instructedAmount and creditorName in the PISP API call, and the bank displays these during BankID authentication.
5. Fraud Detection Pipeline
flowchart TD
A[Transaction Request] --> B[Pre-Transaction Checks]
B --> C{User KYC Status}
C -->|pending/rejected| D[REJECT: kyc_required]
C -->|approved| E[Amount Validation]
E --> F{Amount in range?}
F -->|No| G[REJECT: validation_error]
F -->|Yes| H[Velocity Check]
H --> I{Exceeds daily/weekly limit?}
I -->|Yes| J[FLAG: velocity_alert<br/>Insert into aml_alerts<br/>severity: medium]
I -->|No| K[Pattern Analysis]
K --> L{Structuring detected?<br/>Multiple txns just below threshold}
L -->|Yes| M[FLAG: structuring_alert<br/>Insert into aml_alerts<br/>severity: high]
L -->|No| N[Corridor Risk Check]
N --> O{High-risk corridor?}
O -->|Yes| P[Enhanced due diligence<br/>FLAG if first-time corridor]
O -->|No| Q[Recipient Screening]
Q --> R{Recipient on sanctions list?}
R -->|Yes| S[BLOCK: sanctions_match<br/>Insert into screening_results<br/>result: match]
R -->|No| T[APPROVE: Proceed to PISP]
J --> T
M --> U[Escalate to compliance officer<br/>Insert into str_reports<br/>status: draft]
P --> T
style D fill:#ff6b6b,color:#fff
style G fill:#ff6b6b,color:#fff
style S fill:#ff6b6b,color:#fff
style T fill:#6bcb77,color:#fff
style J fill:#ffd93d
style M fill:#ffd93d
style U fill:#ff9f43
5.1 Detection Rules
| Rule | Trigger | Severity | Action |
|---|---|---|---|
Velocity limit (checkVelocity) |
> 5 transactions in 1 hour | Medium | aml_alerts record, continue with flag |
Structuring detection (checkStructuring) |
3+ transactions in 24h totaling > 50,000 NOK | High | aml_alerts + str_reports draft |
High-value single (checkHighAmount) |
Single transaction > 100,000 NOK | High | Enhanced monitoring, aml_alerts record |
High-risk corridor (checkHighRiskCorridor) |
Country on FATF grey/black list | High | Enhanced due diligence required |
Unusual pattern (checkUnusualPattern) |
Transaction amount > 5x user's average | Medium | aml_alerts record |
| Sanctions match | Recipient matches sanctions list | Critical | Block transaction, escalate |
| PEP match | User matches PEP database | High | Enhanced due diligence |
These rules are implemented in transaction-monitor.ts and run on each remittance creation.
5.2 AML Screening Tables
| Table | Purpose | Key Columns |
|---|---|---|
aml_alerts |
Transaction monitoring flags | alert_type, severity, status (open/investigating/resolved/escalated/filed) |
str_reports |
Suspicious Transaction Reports to authorities | report_type, status (draft/submitted/acknowledged), reference_number |
screening_results |
PEP/sanctions/adverse media checks | screening_type, result (clear/match/potential_match/error) |
6. Data Classification
6.1 Classification Levels
| Level | Description | Examples | Storage | Access |
|---|---|---|---|---|
| CRITICAL | Financial credentials, encryption keys | JWT_SECRET, BANKID_CLIENT_SECRET, eIDAS private keys | Vaultwarden only | Application runtime only |
| RESTRICTED | PII subject to GDPR | name, email, phone, date_of_birth, national_id_hash | Encrypted at rest (planned), DB access layer | Authenticated user (own data only) |
| CONFIDENTIAL | Financial data | transactions, bank balances, exchange rates, fees | DB with user-scoped access | Authenticated user (own data only) |
| INTERNAL | Operational data | audit_log, rate_limits, sessions | DB | System processes, compliance officers |
| PUBLIC | Non-sensitive | exchange rates (GET /api/rates), health check | DB / API | Unauthenticated |
6.2 Data Classification by Table
| Table | Classification | PII Fields | Encryption at Rest | Retention |
|---|---|---|---|---|
users |
RESTRICTED | email, first_name, last_name, phone, date_of_birth, national_id_hash | Planned | 5 years post-deletion (AML) |
bank_accounts |
RESTRICTED | account_number, iban | Planned | Active + 5 years |
transactions |
CONFIDENTIAL | amount, recipient details | Planned | 5 years (AML/tax) |
recipients |
RESTRICTED | name, bank_account | Planned | Active + 5 years |
sessions |
INTERNAL | token_hash | N/A (hash only) | 30 days |
audit_log |
INTERNAL | ip_address, user_agent | Planned | 5 years |
aml_alerts |
CONFIDENTIAL | details | Planned | 5 years |
str_reports |
CONFIDENTIAL | details, reference_number | Planned | 10 years |
screening_results |
CONFIDENTIAL | match_details | Planned | 5 years |
consents |
RESTRICTED | ip_address | Planned | Until withdrawn + 5 years |
merchants |
CONFIDENTIAL | None (business data) | Planned | Active + 5 years |
cards |
RESTRICTED | last_four, token_ref | Planned | Active + 5 years |
data_access_requests |
INTERNAL | None (metadata only) | N/A | 5 years |
complaints |
INTERNAL | None (user text) | Planned | 5 years |
notifications |
INTERNAL | None | N/A | 90 days |
settings |
INTERNAL | None (preferences) | N/A | Active |
spending_limits |
INTERNAL | None | N/A | Active |
exchange_rates |
PUBLIC | None | N/A | Indefinite |
rate_limits |
INTERNAL | None | N/A | Transient |
7. Encryption
7.1 Encryption in Transit
| Connection | Protocol | Certificate |
|---|---|---|
| Browser to Drop | TLS 1.3 (Cloudflare) | Cloudflare managed |
| Mobile to Drop | TLS 1.3 | Cloudflare managed |
| Drop to BankID | TLS 1.2+ | BankID server cert |
| Drop to ASPSP | mTLS (eIDAS QWAC) | Qualified Website Authentication Certificate |
| Drop to Database | N/A (SQLite local) / TLS (PostgreSQL) | PostgreSQL server cert |
7.2 Encryption at Rest
| Data | Current | Target |
|---|---|---|
| PostgreSQL 16 (all environments) | AWS RDS encryption (AES-256, TLS 1.3) | Active |
| Secrets (JWT_SECRET, etc.) | Vaultwarden | Vaultwarden + AWS Secrets Manager |
| Backups | Not encrypted | AES-256 encrypted backups |
| Logs | Plain text | Encrypted log storage |
7.3 Key Management
| Key | Purpose | Storage | Rotation |
|---|---|---|---|
JWT_SECRET |
Sign Drop JWTs | Vaultwarden / env var | Every 90 days |
BANKID_CLIENT_SECRET |
BankID OIDC client auth | Vaultwarden / env var | Per BankID policy |
| eIDAS QWAC private key | mTLS to ASPSPs | HSM (planned) | Per certificate lifecycle |
| eIDAS QSeal private key | Sign API requests | HSM (planned) | Per certificate lifecycle |
qr_hmac_key (merchants) |
HMAC for QR code verification | DB (merchants table) |
Per merchant, on creation |
7.4 Hashing
| Data | Algorithm | Purpose | Source |
|---|---|---|---|
| Passwords | bcrypt (cost 12) | Password verification | utils-server.ts:8-16 |
| National ID (pid) | SHA-256 | User deduplication | bankid.ts:211 |
| JWT tokens | SHA-256 | Session lookup | auth.ts:59 |
| PIN codes | bcrypt | Card PIN verification | cards/[id]/pin/route.ts |
8. Security Controls Summary
8.1 Application Security
| Control | Implementation | Source |
|---|---|---|
| Authentication | BankID OIDC (SCA) | bankid.ts, auth.ts |
| Authorization | JWT + role check + user_id scoping | middleware/auth.ts |
| Input validation | sanitizeText, validateName, validateAmount, etc. | middleware/validation.ts |
| SQL injection prevention | Parameterized queries (all endpoints) | db.ts |
| XSS prevention | React auto-escaping + CSP + sanitization | next.config.ts, validation.ts |
| CSRF prevention | Origin validation + sameSite=Lax cookies | app.ts:23-30 (CORS) |
| Rate limiting | Per-IP, persistent (SQLite-backed) | middleware/rate-limit.ts |
| Session management | Server-side tracking with revocation | sessions table, auth.ts |
8.2 Infrastructure Security
| Control | Implementation | Status |
|---|---|---|
| TLS 1.3 | Cloudflare edge | Active (landing page) |
| WAF | Cloudflare WAF rules | Active (landing page) |
| DDoS protection | Cloudflare automatic | Active |
| HSTS | max-age=63072000; includeSubDomains; preload |
Configured (next.config.ts) |
| X-Frame-Options | DENY |
Configured |
| X-Content-Type-Options | nosniff |
Configured |
| Referrer-Policy | strict-origin-when-cross-origin |
Configured |
| Permissions-Policy | Camera (self), microphone (none), geolocation (self) | Configured |
8.3 Compliance Controls
| Control | Implementation | Table |
|---|---|---|
| Audit trail | All significant actions logged | audit_log |
| AML monitoring | Transaction pattern detection | aml_alerts |
| STR filing | Suspicious transaction reports | str_reports |
| PEP/sanctions screening | Automated list checking | screening_results |
| GDPR consent tracking | Consent grant/withdraw with IP | consents |
| Data access requests | GDPR Art. 15-17 | data_access_requests |
| Complaint handling | Finansavtaleloven compliance | complaints |
9. Security Audit Results
9.1 Pre-Hardening (2026-02-12)
| Severity | Count |
|---|---|
| CRITICAL | 4 |
| HIGH | 5 |
| MEDIUM | 6 |
| LOW | 4 |
9.2 Post-Hardening (2026-02-13)
| Severity | Count | Details |
|---|---|---|
| CRITICAL | 0 | All resolved |
| HIGH | 0 | All resolved |
| MEDIUM | 2 | CSP tightening (nonce-based), proxy config |
| LOW | 4 | Acknowledged, out of scope for MVP |
9.3 Key Remediations
| Finding | Fix | Source |
|---|---|---|
| C1: Card data stored in plain | Now stores only last_four + token_ref |
Schema change |
| C2: Demo credentials in production | Gated behind NODE_ENV !== 'production' (note: SEED_DEMO=true can override this check) |
db.ts:241 |
| C4: SHA-256 password hashes | Removed entirely, bcrypt only | utils-server.ts |
| C6/H1: No session revocation | Implemented in sessions table |
auth.ts:56-65 |
| H4: No input sanitization | sanitizeText() on all text fields | validation.ts |
| M5: Notification ID injection | Validated format + max 100 per request | notifications/route.ts |
| M6: Settings value injection | Currency/language whitelists | settings/route.ts |
10. Cross-References
- Existing Security Docs: ../../security/SECURITY-ARCHITECTURE.md — Detailed implementation-level security
- Compliance Status: ../../security/COMPLIANCE.md — Regulatory readiness assessment
- BankID OIDC: ../integration/bankid-oidc-integration.md — Authentication flow details
- Open Banking: ../integration/open-banking-aisp-pisp.md — ASPSP SCA, consent security
- Payment Processing: ../integration/payment-processing.md — Transaction integrity, idempotency
- Database Schema: ../../backend/DATABASE-SCHEMA.md — All 19 tables including compliance tables
- API Reference: ../../backend/API-REFERENCE.md — Endpoint security requirements
- Authentication: ../../backend/AUTHENTICATION.md — JWT, session, rate limiting details
Data Architecture
Data Architecture
Version: 1.0 Date: 2026-02-21 Status: Approved Owner: Database Architect
Overview
Drop's data architecture supports a PSD2 pass-through fintech application with two core functions: international remittances and QR merchant payments. The system manages 19 tables across 5 domains, backed by PostgreSQL 16 (all environments: development, CI, staging, production) via Drizzle ORM. See ADR-014.
Drop never holds customer funds. The bank_accounts.balance field is a cached AISP read from the user's real bank account -- not a Drop-held balance.
Domain Model
The 19 tables are organized into 5 logical domains:
erDiagram
%% User Domain
users ||--o{ bank_accounts : "links"
users ||--o{ cards : "owns"
users ||--o{ recipients : "saves"
users ||--o{ transactions : "initiates"
users ||--o{ sessions : "authenticates"
users ||--o{ notifications : "receives"
users ||--|| settings : "configures"
users ||--o{ spending_limits : "sets"
%% Financial Domain
transactions }o--o| recipients : "sends to"
transactions }o--o| merchants : "pays"
users ||--o{ merchants : "registers as"
cards ||--o{ spending_limits : "limited by"
%% KYC/AML Domain
users ||--o{ screening_results : "screened"
users ||--o{ aml_alerts : "flagged"
aml_alerts ||--o{ str_reports : "escalated to"
transactions ||--o{ aml_alerts : "triggers"
%% GDPR Domain
users ||--o{ consents : "grants"
users ||--o{ data_access_requests : "submits"
users ||--o{ complaints : "files"
%% System Domain
users ||--o{ audit_log : "generates"
users {
text id PK "usr_ prefix"
text email UK
text password_hash
text first_name
text last_name
text phone
text date_of_birth
text kyc_status "pending|approved|rejected"
text role "user|merchant"
text risk_level "low|medium|high"
text pep_status
text national_id_hash
text deleted_at
text created_at
}
bank_accounts {
text id PK
text user_id FK
text bank_name
text account_number
text iban
integer balance "cached AISP read"
text currency
integer is_primary
}
transactions {
text id PK
text user_id FK
text type "remittance|qr_payment"
text status "processing|completed|failed"
integer amount
text currency
integer fee
text recipient_id FK
text merchant_id FK
real exchange_rate
text idempotency_key UK
}
recipients {
text id PK
text user_id FK
text name
text country
text currency
text bank_account
}
merchants {
text id PK
text user_id FK
text business_name
text org_number UK
text bank_account
real fee_rate
text qr_hmac_key
}
sessions {
text id PK
text user_id FK
text token_hash
text expires_at
integer revoked
}
notifications {
text id PK
text user_id FK
text type
text title
text body
integer read
}
settings {
text user_id PK
text currency
text language
integer push_enabled
integer email_enabled
}
exchange_rates {
integer id PK
text from_currency
text to_currency
real rate
}
cards {
text id PK
text user_id FK
text type "virtual|physical"
text last_four
text status "active|frozen|cancelled"
}
spending_limits {
text id PK
text user_id FK
text card_id FK
text limit_type
integer amount
}
audit_log {
text id PK
text user_id FK
text action
text resource_type
text resource_id
text ip_address
}
aml_alerts {
text id PK
text user_id FK
text alert_type
text severity "low|medium|high|critical"
text transaction_id FK
text status "open|investigating|resolved|escalated|filed"
}
str_reports {
text id PK
text user_id FK
text alert_id FK
text report_type
text status "draft|submitted|acknowledged"
}
screening_results {
text id PK
text user_id FK
text screening_type "pep|sanctions|adverse_media"
text result "clear|match|potential_match|error"
}
consents {
text id PK
text user_id FK
text consent_type
integer granted
text ip_address
}
data_access_requests {
text id PK
text user_id FK
text request_type "export|erasure|rectification|restriction"
text status "pending|processing|completed|rejected"
}
complaints {
text id PK
text user_id FK
text category
text subject
text status "received|investigating|resolved|escalated"
}
rate_limits {
text key PK
integer count
integer reset_at
}
Domain Groupings
1. User Domain (4 tables)
Core identity, authentication, and preferences.
| Table | Purpose | Record Growth |
|---|---|---|
users |
User accounts with KYC/AML fields, BankID identity | 1 per registered user |
settings |
Per-user preferences (currency, language, notifications) | 1 per user (1:1) |
sessions |
JWT session tracking with revocation support | Multiple per user, prunable |
notifications |
In-app notification delivery | High volume, prunable |
Key relationships: users is the central entity. Every other user-scoped table references users(id) via foreign key. settings has a 1:1 relationship using user_id as its primary key.
2. Financial Domain (7 tables)
Transaction processing, bank account linkage, exchange rates, and payment cards.
| Table | Purpose | Record Growth |
|---|---|---|
transactions |
All financial operations (remittance + QR payment) | High volume, append-only |
bank_accounts |
Linked bank accounts with cached AISP balance | Few per user |
recipients |
Saved remittance recipients | Few per user |
merchants |
Registered merchant profiles | 1 per merchant user |
exchange_rates |
NOK-to-foreign currency rates | 6 corridor records, updated periodically |
cards |
Virtual/physical payment cards (FUTURE, feature-flagged) | Few per user |
spending_limits |
Card spending limits (FUTURE) | Few per card |
Key relationships: transactions polymorphically references either recipients (for remittances) or merchants (for QR payments) -- never both simultaneously. bank_accounts.balance is a cached read-only value from AISP, not a Drop-held balance.
3. KYC/AML Domain (3 tables)
Anti-money laundering monitoring and regulatory screening.
| Table | Purpose | Record Growth |
|---|---|---|
aml_alerts |
Flagged suspicious transaction patterns | Event-driven, low volume |
str_reports |
Suspicious Transaction Reports filed with Økokrim | Rare, legally retained |
screening_results |
PEP/sanctions/adverse media screening results | Per user, periodic rescreens |
Key relationships: aml_alerts links to a triggering transaction. str_reports escalates from an aml_alert. Both reference the user under investigation.
4. GDPR/Compliance Domain (3 tables)
Data subject rights, consent management, and complaint handling.
| Table | Purpose | Record Growth |
|---|---|---|
consents |
GDPR consent records (terms, privacy, marketing, cookies) | Few per user |
data_access_requests |
DSAR tracking (export, erasure, rectification, restriction) | Rare |
complaints |
Customer complaints per Finansavtaleloven section 3-53 | Low volume |
Key relationships: All reference users(id). Consent withdrawal triggers downstream processing (e.g., marketing opt-out).
5. System Domain (2 tables)
Operational infrastructure for audit trails and rate limiting.
| Table | Purpose | Record Growth |
|---|---|---|
audit_log |
User action audit trail for compliance | Very high volume |
rate_limits |
IP-based rate limiting counters | Ephemeral, auto-cleaned |
Key relationships: audit_log optionally references users(id) (some system events are unauthenticated). rate_limits is standalone with no foreign keys.
Data Classification
Each table is classified by sensitivity level for security controls, encryption, and access policies. Classification uses the 5-level taxonomy defined in security-architecture.md: CRITICAL, RESTRICTED, CONFIDENTIAL, INTERNAL, PUBLIC.
| Table | Classification | PII | Financial | Compliance | Rationale |
|---|---|---|---|---|---|
users |
RESTRICTED | Yes | No | Yes | Contains name, email, phone, DOB, national ID hash |
bank_accounts |
RESTRICTED | Yes | Yes | Yes | Bank account numbers, IBAN, cached balance |
transactions |
CONFIDENTIAL | No | Yes | Yes | Financial records, amounts, exchange rates |
recipients |
RESTRICTED | Yes | Yes | No | Names and foreign bank account numbers |
merchants |
CONFIDENTIAL | No | Yes | No | Business details, org numbers, bank accounts |
sessions |
INTERNAL | No | No | No | Token hashes enabling authentication bypass if leaked |
cards |
RESTRICTED | Yes | Yes | Yes | Card last-four, token refs, PINs (FUTURE) |
aml_alerts |
CONFIDENTIAL | No | No | Yes | Regulatory investigation data |
str_reports |
CONFIDENTIAL | No | No | Yes | Filed with Økokrim, legally protected |
screening_results |
CONFIDENTIAL | No | No | Yes | PEP/sanctions match data |
audit_log |
INTERNAL | Partial | No | Yes | IP addresses, user agents, action descriptions |
consents |
RESTRICTED | Partial | No | Yes | IP addresses, consent timestamps |
data_access_requests |
INTERNAL | No | No | Yes | DSAR metadata and download URLs |
complaints |
INTERNAL | No | No | Yes | User-submitted text content |
notifications |
INTERNAL | No | No | No | Display text, no sensitive content |
settings |
INTERNAL | No | No | No | UI preferences only |
exchange_rates |
PUBLIC | No | No | No | Public market data |
spending_limits |
INTERNAL | No | No | No | User-configured limits |
rate_limits |
INTERNAL | No | No | No | Ephemeral IP counters |
Data Flow
Remittance Flow
sequenceDiagram
participant U as User (Mobile/Web)
participant API as Hono API / Next.js
participant Auth as Auth Middleware
participant DB as Database (SQLite/PostgreSQL)
participant AISP as Open Banking AISP
participant PISP as Open Banking PISP
U->>API: POST /transactions/remittance
API->>Auth: Verify JWT + session
Auth->>DB: SELECT sessions WHERE token_hash = ? AND revoked = 0
Auth-->>API: userId, role
API->>DB: SELECT * FROM recipients WHERE id = ? AND user_id = ?
DB-->>API: Recipient (country, currency, bank_account)
API->>DB: SELECT rate FROM exchange_rates WHERE to_currency = ?
DB-->>API: Exchange rate
API->>DB: SELECT * FROM bank_accounts WHERE user_id = ? AND is_primary = 1
DB-->>API: Bank account (balance check)
Note over API,DB: Atomic transaction begins
API->>DB: UPDATE bank_accounts SET balance = balance - ? WHERE balance >= ?
API->>DB: INSERT INTO transactions (type='remittance', status='processing', ...)
API->>DB: INSERT INTO audit_log (action='transaction.create', ...)
API->>DB: INSERT INTO notifications (type='transaction', ...)
Note over API,DB: Atomic transaction commits
API->>PISP: Initiate payment from user's bank (production)
API-->>U: 201 { transaction details, ETA }
QR Payment Flow
sequenceDiagram
participant U as User (Mobile)
participant API as Hono API / Next.js
participant DB as Database
participant M as Merchant
U->>U: Scan QR code (drop://pay/{merchantId})
U->>API: POST /transactions/qr-payment { merchantId, amount }
API->>DB: Verify JWT session
API->>DB: SELECT * FROM merchants WHERE id = ?
DB-->>API: Merchant details (fee_rate, bank_account)
API->>DB: SELECT * FROM bank_accounts WHERE user_id = ? AND is_primary = 1
DB-->>API: Primary bank account
Note over API,DB: Atomic transaction
API->>DB: UPDATE bank_accounts SET balance = balance - (amount + fee)
API->>DB: INSERT INTO transactions (type='qr_payment', status='completed')
API->>DB: INSERT INTO audit_log (action='qr_payment.create')
API->>DB: INSERT INTO notifications (title='Betaling registrert')
Note over API,DB: Commit
API-->>U: 201 { payment confirmation }
Caching Strategy
| Data | Cache Location | TTL | Invalidation | Rationale |
|---|---|---|---|---|
| Exchange rates | exchange_rates table |
Updated periodically (external feed in production) | Table update replaces rows | Rates change infrequently; per-request DB lookup is sufficient |
| Bank account balance | bank_accounts.balance column |
balance_synced_at tracks freshness |
Re-synced via AISP on dashboard load | Cached AISP read; Drop never modifies this value except through sync |
| User session validity | sessions table lookup |
Until expires_at |
Set revoked = 1 on logout |
Every authenticated request checks session table |
| Rate limit counters | rate_limits table |
reset_at Unix timestamp (60s window) |
Auto-cleaned every 100 rate limit checks | Expired entries deleted in middleware/rate-limit.ts |
| JWT payload | In-cookie (client-side) | 7d (all clients) | Cookie cleared on logout, session revoked server-side | Stateless token; server validates against sessions table |
| Feature flags | In-memory (process) | Process lifetime | Restart or env var change | Read from environment variables at startup |
No external cache layer (Redis/Memcached): At current scale, PostgreSQL 16 with Drizzle ORM handles the expected query volume without an external cache. A caching layer will be evaluated when query volume exceeds PostgreSQL connection pool capacity (max 20 connections per App Runner instance).
Data Access Layer (Drizzle ORM)
NOTE: The dual-driver abstraction (
db.ts,USE_PG) was removed per ADR-014 (2026-03-03). The data access layer is now Drizzle ORM exclusively.
The database access layer (src/shared/db/schema.ts + Drizzle ORM) provides type-safe access to PostgreSQL 16:
| Pattern | How |
|---|---|
| SELECT queries | db.select().from(table).where(...) |
| Single row SELECT | db.select().from(table).limit(1) |
| INSERT/UPDATE/DELETE | db.insert(table).values(...), db.update(), db.delete() |
| Upsert | db.insert(table).values(...).onConflictDoUpdate(...) |
| Atomic operations | db.transaction(async (tx) => { ... }) |
| Row locking | db.select().from(table).for('update') |
| Raw SQL escape hatch | db.execute(sql\SELECT ...`)` |
Connection string: DATABASE_URL=postgresql://... (required in all environments).
Cross-References
- Schema details: DATABASE-SCHEMA.md
- Database design rationale: database-design.md
- Migration strategy: migration-strategy.md
- Data lifecycle and GDPR: data-lifecycle.md
- Audit architecture: audit-architecture.md
- Indexing strategy: indexing-strategy.md
- API reference: API-REFERENCE.md
- Security architecture: SECURITY-ARCHITECTURE.md
- Authentication: AUTHENTICATION.md
Low-Level Design (LLD)
Detailed flow diagrams and implementation details
Login & Authentication Flow
Flow: Login & Authentication
Document: LLD-001 Version: 1.0 Date: 2026-02-21 Author: Frontend Architect (AI Agent) Status: Draft Scope: End-to-end login flow for web and mobile, including BankID OIDC, session management, demo mode, and error handling
1. Overview
Drop uses BankID OIDC as the sole production authentication method. Email/password login exists only in demo/dev mode. Authentication produces a JWT stored as an httpOnly cookie (web) or Bearer token (mobile). The login flow includes BankID redirect, loading states, token receipt, session persistence, and comprehensive error handling.
Key facts:
- BankID is mandatory for production (PSD2/SCA compliance)
- Demo mode provides email/password fallback for development
- JWT lifetime: 7d (all clients)
- Session tracking via SHA-256 token hash in
sessionstable - Age verification (>= 18) enforced during BankID callback
2. Web Login Flow (BankID OIDC)
2.1 Sequence Diagram — Web BankID Login
sequenceDiagram
actor User
participant Browser as Browser<br/>(Next.js Client)
participant BFF as Next.js BFF<br/>(/api/auth/bankid)
participant BankID as BankID OIDC<br/>Provider
participant DB as SQLite/PostgreSQL
User->>Browser: Navigate to /login
Browser->>Browser: Render login page<br/>(BankID + Vipps buttons)
User->>Browser: Click "BankID" button
Browser->>BFF: GET /api/auth/bankid
BFF->>BFF: Rate limit check (10/min per IP)
BFF->>BFF: Generate state + nonce
BFF->>BFF: Set bankid_state httpOnly cookie
BFF-->>Browser: { redirectUrl }
Browser->>BankID: Redirect to BankID authorize URL<br/>(client_id, redirect_uri, state, nonce, scope=openid)
BankID->>User: BankID authentication UI<br/>(code device, app, or biometric)
User->>BankID: Authenticate with BankID
BankID-->>Browser: 302 → /api/auth/bankid/callback?code=XXX&state=YYY
Browser->>BFF: GET /api/auth/bankid/callback?code&state
BFF->>BFF: Verify state matches bankid_state cookie
BFF->>BankID: POST /token (exchange code for tokens)
BankID-->>BFF: { id_token, access_token }
BFF->>BFF: Verify id_token signature (JWKS)
BFF->>BFF: Parse pid (national ID, 11 digits)
BFF->>BFF: Verify age >= 18 from pid birthdate
BFF->>DB: SELECT user WHERE national_id_hash = SHA-256(pid)
alt New user
BFF->>DB: INSERT user (kyc_status=approved, auth_provider=bankid)
BFF->>DB: INSERT default settings (NOK, nb)
end
BFF->>DB: INSERT session (token_hash, expires_at)
BFF->>BFF: Sign JWT (userId, email, role)
BFF->>BFF: Set drop_token httpOnly cookie (7d, secure, sameSite=Lax)
BFF-->>Browser: 302 → /dashboard
Browser->>Browser: Router navigates to /dashboard
Browser->>BFF: GET /api/auth/me (with cookie)
BFF->>DB: Verify session not revoked
BFF-->>Browser: { user, bankAccounts, totalBalance }
Browser->>Browser: Render dashboard with user data
2.2 Demo Mode Login (Development Only)
In development (isDemoMode() returns true), a demo login endpoint is available. It loads a fixed demo user (usr_demo1, email: demo@example.test) without requiring credentials:
sequenceDiagram
actor User
participant Browser as Browser<br/>(/login page)
participant API as Next.js API<br/>(/api/auth/login)
participant DB as SQLite
User->>Browser: Click "Demo Login"
Browser->>API: POST /v1/auth/demo-login (no credentials)
API->>API: Check isDemoMode()
API->>DB: SELECT user WHERE id = 'usr_demo1'
Note over API: Fixed demo user (demo@example.test)
alt Demo mode active
API->>DB: INSERT session
API->>API: Sign JWT, set httpOnly cookie
API-->>Browser: 200 { user, token }
Browser->>Browser: router.push("/dashboard")
else Demo mode disabled
API-->>Browser: 404 { error: "not_found" }
end
3. Mobile Login Flow (BankID OIDC)
3.1 Sequence Diagram — Mobile BankID Login
sequenceDiagram
actor User
participant App as Expo App
participant WebBrowser as expo-web-browser
participant API as Hono API<br/>(/v1/auth)
participant BankID as BankID OIDC
participant DB as Database
User->>App: Open app, tap "Logg inn"
App->>API: GET /v1/auth/bankid/initiate?platform=mobile
API->>API: Rate limit check
API->>API: Generate state + nonce
API-->>App: { redirectUrl, state }
App->>WebBrowser: Open BankID URL<br/>(expo-web-browser)
WebBrowser->>BankID: BankID authorize URL
BankID->>User: BankID authentication
User->>BankID: Authenticate
BankID-->>WebBrowser: Redirect to drop://auth/callback?code&state
WebBrowser-->>App: Deep link intercept
App->>API: POST /v1/auth/bankid/callback<br/>{ code, state, platform: "mobile" }
API->>BankID: Exchange code for tokens
BankID-->>API: { id_token }
API->>API: Verify id_token (JWKS)
API->>API: Parse pid, verify age >= 18
API->>DB: Find or create user
alt New user
API->>DB: INSERT user (kyc_status=approved)
end
API->>DB: INSERT session
API->>API: Sign JWT (7d expiry)
API-->>App: { token, data: { user } }
App->>App: Store token in AsyncStorage
App->>App: Navigate to (tabs) dashboard
Note over App: Future: biometric unlock<br/>(Face ID / Touch ID)
4. Authentication State Diagram
stateDiagram-v2
[*] --> Unauthenticated: App launch
Unauthenticated --> BankIDRedirect: Click "BankID"
Unauthenticated --> DemoLogin: Enter credentials (dev mode)
BankIDRedirect --> BankIDAuthenticating: Browser opens BankID
BankIDAuthenticating --> CallbackProcessing: BankID returns code
BankIDAuthenticating --> BankIDError: Auth failed/cancelled/timeout
CallbackProcessing --> Authenticated: Valid token + session created
CallbackProcessing --> AgeRejected: User under 18
CallbackProcessing --> BankIDError: Token verification failed
DemoLogin --> Authenticated: Valid credentials
DemoLogin --> LoginError: Invalid credentials
Authenticated --> SessionActive: JWT valid + session not revoked
SessionActive --> TokenExpired: JWT expired (7d all platforms)
SessionActive --> SessionRevoked: Logout or admin revoke
SessionActive --> Authenticated: Token refresh
TokenExpired --> Unauthenticated: Redirect to /login
SessionRevoked --> Unauthenticated: Clear cookie/token
AgeRejected --> Unauthenticated: Show age error
BankIDError --> Unauthenticated: Show error + retry
LoginError --> Unauthenticated: Show error message
5. Error States
5.1 Error State Table
| Error | Cause | User-Facing Message (Norwegian) | Recovery Action |
|---|---|---|---|
| BankID Unavailable | BankID service down | "BankID er midlertidig utilgjengelig. Prøv igjen senere." | Retry button, show status page link |
| BankID Timeout | User took too long (>5min) | "BankID-sesjonen utløp. Vennligst prøv igjen." | Auto-redirect back to login page |
| BankID Cancelled | User cancelled authentication | "Innlogging avbrutt. Trykk 'BankID' for å prøve igjen." | Show login page with BankID button |
| State Mismatch | CSRF attack or stale session | "Noe gikk galt. Vennligst prøv å logge inn på nytt." | Clear state cookie, redirect to /login |
| Token Verification Failed | Invalid/tampered id_token | "Autentisering mislyktes. Prøv igjen." | Redirect to /login |
| Age Under 18 | User is younger than 18 | "Du må være minst 18 år for å bruke Drop." | No retry — age requirement is firm |
| Rate Limited | Too many login attempts | "For mange forsøk. Vent litt og prøv igjen." | Wait and retry (10/min limit) |
| Invalid Credentials (Demo) | Wrong email or password | "Feil e-post eller passord." | Re-enter credentials |
| Session Expired | JWT expired | "Sesjonen din har utløpt. Logg inn igjen." | Redirect to /login |
| Session Revoked | Logout from another device | "Du har blitt logget ut." | Re-login via BankID |
| Network Error | No connectivity | "Ingen nettverkstilkobling. Sjekk internett." | Retry when connectivity restored |
5.2 Error Handling by Platform
| Platform | Error Display | Navigation |
|---|---|---|
| Web | Inline error message on login page, red text below form | router.push("/login") on session errors |
| Mobile | Alert dialog or inline error text | router.replace("/") (welcome screen) on session errors |
6. Session Management
6.1 Token Storage
| Platform | Storage | Token Name | Flags |
|---|---|---|---|
| Web | httpOnly cookie | drop_token |
httpOnly, secure, sameSite=Lax |
| Mobile | AsyncStorage | Bearer token | In-memory variable + persistent storage |
6.2 Session Lifecycle
| Event | Action | Database |
|---|---|---|
| Login success | Create session record | INSERT INTO sessions (id, user_id, token_hash, expires_at) |
| Each request | Verify session valid | SELECT * FROM sessions WHERE token_hash = ? AND revoked = 0 AND expires_at > now() |
| Token refresh | New session, revoke old | INSERT new session, UPDATE old session SET revoked = 1 |
| Logout | Revoke all sessions | UPDATE sessions SET revoked = 1 WHERE user_id = ? |
| Admin action | Revoke specific session | UPDATE sessions SET revoked = 1 WHERE id = ? |
6.3 Token Refresh
POST /v1/auth/refresh refreshes the user's session:
- Reads
drop_tokencookie / Bearer token - Verifies current JWT and session validity
- Revokes old session (
UPDATE sessions SET revoked = 1) - Creates new session + JWT
- Sets new
drop_tokencookie (Max-Age=604800, HttpOnly, SameSite=Lax) - Returns new user data
6.4 Deprecated Endpoints
The following endpoints return 410 Gone and exist only for backward compatibility:
| Endpoint | Status | Reason |
|---|---|---|
POST /v1/auth/login |
410 Gone | Replaced by BankID OIDC flow |
POST /v1/auth/register |
410 Gone | User creation is automatic on first BankID login |
POST /v1/auth/verify-otp |
410 Gone | OTP flow removed with BankID migration |
6.5 useAuth Hook (Web)
The useAuth() hook on the web client:
- Calls
GET /api/auth/meon mount - If 401 → redirects to
/login - Returns
{ user, loading }to the page component - Every authenticated page wraps content with
if (loading) return <Skeleton />
7. UI Components Involved
7.1 Web Login Page (/login)
| Component | Source | Purpose |
|---|---|---|
Image |
next/image | Drop logo display |
Link |
next/link | Navigation to /register, /dashboard |
Button |
shadcn/ui | Submit button, social login buttons |
Mail |
lucide-react | Email input icon |
Lock |
lucide-react | Password input icon |
Eye / EyeOff |
lucide-react | Password visibility toggle |
ArrowRight |
lucide-react | Submit button icon |
7.2 Mobile Login Screen (login.js)
| Element | Implementation | Purpose |
|---|---|---|
| Email input | <TextInput> |
Email entry |
| Password input | <TextInput secureTextEntry> |
Password entry |
| Login button | <TouchableOpacity> |
Trigger api.login() |
| Register link | router.push("/register") |
Navigate to registration |
8. Accessibility Considerations (WCAG 2.1 AA)
| Requirement | Implementation |
|---|---|
| Form labels | All inputs have associated <label> elements |
| Error announcements | Error messages use role="alert" for screen readers |
| Focus management | After error, focus returns to the first invalid field |
| Color contrast | Error red (#EF4444) on white background meets 4.5:1 ratio |
| Keyboard navigation | Tab order follows visual order: email → password → submit → social buttons |
| Password visibility | Toggle button has aria-label "Vis passord" / "Skjul passord" |
| Loading state | Submit button shows loading spinner and is disabled during request |
9. Cross-References
- BankID OIDC integration details: See Authentication
- API endpoints:
GET /v1/auth/bankid/initiate,POST /v1/auth/bankid/callback,POST /v1/auth/demo-login,POST /v1/auth/refresh,GET /api/auth/me— See API Reference - Session database schema:
sessionstable — See Database Schema - Component overview: See component-overview.md
- Figma login screen:
mockups/figma-make-export/src/app/screens/Login.tsx - Web login page:
src/drop-app/src/app/login/page.tsx— See PAGES.md - Registration flow: See flow-registration-onboarding.md
Login & Authentication (Backend)
Login & Authentication — Backend Architecture
Backend-specific authentication details for the Drop fintech platform. Covers JWT token structure, token refresh mechanism, session revocation, rate limiting on auth endpoints, audit logging, demo mode implementation, and cookie security settings.
JWT Token Structure
Token Generation
Source: src/drop-api/src/lib/auth.ts:42-48
Library: jose (HS256 default, RS256 opt-in)
new jose.SignJWT({ userId, email, role })
.setProtectedHeader({ alg: "HS256" })
.setIssuedAt()
.setIssuer("drop-api")
.setAudience("drop")
.setExpirationTime("7d")
.sign(key);
JWT Claims
| Claim | Type | Value | Source |
|---|---|---|---|
userId |
string |
usr_<16 hex chars> (e.g., usr_a1b2c3d4e5f6g7h8) |
auth.ts:44 — from user record |
email |
string |
usr_xxx@bankid.drop.local (BankID placeholder) or real email |
auth.ts:44 — from user record |
role |
string |
user or merchant |
auth.ts:44 — from users.role column |
iat |
number |
Unix timestamp of issuance | auth.ts:45 — setIssuedAt() |
exp |
number |
iat + 7 days (604800 seconds) |
auth.ts:45 — setExpirationTime("7d") |
iss |
string |
drop-api |
auth.ts:45 — setIssuer() |
aud |
string |
drop |
auth.ts:45 — setAudience() |
Algorithm Selection
| Algorithm | Condition | Key Source | Use Case |
|---|---|---|---|
| HS256 (default) | JWT_RS256_PRIVATE_KEY not set |
JWT_SECRET env var → TextEncoder.encode() |
Standard deployment |
| RS256 (opt-in) | Both JWT_RS256_PRIVATE_KEY and JWT_RS256_PUBLIC_KEY set |
PEM-encoded RSA key pair | Multi-service verification (API gateway, microservices) |
Source: auth.ts:12-34 — getAlgorithm() auto-detects based on available keys.
Token Verification
Source: auth.ts:50-66
- Determine algorithm (HS256 or RS256)
- Call
jose.jwtVerify(token, key, { issuer: "drop-api", audience: "drop" }) - Extract
userId,email,rolefrom payload - Type-check: both
userIdandemailmust be strings - Default
roleto"user"if not present
JWT Refresh Flow
sequenceDiagram
participant Client as Client (Web/Mobile)
participant API as drop-api
participant DB as Database
Client->>API: POST /v1/auth/refresh<br/>Authorization: Bearer <current-jwt>
API->>API: Extract token from Bearer header or cookie
API->>API: Verify JWT signature (jose)
API->>DB: SELECT session WHERE token_hash = SHA256(token)<br/>AND revoked = 0 AND expires_at > NOW()
DB-->>API: Session valid
API->>DB: SELECT user WHERE id = userId AND deleted_at IS NULL
DB-->>API: User record
Note over API: Revoke ALL existing sessions
API->>DB: UPDATE sessions SET revoked = 1<br/>WHERE user_id = ?
Note over API: Create new session
API->>API: Sign new JWT (7d expiry)
API->>DB: INSERT INTO sessions<br/>(id, user_id, token_hash, expires_at)
Note over API: Set cookie for web clients
API->>API: Set-Cookie: drop_token=<new-jwt>;<br/>HttpOnly; Path=/; Max-Age=604800; SameSite=Lax
API-->>Client: { data: { id, email, firstName, ... }, token: "<new-jwt>" }
Refresh Behavior
Source: routes/auth.ts:201-210
- Auth middleware validates current token
- All existing sessions revoked (
revokeAllSessions(user.id)) - New JWT signed with fresh
iatandexp(7 days from now) - New session record created in
sessionstable - Cookie set for web clients (
Set-Cookieheader) - Token returned in JSON body for mobile clients
Key design decision: Token refresh performs a full session rotation — old sessions are invalidated immediately. This limits the window for token theft: a stolen token becomes invalid as soon as the legitimate user refreshes.
Session Revocation
Session Revocation Flow
sequenceDiagram
participant Client as Client
participant API as drop-api
participant DB as Database
alt Logout (user-initiated)
Client->>API: POST /v1/auth/logout<br/>Authorization: Bearer <jwt>
API->>API: Verify token (authMiddleware)
API->>DB: UPDATE sessions SET revoked = 1<br/>WHERE user_id = ?
Note over DB: ALL sessions for this user revoked
API->>DB: INSERT INTO audit_log<br/>(action: 'logout', resource_type: 'session')
API->>API: Set-Cookie: drop_token=; Max-Age=0
API-->>Client: { data: { message: "Logged out" } }
else Security incident (admin-initiated)
Note over API: Admin detects compromised account
API->>DB: UPDATE sessions SET revoked = 1<br/>WHERE user_id = ?
API->>DB: INSERT INTO audit_log<br/>(action: 'security_revocation')
Note over Client: Next request fails auth check
Client->>API: Any authenticated request
API->>DB: SELECT session WHERE token_hash = ?<br/>AND revoked = 0
DB-->>API: No valid session found
API-->>Client: 401 Unauthorized
else Token refresh (rotation)
Client->>API: POST /v1/auth/refresh
API->>DB: UPDATE sessions SET revoked = 1<br/>WHERE user_id = ?
Note over DB: Old sessions invalidated
API->>DB: INSERT INTO sessions (new session)
API-->>Client: { token: "<new-jwt>" }
end
Session Verification on Every Request
Source: auth.ts:108-117
Every authenticated request performs these checks:
- Token signature verification — JWT must be valid and not expired
- Session lookup —
SELECT id FROM sessions WHERE token_hash = SHA256(token) AND revoked = 0 AND expires_at > NOW() - Session count check — If user has any sessions in DB but none match the current token, reject (prevents use of tokens from before session tracking was enabled)
- User existence check —
SELECT * FROM users WHERE id = ? AND deleted_at IS NULL(soft-deleted users are blocked)
Session Table Schema
| Column | Type | Description |
|---|---|---|
id |
TEXT PK | Format: ses_<16 hex chars> |
user_id |
TEXT FK | References users.id |
token_hash |
TEXT | SHA-256 hash of the JWT string |
created_at |
TEXT | ISO timestamp of session creation |
expires_at |
TEXT | ISO timestamp, 7 days from creation |
revoked |
INTEGER | 0 = active, 1 = revoked |
Indexes: idx_sessions_user (user_id), idx_sessions_token (token_hash)
Rate Limiting on Auth Endpoints
Rate Limit Configuration
| Endpoint | Limit | Window | Source |
|---|---|---|---|
GET /v1/auth/bankid/initiate |
10 requests | 60 seconds | routes/auth.ts:19 |
POST /v1/auth/bankid/callback |
10 requests | 60 seconds | routes/auth.ts:43 |
POST /v1/auth/demo-login |
Inherits from service mode check | N/A | Only available in demo mode |
GET /v1/auth/me |
No additional rate limit | N/A | Auth required (implicit protection) |
POST /v1/auth/logout |
No additional rate limit | N/A | Auth required |
POST /v1/auth/refresh |
No additional rate limit | N/A | Auth required |
Rate Limiting Implementation
Source: middleware/rate-limit.ts:7-23
Storage: rate_limits table (persistent across restarts)
Key: Client IP address
Algorithm: Fixed window counter
Cleanup: Every 100 requests, expired entries are deleted
Atomic: Uses runUpsert for race-condition-safe counter updates
The rate limiter uses the rate_limits database table:
| Column | Type | Description |
|---|---|---|
key |
TEXT PK | Client IP address |
count |
INTEGER | Request count in current window |
reset_at |
INTEGER | Unix timestamp when window resets |
Client IP Extraction
Source: middleware/rate-limit.ts:25-27
Priority order:
x-real-ipheader (nginx/Cloudflare)- First IP in
x-forwarded-forchain (proxy chain) - Fallback:
127.0.0.1
Audit Logging for Auth Events
Audit Actions
Source: src/drop-api/src/lib/audit.ts
| Action | Trigger | Data Recorded |
|---|---|---|
REGISTER |
New user created via BankID | userId, method: bankid, isNewUser: true, IP, user agent |
LOGIN |
Existing user authenticated via BankID | userId, method: bankid, isNewUser: false, IP, user agent |
LOGOUT |
User calls /v1/auth/logout |
userId, resourceType: session |
REFRESH |
Token refresh | userId, resourceType: session |
Audit Log Schema
Table: audit_log
| Column | Type | Auth-Specific Usage |
|---|---|---|
id |
TEXT PK | Format: aud_<16 hex chars> |
timestamp |
TEXT | ISO timestamp of event |
user_id |
TEXT FK | Authenticated user ID |
action |
TEXT | One of REGISTER, LOGIN, LOGOUT, REFRESH |
resource_type |
TEXT | auth or session |
resource_id |
TEXT | Session ID (for session events) |
details |
TEXT | JSON: { method, isNewUser, platform } |
ip_address |
TEXT | Client IP from middleware |
user_agent |
TEXT | User-Agent header value |
request_id |
TEXT | Correlation ID from x-request-id header |
Audit Log Example
{
"id": "aud_a1b2c3d4e5f6g7h8",
"timestamp": "2026-02-21T12:00:00.000Z",
"user_id": "usr_f1e2d3c4b5a69788",
"action": "LOGIN",
"resource_type": "auth",
"details": "{\"method\":\"bankid\",\"isNewUser\":false}",
"ip_address": "203.0.113.42",
"user_agent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X)",
"request_id": "550e8400-e29b-41d4-a716-446655440000"
}
Demo Mode Implementation
Overview
Source: routes/auth.ts:131-159
Demo mode provides authentication without BankID for development and testing. API-side demo mode is controlled by DROP_MODE env var (checked via isDemoMode() in services/mode.ts). NEXT_PUBLIC_SERVICE_MODE is the client-side equivalent (set to demo in docker-compose.yml).
Demo Login Endpoint
Endpoint: POST /v1/auth/demo-login
| Aspect | Behavior |
|---|---|
| Availability | Only when isDemoMode() returns true |
| Authentication | None required |
| User | Fixed demo user: usr_demo1 (seeded in db.ts) |
| Response | JWT token + user data (same format as BankID callback) |
| Feature flag | Returns 404 when demo mode is disabled |
Demo User Profile
| Field | Value | Source |
|---|---|---|
| ID | usr_demo1 |
db.ts seed data |
demo@example.test |
db.ts seed data |
|
| Name | Demo User | db.ts seed data |
| Phone | +4700000000 |
db.ts seed data |
| Role | merchant |
Upgraded in initDb() |
| KYC Status | approved |
Set on seed |
| Bank accounts | DNB (45,000 NOK), Nordea (12,350 NOK) | db.ts seed data |
BankID Mock Mode
Source: bankid.ts:126-128
When BANKID_MOCK=true, the BankID OIDC flow is mocked:
exchangeAndVerify()skips token exchange and JWKS verification- Returns a mock user based on the auth code value:
- Code starting with
underage: returns user born 2010 (fails age check) - Default: returns
Test Bankersen, born 1990 (passes age check)
- Code starting with
Deprecated Endpoints
Source: routes/auth.ts:109-128
| Endpoint | Status Code | Message |
|---|---|---|
POST /v1/auth/login |
410 Gone | "Email/password login is no longer supported. Please use BankID." |
POST /v1/auth/register |
410 Gone | "Email/password registration is no longer supported. Please use BankID." |
POST /v1/auth/verify-otp |
410 Gone | "OTP verification is no longer supported. Authentication is handled via BankID." |
Cookie Security Settings
Cookie Configuration
Source: routes/auth.ts:195, 206
| Property | Value | Purpose | Source |
|---|---|---|---|
HttpOnly |
true |
Prevents JavaScript access — mitigates XSS token theft | auth.ts, cookie string |
Secure |
true (production) |
Cookie only sent over HTTPS | Implied by deployment (Cloudflare enforces HTTPS) |
SameSite |
Lax |
Prevents CSRF — cookie not sent on cross-origin POST requests | routes/auth.ts:206 |
Path |
/ |
Cookie available to all routes | routes/auth.ts:206 |
Max-Age |
604800 (7 days) |
Session lifetime matching JWT expiry | routes/auth.ts:206 |
Domain |
Not set (defaults to current domain) | Scoped to getdrop.no in production |
Default browser behavior |
Cookie Lifecycle
| Event | Cookie Action | Source |
|---|---|---|
| BankID callback (web) | Set drop_token=<jwt> with full security attributes |
BFF redirect handler |
| Token refresh | Set new drop_token=<new-jwt>, same attributes |
routes/auth.ts:206 |
| Logout | Clear cookie: drop_token=; Max-Age=0 |
routes/auth.ts:195 |
SameSite=Lax Behavior
| Request Type | Cookie Sent? | Reason |
|---|---|---|
| Same-origin GET | Yes | Normal navigation |
| Same-origin POST | Yes | Form submissions |
| Cross-origin GET (top-level navigation) | Yes | Allows BankID redirect back |
| Cross-origin POST | No | CSRF protection |
| Cross-origin AJAX/fetch | No | CSRF protection |
| Subdomain requests | Depends on Domain setting | No Domain set = strict origin match |
Web vs Mobile Token Strategy
| Aspect | Web (Next.js) | Mobile (Expo) |
|---|---|---|
| Token delivery | httpOnly cookie (drop_token) |
JSON body ({ token }) |
| Token storage | Browser cookie jar (managed by browser) | AsyncStorage (React Native encrypted storage) |
| Token transmission | Automatic via Cookie header |
Manual via Authorization: Bearer header |
| CSRF protection | SameSite=Lax + CORS origin validation |
Not needed (no cookies, Bearer token) |
| Token extraction | auth.ts:96-99 — parse from cookie header |
auth.ts:93-94 — extract from Authorization header |
Cross-References
- BankID OIDC flow: AUTHENTICATION.md — Full BankID authentication sequence
- Auth source:
src/drop-api/src/lib/auth.ts— JWT signing, verification, session management - Auth routes:
src/drop-api/src/routes/auth.ts— Endpoint handlers - Auth middleware:
src/drop-api/src/middleware/auth.ts— Request authentication - Rate limiter:
src/drop-api/src/middleware/rate-limit.ts— IP-based rate limiting - BankID library:
src/drop-api/src/lib/bankid.ts— OIDC flow, pid parsing, user creation - Security architecture: SECURITY-ARCHITECTURE.md — Cookie settings, JWT configuration
- API reference: API-REFERENCE.md — Full endpoint documentation
- Database schema: DATABASE-SCHEMA.md —
sessions,users,audit_logtables
Registration & Onboarding Flow
Registration & Onboarding Flow -- Low-Level Design
Document: LLD-REGISTRATION Status: Approved Last updated: 2026-02-21 Author: Standards Architect Applies to: Drop v1.0 (PSD2 pass-through model) User requirements: Minimum age 18, Norwegian residency, valid BankID (from vilkar.html)
Overview
Drop's registration is simplified by using BankID as the sole authentication provider. There is no separate registration form -- user accounts are created automatically on first BankID login. The onboarding flow then guides the user through consent collection, KYC verification, and first bank account linking.
Key principle: Progressive disclosure. Users see only what they need at each step. Heavy verification happens in the background while the user explores the app.
Complete Registration Flow
sequenceDiagram
participant User
participant App as Drop App
participant BFF as Next.js BFF / Hono API
participant BankID as BankID OIDC
participant Sumsub
participant Bank as Nordic Bank (Open Banking)
participant DB as PostgreSQL
Note over User,DB: Step 1 -- BankID Authentication + Auto-Registration
User->>App: Tap "Logg inn med BankID"
App->>BFF: GET /api/auth/bankid (or /v1/auth/bankid/initiate)
BFF->>BFF: Generate state + nonce
BFF->>BFF: Rate limit check (10/min per IP)
BFF->>App: { redirectUrl }
App->>BankID: Open BankID authorize URL
User->>BankID: Authenticate (BankID app/code device)
Note over User,BankID: SCA: possession (device) + knowledge (PIN)
BankID->>App: Redirect with ?code=&state=
App->>BFF: GET /callback?code=&state= (or POST /callback)
BFF->>BFF: Verify state vs cookie/session
BFF->>BankID: POST /token (exchange code)
BankID->>BFF: { id_token, access_token }
BFF->>BFF: Verify ID token signature (JWKS)
BFF->>BFF: Extract pid (fodselsnummer, 11 digits)
BFF->>BFF: Parse DOB from pid
BFF->>BFF: Verify age >= 18
alt Age < 18
BFF->>App: Error: "Du ma vaere minst 18 ar"
App->>User: Show age restriction message
else Age >= 18
BFF->>BFF: SHA-256 hash pid -> national_id_hash
BFF->>DB: SELECT user WHERE national_id_hash = ?
alt Existing user
BFF->>DB: Create session + JWT
BFF->>App: Set cookie / return Bearer token
App->>User: Redirect to /dashboard
else New user (first login)
BFF->>DB: INSERT user (kyc_status='approved', kyc_method='bankid', auth_provider='bankid', password_hash='EIDONLY')
BFF->>DB: Create session + JWT
BFF->>App: Set cookie / return Bearer token
App->>User: Redirect to /onboarding
end
end
Note over User,DB: Step 2 -- Consent Collection (Onboarding Screen 1)
App->>User: Show consent checkboxes
User->>App: Accept terms + privacy + data processing
App->>BFF: POST /api/consents (type: 'terms', granted: true)
BFF->>DB: INSERT consents (terms, ip_address, granted_at)
App->>BFF: POST /api/consents (type: 'privacy', granted: true)
BFF->>DB: INSERT consents (privacy, ip_address, granted_at)
App->>User: Optional: marketing consent checkbox
Note over User,App: Marketing consent is OPTIONAL per GDPR
Note over User,DB: Step 3 -- KYC Trigger (Background)
BFF->>Sumsub: Create applicant (name, DOB from BankID)
Sumsub->>BFF: applicant_id
BFF->>DB: Store applicant_id
Sumsub->>Sumsub: PEP + sanctions screening
Sumsub->>BFF: Webhook: screening results
BFF->>DB: INSERT screening_results
Note over User,DB: Step 4 -- Bank Account Linking (Onboarding Screen 2)
App->>User: "Koble til bankkonto"
User->>App: Select bank (DNB, SpareBank1, Nordea...)
App->>BFF: POST /api/bank-accounts/link
BFF->>Bank: Open Banking: AISP consent request
Bank->>User: Authorize AISP access (SCA)
User->>Bank: Approve
Bank->>BFF: AISP access token + account list
BFF->>DB: INSERT bank_accounts (from AISP response)
BFF->>Bank: GET /accounts/{id}/balances
Bank->>BFF: { balance, currency }
BFF->>DB: UPDATE bank_accounts SET balance = ?, is_primary = 1
BFF->>App: { bankAccounts: [...] }
App->>User: Show linked account with balance
Note over User,DB: Step 5 -- Onboarding Complete
App->>User: "Velkommen til Drop!"
App->>User: Redirect to /dashboard
Onboarding States
stateDiagram-v2
[*] --> bankid_redirect : User taps "Logg inn med BankID"
bankid_redirect --> bankid_auth : BankID authorize page
bankid_auth --> age_check : BankID callback received
age_check --> rejected_underage : Age < 18
age_check --> user_lookup : Age >= 18
rejected_underage --> [*]
user_lookup --> existing_user : national_id_hash found
user_lookup --> new_user : national_id_hash not found
existing_user --> dashboard : Session created, redirect
new_user --> user_created : Auto-register from BankID data
user_created --> consent_collection : Redirect to /onboarding
consent_collection --> consents_granted : Terms + privacy accepted
consents_granted --> kyc_background : Sumsub screening starts
kyc_background --> bank_linking : Screening runs in background
bank_linking --> bank_consent : User selects bank
bank_consent --> bank_authorized : AISP consent granted
bank_authorized --> account_linked : Balance fetched
account_linked --> onboarding_complete : First bank account linked
onboarding_complete --> dashboard : Redirect to /dashboard
state kyc_background {
[*] --> sumsub_pending
sumsub_pending --> screening
screening --> kyc_approved : All clear
screening --> kyc_review : Match found
kyc_review --> kyc_approved : Cleared by compliance
kyc_review --> kyc_rejected : Confirmed risk
}
dashboard --> [*]
Age Verification (18+)
Norwegian fodselsnummer (11-digit personal identification number) encodes the date of birth:
| Digits | Meaning | Example |
|---|---|---|
| 1-2 | Day of birth (DD) | 15 |
| 3-4 | Month of birth (MM) | 03 |
| 5-6 | Year of birth (YY) | 95 |
| 7-9 | Individual number | 123 |
| 10-11 | Check digits | 45 |
Century determination (from individual number, digits 7-9):
- 000-499: born 1900-1999
- 500-749: born 1854-1899 (historical) or 2000-2039
- 750-899: born 1854-1899 (historical)
- 900-999: born 1940-1999
Verification logic:
1. Extract DD, MM, YY from pid[0..5]
2. Determine century from pid[6..8]
3. Construct full birthdate
4. Calculate age = today - birthdate
5. If age < 18: reject with "Du ma vaere minst 18 ar for a bruke Drop"
Source: vilkar.html section 3 -- "Du ma vaere minst 18 ar og bosatt i Norge for a bruke Drop."
Norwegian Residency Check
BankID issuance inherently confirms Norwegian residency:
- BankID is issued only by Norwegian banks to their customers
- Norwegian bank accounts require Norwegian national ID (fodselsnummer or D-number)
- D-numbers are issued to foreign nationals with legitimate ties to Norway
Additional signals:
- Phone number prefix:
+47(Norwegian) - BankID issuer: Norwegian bank (from ID token
amrclaim)
| Signal | Check | Enforcement |
|---|---|---|
| BankID ownership | Implicit -- only Norwegian residents have BankID | At authentication |
| Phone number | +47 prefix |
Optional validation at profile update |
| Postal address | Norwegian postal code | At bank account linking (from AISP data) |
Consent Collection
Required Consents
Per GDPR Article 6(1)(a) and Article 7, explicit consent must be collected before processing personal data. The following consents are collected during onboarding:
| Consent Type | Required | Legal Basis | Description | Withdrawable |
|---|---|---|---|---|
terms |
Yes (mandatory) | Contract (Art. 6(1)(b)) | Terms of service acceptance | Account deletion required |
privacy |
Yes (mandatory) | Consent (Art. 6(1)(a)) | Privacy policy acknowledgment | Account deletion required |
data_processing |
Yes (mandatory) | Consent (Art. 6(1)(a)) | PSD2 AISP/PISP data processing consent | Revokes bank access |
marketing |
No (optional) | Consent (Art. 6(1)(a)) | Marketing communications | Yes, at any time |
cookies_analytics |
No (optional) | Consent (Art. 6(1)(a)) | Analytics cookies | Yes, at any time |
cookies_marketing |
No (optional) | Consent (Art. 6(1)(a)) | Marketing/tracking cookies | Yes, at any time |
Consent Checklist
| # | Consent | Checkbox Text (Norwegian) | Default | Validation |
|---|---|---|---|---|
| 1 | Terms of service | "Jeg godtar Drop sine brukervilkar" | Unchecked | Must be checked to proceed |
| 2 | Privacy policy | "Jeg har lest og godtar personvernerklaringen" | Unchecked | Must be checked to proceed |
| 3 | PSD2 data access | "Jeg godtar at Drop leser kontoinformasjon og initierer betalinger via Open Banking" | Unchecked | Must be checked to proceed |
| 4 | Marketing | "Jeg onsker a motta nyheter og tilbud fra Drop" | Unchecked | Optional |
Consent Storage
Each consent is stored in the consents table:
| Column | Value | Purpose |
|---|---|---|
id |
con_<hex16> |
Unique consent record ID |
user_id |
usr_<hex16> |
References the user |
consent_type |
terms, privacy, marketing, etc. |
Type of consent |
granted |
1 (true) or 0 (false) |
Current consent state |
granted_at |
ISO timestamp | When consent was granted |
withdrawn_at |
ISO timestamp or NULL | When consent was withdrawn |
ip_address |
Client IP | Proof of consent action (GDPR Art. 7(1)) |
Consent API
| Endpoint | Method | Purpose |
|---|---|---|
GET /api/consents |
GET | List all user consents |
POST /api/consents |
POST | Grant or withdraw consent |
Consent withdrawal flow:
First Bank Account Linking
After consent collection, the user links their first bank account via AISP:
sequenceDiagram
participant User
participant App as Drop App
participant BFF as Drop API
participant Bank as Nordic Bank
User->>App: Select bank from list (DNB, SpareBank1, etc.)
App->>BFF: POST /api/bank-accounts/link { bankId: "dnb" }
BFF->>Bank: Open Banking: GET /authorize (AISP scope)
Bank->>User: Show consent screen (SCA required)
Note over User,Bank: "Drop vil lese kontosaldo og<br/>transaksjonshistorikk"
User->>Bank: Approve (BankID in banking app)
Bank->>BFF: Authorization code
BFF->>Bank: POST /token (exchange code)
Bank->>BFF: AISP access token (90-day validity)
BFF->>Bank: GET /accounts (list user accounts)
Bank->>BFF: [{ accountId, iban, name, type }]
BFF->>Bank: GET /accounts/{id}/balances
Bank->>BFF: { balance: 45230.00, currency: "NOK" }
BFF->>BFF: INSERT bank_accounts
BFF->>BFF: Set first account as is_primary = 1
BFF->>App: { bankAccounts: [{ bankName: "DNB", balance: 45230, isPrimary: true }] }
App->>User: "DNB koblet! Saldo: 45 230 kr"
Progressive Disclosure UX
The onboarding flow uses progressive disclosure to minimize upfront friction:
| Step | Screen | User Action | Background Action |
|---|---|---|---|
| 1 | BankID login | Tap "Logg inn med BankID" | Auto-create account from BankID data |
| 2 | Consent | Check 3 mandatory boxes + optional marketing | Record consents with IP + timestamp |
| 3 | Link bank | Select bank, approve AISP | Sumsub KYC screening runs in parallel |
| 4 | Dashboard | Explore the app | KYC result webhook updates user status |
Time to first value: ~2 minutes (BankID auth + consents + bank linking)
Deferred actions (not required during onboarding):
- Add remittance recipients (done when user first sends money)
- Merchant registration (done from Settings if user is a business)
- Notification preferences (defaults: push enabled, email enabled)
- Profile completion (BankID provides name and DOB; address is optional)
Error Handling
| Error | Screen | User Message | Action |
|---|---|---|---|
| BankID timeout | Login | "BankID-tidsavbrudd. Prov igjen." | Retry button |
| Age < 18 | Login | "Du ma vaere minst 18 ar for a bruke Drop." | No retry -- explain policy |
| BankID state mismatch | Login | "Noe gikk galt. Prov igjen." | Redirect to login |
| Consent not accepted | Onboarding | Cannot proceed without mandatory consents | Highlight unchecked boxes |
| Bank linking failed | Onboarding | "Kunne ikke koble banken. Prov igjen." | Retry or skip (link later) |
| Sumsub KYC rejected | Background | "Identitetsbekreftelse mislyktes. Kontakt oss." | Account limited until resolved |
Cross-References
- KYC/AML Flow -- Detailed KYC verification and AML monitoring
- Login Authentication Flow -- BankID login implementation details
- Bank Account Linking Flow -- AISP integration details
- Authentication System -- JWT, sessions, BankID OIDC
- BankID OIDC Integration -- BankID technical specification
- API Reference -- Consent and auth endpoints
- Database Schema -- users, consents, bank_accounts tables
- ADR-007: BankID OIDC Auth -- Authentication provider decision
- ADR-003: PSD2 Pass-through -- Pass-through model context
KYC & AML Flow
KYC/AML Flow -- Low-Level Design
Document: LLD-KYC-AML Status: Approved Last updated: 2026-02-21 Author: Standards Architect Applies to: Drop v1.0 (PSD2 pass-through model) Regulatory basis: Hvitvaskingsloven (LOV-2018-06-01-23), GDPR (Personopplysningsloven)
Overview
Drop's KYC (Know Your Customer) and AML (Anti-Money Laundering) system ensures compliance with Norwegian anti-money laundering law (hvitvaskingsloven). The system has three phases:
- Onboarding KYC -- Identity verification at registration via BankID + Sumsub document verification
- Transaction monitoring -- Real-time and periodic analysis of transaction patterns
- Ongoing due diligence -- Periodic re-screening of PEP/sanctions lists and adverse media
Drop uses a risk-based approach per hvitvaskingsloven section 4-6: higher-risk customers and transactions receive enhanced scrutiny.
KYC Verification Flow
sequenceDiagram
participant User
participant Drop as Drop API
participant BankID
participant Sumsub
participant DB as PostgreSQL
Note over User,DB: Phase 1 -- BankID Identity Verification
User->>Drop: Login via BankID OIDC
Drop->>BankID: Exchange auth code for ID token
BankID->>Drop: ID token (pid, name, DOB)
Drop->>Drop: Parse pid (fodselsnummer)
Drop->>Drop: Verify age >= 18
Drop->>Drop: Hash pid with SHA-256
Drop->>DB: Find/create user (national_id_hash)
Drop->>DB: Set kyc_status = 'approved', kyc_method = 'bankid'
Note over User,DB: Phase 2 -- Sumsub Enhanced Verification
Drop->>Sumsub: Create applicant (user_id, name, DOB)
Sumsub->>Drop: applicant_id
Drop->>DB: Store applicant_id in users table
Drop->>User: Request document upload (if EDD triggered)
alt Standard CDD (low risk)
Note over User,Sumsub: BankID sufficient -- no document upload
Sumsub->>Sumsub: Auto-approve based on BankID data
else Enhanced CDD (high risk)
User->>Sumsub: Upload ID document + selfie
Sumsub->>Sumsub: Document verification + liveness check
end
Note over User,DB: Phase 3 -- PEP/Sanctions Screening
Sumsub->>Sumsub: Screen against PEP lists
Sumsub->>Sumsub: Screen against sanctions (OFAC, UN, EU, Norway)
Sumsub->>Sumsub: Screen adverse media
Sumsub->>Drop: Webhook: verification result
Drop->>DB: Update kyc_status (approved/rejected)
Drop->>DB: Insert screening_results (pep, sanctions, adverse_media)
Drop->>DB: Update users.risk_level, pep_status, sanctions_cleared
alt Screening match found
Drop->>DB: Create aml_alert (severity based on match type)
Drop->>Drop: Block user from transactions
end
KYC Applicant States
stateDiagram-v2
[*] --> bankid_verified : BankID login successful
bankid_verified --> sumsub_pending : Sumsub applicant created
sumsub_pending --> document_requested : EDD required (high risk)
sumsub_pending --> screening : CDD sufficient (low risk)
document_requested --> document_uploaded : User uploads ID + selfie
document_uploaded --> document_review : Sumsub processes documents
document_review --> screening : Documents verified
document_review --> document_rejected : Documents invalid
document_rejected --> document_requested : User retries
screening --> approved : All clear (PEP, sanctions, media)
screening --> manual_review : Potential match found
manual_review --> approved : Compliance officer clears
manual_review --> rejected : Confirmed match or fraud
approved --> ongoing_monitoring : Periodic re-screening
ongoing_monitoring --> manual_review : Re-screening match
ongoing_monitoring --> approved : Re-screening clear
rejected --> [*]
Document Verification Steps
When Enhanced Due Diligence (EDD) is triggered, Sumsub performs multi-step document verification:
| Step | Check | Provider | Pass Criteria |
|---|---|---|---|
| 1. Document quality | Image clarity, glare, blur | Sumsub AI | Readable text, clear photo |
| 2. Document authenticity | Hologram detection, font analysis, template matching | Sumsub AI | Matches known document templates |
| 3. Data extraction | OCR: name, DOB, document number, expiry | Sumsub OCR | All fields extracted successfully |
| 4. Cross-reference | Extracted data vs BankID data (name, DOB) | Drop API | Name and DOB match within tolerance |
| 5. Liveness check | Selfie vs document photo, anti-spoofing | Sumsub AI | Face match > 80%, liveness confirmed |
| 6. Expiry check | Document expiration date | Sumsub | Document not expired |
Accepted documents (Norway-specific):
- Norwegian passport (preferred)
- Norwegian national ID card
- Norwegian driver's license (with photo)
- EEA passport or national ID card (for EEA residents)
PEP/Sanctions Screening
Screening Sources
| List | Source | Update Frequency | Scope |
|---|---|---|---|
| Norwegian PEP list | Finanstilsynet | Real-time | Norwegian politically exposed persons |
| OFAC SDN | US Treasury | Daily | Global sanctions |
| UN Consolidated | UN Security Council | Real-time | Global sanctions |
| EU Consolidated | European Commission | Daily | EU sanctions |
| Norwegian sanctions | Utenriksdepartementet | As published | Norway-specific restrictions |
| Adverse media | Sumsub media monitoring | Continuous | Negative news, legal proceedings |
Screening Triggers
| Trigger | Type | Screening Scope |
|---|---|---|
| New user registration | Initial CDD | Full: PEP + sanctions + adverse media |
| Transaction > 10,000 NOK | Transaction monitoring | Sanctions only (real-time) |
| Cumulative 30-day > 50,000 NOK | Periodic monitoring | Full re-screening |
| Quarterly schedule | Ongoing due diligence | Full re-screening of all active users |
| User data change | Event-driven | Full re-screening |
Database: screening_results table
| Column | Type | Values |
|---|---|---|
screening_type |
TEXT | pep, sanctions, adverse_media |
provider |
TEXT | sumsub (or future: refinitiv, dow_jones) |
result |
TEXT | clear, match, potential_match, error |
match_details |
TEXT (JSON) | Match metadata (name similarity, list source, entry details) |
Risk Scoring Algorithm
Drop assigns a risk level to each user based on multiple factors. The risk score determines the level of due diligence applied.
Risk Factor Matrix
| Factor | Low Risk (1 pt) | Medium Risk (3 pts) | High Risk (5 pts) |
|---|---|---|---|
| Country of origin | Norway, Sweden, Denmark, Finland | EU/EEA countries | Non-EEA, high-risk jurisdictions (FATF grey/black list) |
| Remittance corridor | SEPA (intra-EEA) | Non-EEA low-risk | Pakistan, Turkey, non-EEA high-risk |
| Transaction volume (30-day) | < 10,000 NOK | 10,000-50,000 NOK | > 50,000 NOK |
| Transaction frequency (30-day) | < 5 transactions | 5-20 transactions | > 20 transactions |
| PEP status | Not PEP | PEP family member | PEP (direct) |
| Sanctions screening | Clear | Potential match (resolved) | Active match |
| Account age | > 12 months | 3-12 months | < 3 months |
| Adverse media | None | Resolved/historical | Active negative coverage |
Risk Level Classification
| Total Score | Risk Level | Due Diligence | Monitoring | Transaction Limits |
|---|---|---|---|---|
| 8-12 | Low | Standard CDD (BankID only) | Quarterly re-screening | 50,000 NOK/month |
| 13-20 | Medium | Enhanced CDD (BankID + document) | Monthly re-screening | 25,000 NOK/month |
| 21-30 | High | Enhanced CDD + source of funds | Weekly re-screening | 10,000 NOK/month |
| 31+ | Prohibited | Account blocked | Continuous | 0 (blocked) |
Database: users risk fields
| Column | Type | Purpose |
|---|---|---|
risk_level |
TEXT | low, medium, high, prohibited |
pep_status |
TEXT | none, pep_family, pep_direct |
sanctions_cleared |
INTEGER | 0 = not cleared, 1 = cleared |
AML Transaction Monitoring
Alert Rules
| Rule ID | Alert Type | Trigger | Severity | Description |
|---|---|---|---|---|
| AML-001 | structuring |
Multiple transactions just below 10,000 NOK threshold | high |
Potential structuring to avoid reporting |
| AML-002 | velocity |
> 5 transactions in 1 hour | medium |
Unusual transaction velocity |
| AML-003 | high_value |
Single transaction > 25,000 NOK | medium |
Large transaction review |
| AML-004 | cumulative |
30-day total > 50,000 NOK | high |
Cumulative volume threshold |
| AML-005 | corridor_risk |
Transfer to FATF grey/black list country | high |
High-risk corridor |
| AML-006 | new_account_high_value |
Account < 30 days + transaction > 5,000 NOK | medium |
New account with large transfer |
| AML-007 | round_amounts |
Multiple transactions with round amounts (1000, 5000, 10000) | low |
Potential structuring pattern |
| AML-008 | rapid_recipient_add |
> 3 new recipients in 24 hours | medium |
Unusual recipient creation pattern |
Database: aml_alerts table
| Column | Type | Values |
|---|---|---|
alert_type |
TEXT | structuring, velocity, high_value, cumulative, corridor_risk, etc. |
severity |
TEXT | low, medium, high, critical |
status |
TEXT | open, investigating, resolved, escalated, filed |
reviewed_by |
TEXT | Compliance officer identifier |
Alert Lifecycle
stateDiagram-v2
[*] --> open : Rule triggered
open --> investigating : Compliance officer reviews
investigating --> resolved : False positive confirmed
investigating --> escalated : Suspicious activity confirmed
escalated --> filed : STR filed with Okokrim
resolved --> [*]
filed --> [*]
note right of escalated
Auto-block user transactions
until STR processed
end note
SAR/STR Filing
When an AML alert is escalated, a Suspicious Transaction Report (STR) is filed with Okokrim/EFE (Norwegian Financial Intelligence Unit) per hvitvaskingsloven section 4-26.
STR Filing Trigger Conditions
| Condition | Action | Timeline |
|---|---|---|
AML alert severity = critical |
Automatic STR draft + compliance notification | Immediately |
AML alert escalated to filed |
STR submitted to Okokrim | Within 24 hours of escalation |
| Compliance officer judgment | Manual STR creation | As determined |
| User account matches sanctions list | Immediate freeze + STR | Immediately |
Database: str_reports table
| Column | Type | Values |
|---|---|---|
report_type |
TEXT | suspicious_transaction, sanctions_match, terrorism_financing |
status |
TEXT | draft, submitted, acknowledged |
filed_at |
TEXT | Timestamp of submission to Okokrim |
reference_number |
TEXT | Okokrim reference (returned on submission) |
STR Content (per hvitvaskingsloven section 4-26)
| Field | Source | Description |
|---|---|---|
| Reporter details | Drop company info | ALAI Holding AS, org number, contact |
| Subject details | users table |
Name, DOB, national_id_hash, address |
| Transaction details | transactions table |
Amount, currency, date, recipient, corridor |
| Suspicious indicators | aml_alerts table |
Alert type, pattern description, severity |
| Supporting evidence | audit_log table |
Login history, transaction history, behavioral anomalies |
| Reporter assessment | Compliance officer | Narrative summary of suspicion basis |
Ongoing Monitoring Schedule
| Activity | Frequency | Scope | Automated |
|---|---|---|---|
| PEP/sanctions re-screening | Quarterly (low risk), Monthly (medium), Weekly (high) | All active users at applicable risk level | Yes (Sumsub batch API) |
| Transaction pattern analysis | Real-time | All transactions | Yes (AML rule engine) |
| Cumulative volume check | Daily | All users with transactions in last 30 days | Yes (scheduled job) |
| High-risk corridor review | Weekly | Users with transfers to FATF grey/black list countries | Yes (automated report) |
| Dormant account review | Monthly | Accounts with no activity for 6+ months then sudden activity | Yes (scheduled job) |
| Full compliance audit | Annually | All users, all transactions, all alerts | Manual + automated |
GDPR Data Minimization for KYC Data
Per GDPR Article 5(1)(c) and Article 25 (data protection by design), KYC data collection and retention must be minimized.
Data Retention Table
| Data Category | Data Elements | Retention Period | Legal Basis | Deletion Method |
|---|---|---|---|---|
| Identity (BankID) | national_id_hash, name, DOB | 5 years after account closure | Hvitvaskingsloven s. 4-18 (AML record keeping) | Hard delete after retention |
| KYC documents | Passport/ID images, selfie | 5 years after account closure | Hvitvaskingsloven s. 4-18 | Sumsub retention + local reference deletion |
| Screening results | PEP/sanctions/media results | 5 years after last screening | Hvitvaskingsloven s. 4-18 | Hard delete after retention |
| AML alerts | Alert details, investigation notes | 5 years after alert closure | Hvitvaskingsloven s. 4-18 | Hard delete after retention |
| STR reports | Filed reports, evidence packages | 10 years after filing | Hvitvaskingsloven s. 4-19 (STR records) | Hard delete after retention |
| Transaction records | Amount, currency, parties, timestamps | 5 years after transaction | Bokforingsloven (accounting law) | Hard delete after retention |
| Consent records | Consent type, granted/withdrawn timestamps | Duration of relationship + 3 years | GDPR Art. 7(1) (proof of consent) | Hard delete after retention |
| Audit logs | User actions, IP addresses, user agents | 2 years | Legitimate interest (security) | Hard delete after retention |
Data Minimization Controls
| Control | Implementation | GDPR Article |
|---|---|---|
| Collect only necessary data | BankID provides verified name + DOB; no separate address collection until needed | Art. 5(1)(c) |
| Purpose limitation | KYC data used only for AML compliance, not marketing | Art. 5(1)(b) |
| Storage limitation | Automated retention policies with scheduled deletion jobs | Art. 5(1)(e) |
| Pseudonymization | National ID stored as SHA-256 hash, not plaintext | Art. 25 |
| Access control | KYC data accessible only to compliance role | Art. 25 |
| Right to erasure | Soft delete (set deleted_at) but retain AML-required data for legal period |
Art. 17(3)(b) |
| Data portability | GET /api/user/data-export exports all personal data as JSON |
Art. 20 |
Conflict: GDPR Erasure vs AML Retention
When a user requests account deletion (DELETE /api/user/account):
- User record is soft-deleted (
deleted_attimestamp set) - Active sessions are revoked
- A
data_access_requestrecord is created (type:erasure, status:completed) - AML-required data is RETAINED for 5 years per hvitvaskingsloven s. 4-18
- Response includes:
"retentionNote": "Data retained for 5 years per AML requirements"
This is legally permitted under GDPR Article 17(3)(b): "compliance with a legal obligation which requires processing by Union or Member State law."
Cross-References
- Registration & Onboarding Flow -- User registration with KYC trigger
- System Context (C4 Level 1) -- Sumsub and Okokrim external actors
- Sumsub KYC Integration -- Technical integration specification
- Database Schema -- Compliance tables (aml_alerts, str_reports, screening_results, consents)
- Compliance Status -- Current compliance readiness
- ADR-003: PSD2 Pass-through -- Regulatory context
- Data Lifecycle -- Full data retention and deletion policies
Bank Account Linking Flow
Low-Level Design: Bank Account Linking Flow
Version: 1.0 Date: 2026-02-21 Author: Banking Architecture Team Status: Approved Applies to: Drop — AISP Consent & Bank Account Linking
1. Overview
Bank account linking is the process where a Drop user connects their bank account via Open Banking (PSD2 AISP). This enables Drop to:
- Read the user's bank account balance (displayed on Dashboard)
- Verify sufficient funds before initiating PISP payments
- Display linked accounts in the Bank Accounts screen (
/accounts)
The linking flow requires AISP consent from the user's bank (ASPSP), authenticated via BankID SCA at the bank. Drop stores the consent reference and caches the balance in the bank_accounts table.
Key principle: Drop never holds money. The bank_accounts.balance column is a cached read from the user's real bank account via AISP.
2. Complete Bank Linking Flow
sequenceDiagram
participant U as User
participant UI as Drop UI<br/>(/accounts)
participant API as Drop API
participant DB as Drop DB
participant ASPSP as User's Bank<br/>(e.g., DNB)
Note over U,ASPSP: Step 1: Bank Selection
U->>UI: Tap "Koble til bank" (Link bank)
UI->>UI: Show bank selection list<br/>(DNB, SpareBank 1, Nordea, ...)
U->>UI: Select "DNB"
Note over U,ASPSP: Step 2: AISP Consent Request
UI->>API: POST /api/accounts/link<br/>{bankId: "dnb"}
API->>API: Verify user authenticated<br/>(JWT from drop_token cookie)
API->>ASPSP: POST /v1/consents<br/>{access: {balances: ["allAccounts"],<br/>transactions: ["allAccounts"]},<br/>recurringIndicator: true,<br/>validUntil: "2026-05-22",<br/>frequencyPerDay: 4,<br/>combinedServiceIndicator: false}
ASPSP-->>API: 201 Created<br/>{consentId: "cons_abc123",<br/>consentStatus: "received",<br/>_links: {scaRedirect:<br/>"https://dnb.no/psd2/consent/authorize?id=..."}}
API->>DB: INSERT INTO consents<br/>(consent_type: 'psd2_aisp',<br/>granted: 0, aspsp_consent_id: cons_abc123)
API-->>UI: {redirectUrl: "https://dnb.no/psd2/consent/authorize?id=..."}
Note over U,ASPSP: Step 3: SCA at Bank
UI->>U: Redirect to bank consent page
U->>ASPSP: View consent details<br/>"Drop requests access to your<br/>account balances and transactions"
U->>ASPSP: Authenticate with BankID<br/>(possession + knowledge/inherence)
ASPSP-->>U: Consent granted<br/>Redirect to Drop callback
Note over U,ASPSP: Step 4: Callback & Account Retrieval
U->>API: GET /api/accounts/link/callback<br/>?consentId=cons_abc123&state=xyz
API->>API: Verify state parameter
API->>ASPSP: GET /v1/consents/cons_abc123/status
ASPSP-->>API: {consentStatus: "valid"}
API->>DB: UPDATE consents SET granted = 1,<br/>granted_at = now
API->>ASPSP: GET /v1/accounts<br/>(with consentId header)
ASPSP-->>API: {accounts: [<br/>{resourceId: "acc_1", iban: "NO1234567890123",<br/>currency: "NOK", name: "Brukskonto"},<br/>{resourceId: "acc_2", iban: "NO9876543210987",<br/>currency: "NOK", name: "Sparekonto"}]}
Note over U,ASPSP: Step 5: Balance Fetch & Storage
loop For each account
API->>ASPSP: GET /v1/accounts/{resourceId}/balances
ASPSP-->>API: {balances: [{balanceType: "expected",<br/>balanceAmount: {currency: "NOK",<br/>amount: "45230.00"}}]}
API->>DB: INSERT INTO bank_accounts<br/>(bank_name: "DNB",<br/>account_number: "NO12...0123",<br/>iban: "NO1234567890123",<br/>balance: 4523000,<br/>balance_synced_at: now,<br/>is_primary: first ? 1 : 0)
end
API-->>UI: {success: true,<br/>accounts: [{bankName: "DNB",<br/>balance: 45230.00, currency: "NOK"}]}
UI-->>U: "DNB koblet til!" (DNB linked!)<br/>Show accounts with balances
3. Linked Account States
stateDiagram-v2
[*] --> Unlinked: User has no linked bank accounts
Unlinked --> ConsentRequested: User taps "Koble til bank"<br/>POST /v1/consents to ASPSP
ConsentRequested --> ScaPending: ASPSP returns scaRedirect
ScaPending --> Active: User completes BankID SCA<br/>consentStatus = "valid"
ScaPending --> Failed: SCA timeout (5 min)
ScaPending --> Failed: User cancels BankID
ScaPending --> Failed: Bank rejects consent
Active --> Active: Balance refresh<br/>(on-demand or scheduled,<br/>max 4x/day TPP-initiated)
Active --> SyncError: ASPSP returns error on balance read<br/>Show last cached balance
SyncError --> Active: Next successful balance read
Active --> ConsentExpiring: 30 days before consent expiry<br/>Notify user to renew
ConsentExpiring --> RenewalPending: User taps "Forny tilgang"<br/>(Renew access)
RenewalPending --> ScaPending: New consent + SCA
ConsentExpiring --> Expired: User ignores renewal
Active --> Unlinked: User taps "Fjern konto"<br/>(Remove account)<br/>DELETE /v1/consents/{id}
Active --> Suspended: ASPSP revokes consent
Expired --> Unlinked: Consent expired,<br/>balance zeroed,<br/>notify user
Suspended --> Unlinked: User must re-link
Failed --> Unlinked: User can retry
Unlinked --> [*]
4. Detailed Steps
4.1 Step 1: Bank Selection
UI: Bank Accounts screen (/accounts) shows a "Link bank account" button. Tapping it displays a list of supported Norwegian banks.
Supported banks (initial):
| Bank | Berlin Group API | Logo |
|---|---|---|
| DNB | https://api.dnb.no/psd2/ |
DNB logo asset |
| SpareBank 1 | https://api.sparebank1.no/open-banking/ |
SB1 logo asset |
| Nordea | https://api.nordeaopenbanking.com/ |
Nordea logo asset |
| Sbanken | Via SpareBank 1 API | Sbanken logo asset |
4.2 Step 2: AISP Consent Creation
Berlin Group consent request:
{
"access": {
"balances": [{"iban": "allAccounts"}],
"transactions": [{"iban": "allAccounts"}]
},
"recurringIndicator": true,
"validUntil": "2026-05-22",
"frequencyPerDay": 4,
"combinedServiceIndicator": false
}
| Field | Value | Reason |
|---|---|---|
balances |
allAccounts |
User picks which accounts to display after consent |
transactions |
allAccounts |
Future: transaction history from bank |
recurringIndicator |
true |
Ongoing access (not one-time) |
validUntil |
Now + 90 days | PSD2 maximum consent duration (RTS Art. 10) |
frequencyPerDay |
4 |
Max TPP-initiated reads per day (PSD2 RTS Art. 36(6)) |
combinedServiceIndicator |
false |
AISP consent only (PISP is separate per transaction) |
4.3 Step 3: SCA at Bank
The user is redirected to their bank's consent page where they:
- See what data Drop is requesting (balances, transactions)
- Authenticate with BankID (SCA: 2 of 3 factors)
- Approve or deny the consent
- Get redirected back to Drop
4.4 Step 4: Account Retrieval
After consent is confirmed, Drop calls the ASPSP's account list endpoint to discover which accounts the user has. Then it fetches the balance for each account.
4.5 Step 5: Data Storage
Each linked account is stored in the bank_accounts table:
| Column | Value | Source |
|---|---|---|
id |
ba_<hex16> |
Generated by randomId("ba") |
user_id |
Current user ID | From JWT |
bank_name |
"DNB" | From bank selection |
account_number |
Domestic format (masked in API) | From ASPSP account details |
iban |
Full IBAN | From ASPSP |
balance |
Balance in oere (e.g., 4523000 = 45,230 NOK) | From ASPSP balance endpoint |
balance_synced_at |
Current timestamp | Set on each refresh |
currency |
"NOK" | From ASPSP |
is_primary |
1 for first account, 0 for others | Auto-set |
connected_at |
Current timestamp | Set on linking |
5. Balance Refresh Strategy
| Trigger | Frequency | ASPSP Call | UI Behavior |
|---|---|---|---|
| User opens Dashboard | On demand | GET /v1/accounts/{id}/balances |
Show spinner, then updated balance |
| User pulls to refresh | On demand | GET /v1/accounts/{id}/balances |
Pull-to-refresh animation |
| Background sync | Every 6 hours | GET /v1/accounts/{id}/balances |
Silent update |
| Pre-payment check | Before PISP | GET /v1/accounts/{id}/balances |
Inline balance verification |
| User views /accounts | On demand | GET /v1/accounts/{id}/balances |
Show spinner per account |
PSD2 constraint: TPP-initiated requests (background sync) are limited to 4 per day per account (RTS Art. 36(6)). User-initiated requests (opening app, pull-to-refresh) are unlimited.
6. Error Handling
6.1 Error Table
| Error | Stage | HTTP Status | User Message (Norwegian) | Recovery |
|---|---|---|---|---|
| Bank not supported | Bank selection | 400 | "Denne banken stottes ikke ennaa." | Show supported banks |
| ASPSP API unreachable | Consent creation | 502 | "Kunne ikke koble til banken. Prv igjen senere." | Retry after 30s |
| SCA timeout | SCA at bank | 408 | "BankID-sesjonen utlp. Prv igjen." | Restart linking flow |
| SCA cancelled | SCA at bank | 400 | "Du avbrt tilkoblingen." | Offer retry button |
| SCA rejected | SCA at bank | 403 | "Banken avviste tilgangen." | Contact bank support |
| Consent invalid | Callback | 403 | "Tilgangsforesprselen var ugyldig." | Restart flow |
| State mismatch | Callback | 403 | "Sikkerhetssjekk feilet. Prv igjen." | Restart flow |
| No accounts found | Account retrieval | 404 | "Fant ingen kontoer hos denne banken." | Verify correct bank selected |
| Balance read failed | Balance fetch | 502 | "Kunne ikke hente saldo. Viser sist kjente." | Show cached balance with timestamp |
| Consent expired | Any balance read | 403 | "Tilgangen til banken din er utlpt. Koble til paa nytt." | Re-link flow |
| Rate limit exceeded | Balance read | 429 | "For mange foresprsler. Viser sist kjente saldo." | Show cached balance |
6.2 Fallback Behavior
When a balance read fails, Drop shows the last cached balance with the balance_synced_at timestamp:
DNB Brukskonto
45 230,00 kr
Sist oppdatert: 2 timer siden
If the consent is expired or revoked, the balance is zeroed and the user sees:
DNB Brukskonto
-- kr
Tilgangen er utlopt. Koble til paa nytt.
[Koble til] button
7. Consent Renewal
AISP consents have a maximum validity of 90 days (PSD2 RTS Art. 10). Drop proactively prompts renewal:
| Timeline | Action |
|---|---|
| Day 0 | Consent created, validUntil = Day 90 |
| Day 60 | Push notification: "Din banktilgang utlper snart. Forny tilgangen." |
| Day 80 | In-app banner on Dashboard: "Tilgangen til DNB utlper om 10 dager." |
| Day 85 | Push notification: "Tilgangen utlper om 5 dager. Forny naa." |
| Day 90 | Consent expires. Balance zeroed. User must re-link. |
Renewal flow: Same as initial linking — new consent + BankID SCA at bank. The old consent is deleted after the new one is active.
8. Account Unlinking
When the user taps "Fjern konto" (Remove account):
DELETE /v1/consents/{consentId}at the ASPSP (revoke consent)DELETE FROM bank_accounts WHERE id = ?in Drop DBUPDATE consents SET withdrawn_at = now WHERE aspsp_consent_id = ?- If the removed account was primary, promote another linked account to primary
- If no accounts remain, Dashboard shows "Koble til bank" prompt
9. Multi-Bank Support
Users can link accounts from multiple banks. Each linked account has its own AISP consent with its own lifecycle.
Dashboard display:
Dine bankkontoer (Your bank accounts)
DNB Brukskonto 45 230,00 kr (primary)
DNB Sparekonto 12 800,00 kr
Nordea Brukskonto 8 450,00 kr
Totalt 66 480,00 kr
[Koble til ny bank]
Drop API: GET /api/auth/me returns totalBalance (sum of all linked account balances) and bankAccounts[] array.
10. Database Impact
10.1 Tables Affected
| Table | Operation | When |
|---|---|---|
bank_accounts |
INSERT | Account linked |
bank_accounts |
UPDATE (balance, balance_synced_at) | Balance refresh |
bank_accounts |
DELETE | Account unlinked |
consents |
INSERT (consent_type: 'psd2_aisp') | Consent created |
consents |
UPDATE (granted, granted_at) | SCA completed |
consents |
UPDATE (withdrawn_at) | Consent revoked |
audit_log |
INSERT | Every linking/unlinking action |
notifications |
INSERT | Consent expiry reminders |
10.2 Index Usage
| Index | Used By |
|---|---|
idx_bank_accounts_user on user_id |
All account queries (scoped to user) |
idx_consents_user on user_id |
Consent lookups for renewal checks |
11. Cross-References
- Open Banking AISP/PISP: ../integration/open-banking-aisp-pisp.md — Berlin Group API details, consent properties
- BankID OIDC: ../integration/bankid-oidc-integration.md — Drop authentication (separate from bank SCA)
- Security Architecture: ../hld/security-architecture.md — Trust boundaries, data classification
- Remittance Flow: flow-remittance.md — Uses linked bank account for PISP
- Database Schema: ../../backend/DATABASE-SCHEMA.md —
bank_accounts,consentstables - API Reference: ../../backend/API-REFERENCE.md —
GET /api/auth/mereturns bank accounts
QR Payment Flow
Flow: QR Payment
Document: LLD-002 Version: 1.0 Date: 2026-02-21 Author: Frontend Architect (AI Agent) Status: Draft Scope: End-to-end QR payment flow covering camera handling, merchant resolution, payment confirmation, SCA, and error states for both web and mobile
1. Overview
QR payments allow Drop users to pay in-store by scanning a merchant's QR code. The payment is initiated via PISP (Payment Initiation Service Provider) directly from the user's bank account under the PSD2 pass-through model. Drop never holds customer funds.
QR Code Format: drop://pay/{merchantId}
Payment Fee: 1% of transaction amount (fee is deducted from user's balance: totalOre = amountOre + feeOre. Settlement to merchant is separate.)
Note: Database stores amounts in ore (minor units). API accepts NOK and converts internally using
nokToOre(). Example: 129 NOK = 12900 ore in DB.
Flow summary: Camera permission → QR scan → decode merchant ID → fetch merchant details → amount entry → SCA trigger → payment confirmation → receipt display
2. QR Payment Sequence Diagram
sequenceDiagram
actor User
participant App as Drop App<br/>(Web/Mobile)
participant Camera as Camera API<br/>(Browser/Native)
participant API as Drop API<br/>(/api/transactions)
participant Bank as User's Bank<br/>(via PISP)
participant DB as Database
User->>App: Navigate to /scan
App->>App: Check auth (useAuth / Bearer token)
alt Camera available
App->>Camera: Request camera permission
Camera-->>App: Permission granted
App->>App: Show camera viewfinder<br/>with scan frame brackets
else Camera denied / unavailable
App->>App: Show "Simuler skanning" button<br/>(demo mode fallback)
end
User->>Camera: Point at merchant QR code
Camera->>App: Decode QR: "drop://pay/{merchantId}"
App->>App: Parse merchantId from QR URI
App->>API: GET /api/merchants/{merchantId}
API->>DB: SELECT merchant WHERE id = ?
API-->>App: { merchantId, businessName, category }
App->>App: Show merchant info + amount input
User->>App: Enter amount (e.g., 129 NOK)
App->>App: Calculate fee (1% = 1.29 NOK)
App->>App: Show payment summary<br/>(amount, fee, total, source account)
User->>App: Tap "Betal nå" (confirm)
App->>API: POST /api/transactions/qr-payment<br/>{ merchantId, amount }
API->>API: Rate limit check (10/min)
API->>DB: Verify merchant exists
API->>DB: Get user's primary bank account
API->>API: Calculate fee (1% of amount)
Note over API,Bank: Production: PISP initiates<br/>payment from user's bank.<br/>Demo: Direct DB debit.
API->>DB: BEGIN TRANSACTION
API->>DB: UPDATE bank_accounts SET balance = balance - (amount + fee)
API->>DB: INSERT transaction (type=qr_payment, status=completed)
API->>DB: COMMIT
API-->>App: 201 { transaction }
App->>App: Show success screen<br/>(checkmark, merchant, amount, fee)
User->>App: Tap "Tilbake til hjem"
App->>App: Navigate to /dashboard
3. Payment Flow State Diagram
stateDiagram-v2
[*] --> Idle: Navigate to /scan
Idle --> RequestingPermission: Camera API available
Idle --> SimulationMode: No camera / demo mode
RequestingPermission --> Scanning: Permission granted
RequestingPermission --> PermissionDenied: Permission denied
PermissionDenied --> Scanning: User grants in settings
PermissionDenied --> SimulationMode: Use simulation
SimulationMode --> MerchantResolved: Click "Simuler skanning"
Scanning --> Decoding: QR code detected
Decoding --> MerchantResolved: Valid drop:// URI
Decoding --> InvalidQR: Not a Drop QR code
InvalidQR --> Scanning: Dismiss error, retry scan
MerchantResolved --> AmountEntry: Merchant details loaded
MerchantResolved --> MerchantNotFound: Merchant lookup failed
MerchantNotFound --> Scanning: Go back, scan again
AmountEntry --> PaymentReview: Amount entered + confirmed
AmountEntry --> Scanning: Cancel / go back
PaymentReview --> Processing: Tap "Betal nå"
PaymentReview --> AmountEntry: Edit amount
Processing --> Success: Payment completed (201)
Processing --> InsufficientFunds: Balance too low
Processing --> PaymentFailed: API error
InsufficientFunds --> AmountEntry: Adjust amount
PaymentFailed --> PaymentReview: Retry
Success --> [*]: Navigate to dashboard
4. Camera Permission Handling
4.1 Camera Permission Table (iOS / Android)
| Platform | Permission API | First Request | After Denial | Settings Redirect |
|---|---|---|---|---|
| iOS (Safari) | navigator.mediaDevices.getUserMedia() |
System prompt: "getdrop.no would like to access the camera" | Blocked silently; must reset in Safari Settings → getdrop.no → Camera | Link to Settings not programmatically available |
| iOS (Expo) | expo-camera Camera.requestCameraPermissionsAsync() |
System prompt: "Drop would like to access the camera" | Returns { status: 'denied' }; use Linking.openSettings() |
Linking.openSettings() → iOS Settings → Drop → Camera |
| Android (Chrome) | navigator.mediaDevices.getUserMedia() |
System prompt: "Allow getdrop.no to use your camera?" | Blocked; user must tap lock icon → Site settings → Camera → Allow | Site settings accessible via address bar |
| Android (Expo) | expo-camera Camera.requestCameraPermissionsAsync() |
System prompt: "Allow Drop to take pictures and record video?" | Returns { status: 'denied' }; { canAskAgain: false } after permanent deny |
Linking.openSettings() → App Info → Permissions → Camera |
4.2 Fallback Behavior
- Web: Shows "Simuler skanning" button that triggers a demo merchant scan (Ahmetov Kebab, merchant_001)
- Mobile: Shows camera placeholder (gray box with QR icon) + "Simuler skanning" button + nearby merchants list (hardcoded: Ahmetov Kebab, Kafe Oslo, Narvesen)
5. QR Code Format and Validation
| Field | Value | Validation |
|---|---|---|
| URI scheme | drop:// |
Must match exactly |
| Path | pay/{merchantId} |
Must start with pay/ |
| Merchant ID format | mer_ prefix + flexible identifier |
No strict regex enforced (e.g., mer_demo1 is valid) |
| Example | drop://pay/mer_demo1 |
Validated by DB lookup |
HMAC verification: QR codes may optionally include timestamp and signature parameters for HMAC-SHA256 verification using the merchant's qr_hmac_key. Verification is performed only when both qrTimestamp and qrSignature are present in the request. If omitted, the payment proceeds without cryptographic QR verification.
Invalid QR handling:
- Non-Drop QR codes: Show "Ugyldig QR-kode. Vennligst skann en Drop-butikks QR-kode."
- Malformed merchant ID: Show "Ugyldig betalingskode."
- Empty scan result: Continue scanning (do not trigger error)
6. Error States
| Error | HTTP Status | Cause | User-Facing Message (Norwegian) | Recovery |
|---|---|---|---|---|
| Invalid QR | N/A (client) | Not a drop://pay/ URI |
"Ugyldig QR-kode. Skann en Drop-butikks QR-kode." | Retry scan |
| Merchant Not Found | 404 | Merchant ID not in database | "Butikken ble ikke funnet. QR-koden kan være utdatert." | Scan different QR |
| Insufficient Funds | 402 | Bank balance < amount + fee | "Ikke nok penger på kontoen. Saldo: {balance} NOK." | Reduce amount or top up bank |
| No Bank Account | 400 | No linked bank account | "Ingen bankkonto koblet. Koble en konto først." | Navigate to /accounts |
| Rate Limited | 429 | >10 payments/min | "For mange betalinger. Vent litt." | Wait and retry |
| Network Error | N/A | No connectivity | "Ingen nettverkstilkobling." | Retry when online |
| Server Error | 500 | Internal error | "Noe gikk galt. Prøv igjen." | Retry |
| Camera Error | N/A | Camera hardware failure | "Kameraet fungerer ikke. Bruk 'Simuler skanning'." | Use simulation mode |
7. UI Components
7.1 Web — Scan Page (/scan)
| State | UI Elements | Components Used |
|---|---|---|
| Scanning | Dark background (#0F172A), camera viewfinder with gold corner brackets (#D4A017), instruction text, "Simuler skanning" button, BankID/Vipps badges | BottomNav, Button, ArrowLeft/Camera (lucide) |
| Payment | White background, merchant icon (gold gradient circle), merchant name (Fraunces font), amount display (4xl), source account info, "Betal nå" button (green), "Avbryt" button | BottomNav, Button, ChevronLeft (lucide) |
| Paying | Loading spinner overlay | Spinner component |
| Success | Checkmark icon (green), transaction details, merchant name, amount, fee | BottomNav, Check/Store (lucide) |
7.2 Mobile — Scan Screen ((tabs)/scan.js)
| State | UI Elements |
|---|---|
| Scanning | Gray camera placeholder, QR icon, "Skann QR-kode" text, "Simuler skanning" button, nearby merchants list |
| Payment | Merchant info, amount input, confirm button |
| Success | Confirmation with "Tilbake til hjem" button |
7.3 Figma Reference
Source of truth: mockups/figma-make-export/src/app/screens/ScanQR.tsx
- Dark scanning mode with gold bracket viewfinder
- Payment confirmation with merchant details and amount
- Green "Betal nå" CTA button
8. Data Flow
8.1 Request: POST /api/transactions/qr-payment
{
"merchantId": "mer_a1b2c3d4e5f6g7h8",
"amount": 129
}
8.2 Response: 201 Created
{
"data": {
"id": "tx_qr_a1b2c3d4e5f6g7h8",
"type": "qr_payment",
"status": "completed",
"amount": 129,
"currency": "NOK",
"fee": 1.29,
"feePercent": 1,
"merchantName": "Ahmetov Kebab",
"merchantId": "mer_1",
"fromAccount": "DNB",
"createdAt": "2026-02-21T14:30:00.000Z"
}
}
8.3 Database Operations (Atomic Transaction)
BEGIN;
UPDATE bank_accounts SET balance = balance - 130.29 WHERE id = ? AND user_id = ?;
INSERT INTO transactions (id, user_id, type, status, amount, currency, fee, merchant_id, created_at, completed_at)
VALUES (?, ?, 'qr_payment', 'completed', 129, 'NOK', 1.29, ?, datetime('now'), datetime('now'));
COMMIT;
9. Production vs Demo Differences
| Aspect | Demo (Current) | Production (Phase 2+) |
|---|---|---|
| Camera | Simulated scan button | Real camera scanning via expo-camera or getUserMedia() |
| Payment execution | Direct DB balance debit | PISP initiation via Open Banking API |
| SCA | Not implemented | BankID SCA required for each payment |
| Merchant verification | Static seed data (Ahmetov Kebab) | Live Brønnøysund org number verification |
| Fee handling | Fee deducted from user's balance (totalOre = amountOre + feeOre) |
Merchant settlement is separate from user debit |
| Settlement | Instant (DB update) | T+1 or T+2 settlement to merchant bank account |
10. Accessibility Considerations (WCAG 2.1 AA)
| Requirement | Implementation |
|---|---|
| Camera alternative | "Simuler skanning" button provides non-camera path |
| Amount input | Labeled with "Beløp" and suffixed with "NOK" |
| Confirmation | "Betal nå" button clearly labeled; "Avbryt" provides escape |
| Success feedback | Visual checkmark + text confirmation of payment |
| Color contrast | Gold (#D4A017) on dark (#0F172A) = 5.2:1 ratio (passes AA) |
| Screen reader | Merchant name and amount announced on payment confirmation |
11. Cross-References
- QR payment API:
POST /api/transactions/qr-payment— See API Reference - Merchant registration:
POST /api/merchants/register— See API Reference - Transaction schema:
transactionstable — See Database Schema - Merchant schema:
merchantstable — See Database Schema - Component overview: See component-overview.md
- Figma scan screen:
mockups/figma-make-export/src/app/screens/ScanQR.tsx - Web scan page:
src/drop-app/src/app/scan/page.tsx— See PAGES.md - Mobile scan screen:
src/drop-mobile/app/(tabs)/scan.js— See MOBILE-APP.md - Merchant onboarding flow: See flow-merchant-onboarding.md
- PSD2 PISP details: See open-banking-aisp-pisp.md
Remittance Flow
Low-Level Design: Remittance Flow
Version: 1.0 Date: 2026-02-21 Author: Banking Architecture Team Status: Approved Applies to: Drop — Cross-Border Money Transfer (PISP)
1. Overview
Remittance is Drop's core feature — sending money from a Norwegian bank account to a recipient abroad. The flow has 4 user-facing steps:
- Select recipient (or add new)
- Enter amount (see FX rate + fee in real-time)
- Review (PSD2 Art. 45 pre-payment disclosure)
- Confirm (SCA via BankID at user's bank)
Drop uses PISP (Payment Initiation Service) to initiate the transfer directly from the user's bank account. Drop never touches the money.
API endpoint: POST /api/transactions/remittance
Fee: 0.5% of send amount
Amount range: 100 - 50,000 NOK
KYC required: Yes (kyc_status = 'approved')
Supported corridors: Serbia (RSD), Bosnia (BAM), Poland (PLN), Pakistan (PKR), Turkey (TRY), EU (EUR)
2. End-to-End Remittance Flow
sequenceDiagram
participant U as User
participant UI as Drop App<br/>(/send)
participant API as Drop API
participant DB as Drop DB
participant ASPSP as User's Bank
participant RB as Recipient Bank
Note over U,RB: Step 1 — Select Recipient
U->>UI: Navigate to Send Money
UI->>API: GET /api/recipients?page=1&limit=20
API->>DB: SELECT * FROM recipients<br/>WHERE user_id = ? ORDER BY created_at DESC
DB-->>API: [{id: "rec_1", name: "Marko Petrovic",<br/>country: "Serbia", currency: "RSD"}]
API-->>UI: Recipient list (bank accounts masked)
U->>UI: Select "Marko Petrovic"
Note over U,RB: Step 2 — Enter Amount
UI->>API: GET /api/rates/RSD
API->>DB: SELECT rate FROM exchange_rates<br/>WHERE to_currency = 'RSD'
DB-->>API: {rate: 10.17}
API-->>UI: {rate: 10.17, fee: 0.005}
U->>UI: Enter 2000 NOK
UI->>UI: Live calculation:<br/>Send: 2,000 NOK<br/>Fee: 10 NOK (0.5%)<br/>Rate: 1 NOK = 10.17 RSD<br/>Receives: 20,340 RSD
Note over U,RB: Step 3 — Review (PSD2 Art. 45 Disclosure)
U->>UI: Tap "Neste" (Next)
UI->>API: POST /api/transactions/disclosure<br/>{type: "remittance", amount: 2000,<br/>recipientId: "rec_1"}
API->>DB: Lookup recipient currency, exchange rate
API->>API: Calculate fee (2000 * 0.005 = 10)<br/>Calculate receive (2000 * 10.17 = 20340)<br/>Determine delivery (non-EEA: 2-4 days)
API-->>UI: {amount: 2000, fee: 10, feePercentage: 0.5,<br/>exchangeRate: 10.17, receiveAmount: 20340,<br/>receiveCurrency: "RSD",<br/>estimatedDelivery: "2-4 business days",<br/>totalCost: 2010}
UI->>UI: Display disclosure screen:<br/>"Du sender 2 000 kr til Marko Petrovic<br/>Gebyr: 10 kr (0,5%)<br/>Vekslingskurs: 1 NOK = 10,17 RSD<br/>Marko mottar: 20 340 RSD<br/>Total kostnad: 2 010 kr<br/>Estimert levering: 2-4 virkedager"
Note over U,RB: Step 4 — Confirm & Payment Initiation
U->>UI: Tap "Bekreft og send" (Confirm and send)
UI->>API: POST /api/transactions/remittance<br/>{recipientId: "rec_1", amount: 2000,<br/>bankAccountId: "ba_1"}
API->>DB: Verify KYC: kyc_status = 'approved'
API->>DB: Verify recipient belongs to user
API->>DB: Verify bank account exists + balance >= 2010
API->>DB: Lookup exchange rate for RSD
API->>DB: Generate idempotency_key<br/>BEGIN TRANSACTION<br/>UPDATE bank_accounts SET balance = balance - 201000<br/>INSERT INTO transactions (status: 'processing')
API->>DB: COMMIT
API->>ASPSP: POST /v1/payments/cross-border-credit-transfers<br/>{debtorAccount: {iban: user_iban},<br/>instructedAmount: {currency: "NOK", amount: "2010.00"},<br/>creditorName: "Marko Petrovic",<br/>creditorAccount: {bban: "265-1234567-89"},<br/>remittanceInformationUnstructured: "Drop remittance tx_rem_xxx"}
ASPSP-->>API: {paymentId: "pay_xyz",<br/>transactionStatus: "RCVD",<br/>scaRedirect: "https://dnb.no/sca/pay/..."}
API-->>UI: {transactionId: "tx_rem_xxx",<br/>scaRedirect: "https://dnb.no/sca/pay/..."}
Note over U,RB: SCA at Bank (Dynamic Linking)
UI->>U: Redirect to bank SCA page
U->>ASPSP: BankID authentication<br/>(sees: "2 010 NOK to Marko Petrovic")
ASPSP-->>U: Redirect to Drop callback
U->>API: GET /api/payments/callback?paymentId=pay_xyz
API->>ASPSP: GET /v1/payments/pay_xyz/status
ASPSP-->>API: {transactionStatus: "ACCP"}
API->>DB: UPDATE transactions<br/>SET status = 'completed',<br/>completed_at = now<br/>WHERE id = 'tx_rem_xxx'
API->>DB: INSERT INTO audit_log<br/>(action: 'payment.completed')
API->>DB: INSERT INTO notifications<br/>(title: 'Overfoering sendt',<br/>body: '2 000 kr sendt til Marko Petrovic')
API-->>UI: {status: "completed"}
UI-->>U: Success screen:<br/>"2 000 kr sendt til Marko Petrovic!<br/>Estimert levering: 2-4 virkedager"
Note over ASPSP,RB: Settlement (Drop is not involved)
ASPSP->>RB: SWIFT gpi / correspondent banking<br/>NOK converted to RSD
RB->>RB: Credit Marko's account: 20,340 RSD
3. Transaction States
stateDiagram-v2
[*] --> Draft: User on review screen<br/>(disclosure shown, not yet confirmed)
Draft --> Initiated: User taps "Bekreft og send"<br/>Transaction record created<br/>(status: processing)
Draft --> Abandoned: User navigates away<br/>(no record created)
Initiated --> ScaPending: ASPSP returns scaRedirect<br/>User redirected to bank
Initiated --> Failed: ASPSP rejects initiation<br/>(invalid IBAN, bank error)
ScaPending --> Completed: User completes BankID SCA<br/>ASPSP status: ACCP/ACSC
ScaPending --> Failed: SCA timeout (5 min)
ScaPending --> Failed: User cancels SCA
ScaPending --> Failed: Bank rejects payment<br/>(insufficient funds at bank)
Completed --> Settled: Funds credited to recipient<br/>(tracked via ASPSP status polling)
Completed --> RefundPending: Settlement failed<br/>(correspondent bank error)
Failed --> [*]: User sees error,<br/>can retry from Step 1
RefundPending --> Refunded: Refund processed<br/>Balance restored
Refunded --> [*]
Settled --> [*]
Abandoned --> [*]
Database status values (transactions.status CHECK constraint):
processing— Transaction created, awaiting SCA or settlementcompleted— ASPSP accepted payment, settlement in progress or donefailed— Payment rejected, SCA failed, or settlement error
4. Pre-Payment Disclosure (PSD2 Art. 45)
Before the user confirms a remittance, Drop must show a complete cost breakdown. This is a legal requirement under PSD2 Art. 45 (Betalingstjenesteloven in Norwegian law).
4.1 Disclosure Checklist
| Item | PSD2 Reference | Drop Field | Example |
|---|---|---|---|
| Amount to be transferred | Art. 45(1)(a) | amount |
2,000 NOK |
| All fees/charges | Art. 45(1)(b) | fee, feePercentage |
10 NOK (0.5%) |
| Exchange rate used | Art. 45(1)(c) | exchangeRate |
1 NOK = 10.17 RSD |
| Amount after conversion | Art. 45(1)(d) | receiveAmount, receiveCurrency |
20,340 RSD |
| Total cost to payer | Art. 45(1)(e) | totalCost |
2,010 NOK |
| Estimated delivery time | Art. 45(1)(f) | estimatedDelivery |
2-4 business days |
| Currency of debit | Implicit | Send currency | NOK |
| Currency of credit | Implicit | Receive currency | RSD |
4.2 Disclosure Screen Content (Norwegian)
Bekreft overfoering
Til: Marko Petrovic
Land: Serbia
Bankkonto: *****567-89 (Banca Intesa)
Du sender: 2 000,00 kr
Gebyr (0,5%): 10,00 kr
Totalt belop: 2 010,00 kr
Vekslingskurs: 1 NOK = 10,17 RSD
Marko mottar: 20 340,00 RSD
Estimert levering: 2-4 virkedager
Pengene trekkes fra: DNB Brukskonto
[Bekreft og send] [Avbryt]
5. FX Rate Lock & Expiry
5.1 Rate Lock Window
| Event | Time | Action |
|---|---|---|
| User views disclosure | T+0 | Rate displayed from exchange_rates table |
| User confirms payment | T+0 to T+15min | Rate locked in transactions.exchange_rate |
| SCA completes | T+0 to T+15min | Locked rate applies to settlement |
| SCA not completed | T+15min | Rate expires, transaction fails, user must re-quote |
5.2 Rate Lock Implementation
- When
POST /api/transactions/remittanceis called, the current rate is read fromexchange_rates - The rate is stored in
transactions.exchange_rateat insert time - If the SCA takes longer than 15 minutes, the reconciliation job detects the stale transaction and marks it
failed - User is notified to retry (with a new, current rate)
6. Validation Rules
6.1 Pre-Flight Checks (Before Transaction Creation)
| Check | Source | Error if Failed |
|---|---|---|
| User authenticated | JWT from cookie/header | 401 unauthorized |
| KYC approved | users.kyc_status = 'approved' |
403 kyc_required |
| Recipient exists | recipients.id WHERE user_id = ? |
404 not_found |
| Recipient belongs to user | recipients.user_id = jwt.userId |
404 not_found |
| Bank account exists | bank_accounts.id WHERE user_id = ? |
400 no_bank_account |
| Amount in range | 100 to 50,000 NOK | 422 validation_error |
| Amount valid | Number.isFinite(), max 2 decimals |
422 validation_error |
| Balance sufficient | bank_accounts.balance >= amount + fee |
402 insufficient_balance |
| Currency corridor supported | exchange_rates.to_currency exists |
422 validation_error |
| Not duplicate | idempotency_key unique |
Return existing transaction |
| Rate limit | < 10 requests/min per IP | 429 rate_limited |
6.2 Amount Validation
Minimum: 100 NOK (protect against micro-transaction abuse)
Maximum: 50,000 NOK (regulatory limit for simplified CDD)
Decimals: max 2 (validated by validateAmount())
Type: Number.isFinite() (prevents NaN, Infinity injection)
7. Error Scenarios & User Messages
| Scenario | API Response | User Message (Norwegian) | Next Step |
|---|---|---|---|
| KYC not approved | 403 kyc_required |
"Du maa fullfoere identitetsverifisering for aa sende penger." | Redirect to KYC flow |
| No linked bank account | 400 no_bank_account |
"Du har ingen tilkoblet bankkonto. Koble til en bank foerst." | Redirect to /accounts |
| Insufficient balance | 402 insufficient_balance |
"Ikke nok penger paa kontoen. Saldo: 1 200 kr, totalt belop: 2 010 kr." | Show balance, suggest lower amount |
| Unsupported corridor | 422 validation_error |
"Vi stoetter ikke overfoering til dette landet ennaa." | Show supported countries |
| Amount too low | 422 validation_error |
"Minimumsbelopet er 100 kr." | Adjust amount |
| Amount too high | 422 validation_error |
"Maksimumsbelopet er 50 000 kr." | Adjust amount |
| SCA timeout | (callback timeout) | "BankID-sesjonen utlop. Overforingen ble ikke gjennomfoert." | Retry button |
| SCA cancelled | (callback cancelled) | "Du avbrot betalingen. Ingen penger er trukket." | Retry button |
| Bank rejected | ASPSP RJCT |
"Banken avviste overforingen. Kontakt banken din." | Show bank support info |
| Rate expired | (rate > 15min old) | "Vekslingskursen har utlopt. Vennligst bekreft ny kurs." | Re-show disclosure with new rate |
| Network error | 502/503 | "Teknisk feil. Proev igjen om noen minutter." | Retry after 30s |
| Duplicate detected | 200 (existing tx) | "Denne overforingen er allerede registrert." | Show existing transaction |
8. Post-Transaction
8.1 Confirmation Screen
After successful SCA:
Overfoering sendt!
2 000 kr sendt til Marko Petrovic
Marko mottar 20 340 RSD
Referanse: tx_rem_a1b2c3d4...
Status: Under behandling
Estimert levering: 2-4 virkedager
[Se detaljer] [Send til en annen]
8.2 Transaction Tracking
Users can track their remittance in the Transaction History (/transactions):
| Status | Display | Icon |
|---|---|---|
processing |
"Under behandling" | Spinner |
completed |
"Fullfoert" | Green checkmark |
failed |
"Feilet" | Red X |
8.3 Transaction Summary
GET /api/transactions/summary returns aggregated transaction statistics for the authenticated user (total sent, total fees, transaction count, breakdown by corridor).
8.4 Receipt
GET /api/transactions/{id}/receipt returns a detailed receipt:
{
"transactionId": "tx_rem_xxx",
"date": "2026-02-21T14:30:00Z",
"type": "remittance",
"amount": 2000,
"currency": "NOK",
"fee": 10,
"exchangeRate": 10.17,
"receiveAmount": 20340,
"receiveCurrency": "RSD",
"recipient": {"name": "Marko Petrovic", "country": "RS"},
"reference": "tx_rem_xxx",
"status": "completed",
"completedAt": "2026-02-21T14:35:00Z"
}
8.5 Notifications
On completion/failure, a notification is created:
| Event | Notification Title | Notification Body |
|---|---|---|
| Payment sent | "Overfoering sendt" | "2 000 kr sendt til Marko Petrovic" |
| Payment completed | "Overfoering fullfoert" | "20 340 RSD mottatt av Marko Petrovic" |
| Payment failed | "Overfoering feilet" | "Overfoering til Marko Petrovic ble avvist. Kontakt oss for hjelp." |
9. Refund Handling
If a remittance fails after funds were debited (e.g., correspondent bank rejects, recipient IBAN invalid):
| Step | Action | Timeline |
|---|---|---|
| 1 | ASPSP reports RJCT or CANC status |
1-5 business days |
| 2 | Drop detects via reconciliation job | Within 1 hour of status change |
| 3 | Drop creates refund record in audit_log | Immediate |
| 4 | ASPSP reverses the debit (automatic for SEPA) | 1-3 business days |
| 5 | Drop updates bank_accounts.balance on next AISP sync |
Next balance refresh |
| 6 | User notified via push notification | Immediate |
Note: For SWIFT transfers, refund timing depends on correspondent banks and may take 5-10 business days. Drop sends a notification with estimated refund timeline.
10. AML/Compliance Checks
Each remittance triggers compliance checks before PISP initiation:
| Check | Implementation | Action on Trigger |
|---|---|---|
| Velocity limit | > 5 remittances/hour or > 20/day | aml_alerts record (medium severity), continue |
| Structuring detection | Multiple amounts just below 25,000 NOK | aml_alerts record (high severity), review queue |
| High-risk corridor | FATF grey/black list country | Enhanced due diligence flag |
| Single large transfer | > 25,000 NOK | Enhanced monitoring |
| Total daily volume | > 100,000 NOK cumulative | aml_alerts record, may require manual approval |
| Sanctions screening | Recipient name vs sanctions lists | Block if match, screening_results record |
11. Database Impact
11.1 Tables Written
| Table | Operation | When |
|---|---|---|
transactions |
INSERT | Payment initiated (Step 4) |
transactions |
UPDATE | Status change (processing to completed/failed) |
bank_accounts |
UPDATE (balance) | Atomic debit during transaction creation |
audit_log |
INSERT | Every payment action |
notifications |
INSERT | Payment sent / completed / failed |
aml_alerts |
INSERT | If AML rule triggered |
11.2 Tables Read
| Table | Operation | When |
|---|---|---|
users |
SELECT (kyc_status) | Pre-flight KYC check |
recipients |
SELECT | Recipient lookup (Step 1) |
bank_accounts |
SELECT (balance) | Balance check (Step 4) |
exchange_rates |
SELECT (rate) | FX rate lookup (Step 2, 3, 4) |
transactions |
SELECT (idempotency_key) | Duplicate detection |
12. Cross-References
- Payment Processing: ../integration/payment-processing.md — SEPA/SWIFT settlement, FX, fees
- Open Banking AISP/PISP: ../integration/open-banking-aisp-pisp.md — PISP API details
- BankID OIDC: ../integration/bankid-oidc-integration.md — Drop authentication
- Security Architecture: ../hld/security-architecture.md — Fraud detection, AML pipeline
- Bank Account Linking: flow-bank-account-linking.md — Prerequisite: linked bank account
- Database Schema: ../../backend/DATABASE-SCHEMA.md —
transactions,recipients,exchange_ratestables - API Reference: ../../backend/API-REFERENCE.md — Remittance, disclosure, receipt, rate endpoints
Merchant Onboarding Flow
Flow: Merchant Onboarding
Document: LLD-006 Version: 1.0 Date: 2026-02-21 Author: Frontend Architect (AI Agent) Status: Draft Scope: Merchant registration, business verification, QR code generation, merchant dashboard, transaction monitoring, and settlement
1. Overview
Drop supports merchant onboarding for in-store QR payments. Any authenticated user can register as a merchant by providing business details and a valid Norwegian organization number. Upon registration, the user's role is upgraded from user to merchant, granting access to the merchant dashboard with transaction monitoring, daily summaries, and settlement views.
Key facts:
- Merchant registration requires authenticated BankID login
- Organization number verified against Brønnøysundregistrene (production; format validation in demo)
- QR code format:
drop://pay/{merchantId} - Merchant fee: 1% per QR transaction (lower than card terminal 1.75-2.75%)
- Settlement: T+1 (planned) — funds from transactions deposited to merchant's bank account
2. Merchant Registration Flow
2.1 Sequence Diagram — Registration to Active Merchant
sequenceDiagram
actor User
participant App as Drop App
participant API as Drop API<br/>(/api/merchants)
participant Brreg as Brønnøysund<br/>Register (prod)
participant DB as Database
User->>App: Navigate to merchant registration
App->>App: useAuth() — verify authenticated
User->>App: Fill registration form<br/>(businessName, orgNumber, address, bankAccount)
App->>App: Client-side validation<br/>(name: validateName, orgNumber: 9 digits)
App->>API: POST /api/merchants/register<br/>{ businessName, orgNumber, address, bankAccount }
API->>API: JWT verification
API->>API: Validate input fields
alt Production
API->>Brreg: GET /enhetsregisteret/api/enheter/{orgNumber}
Brreg-->>API: { organisasjonsnummer, navn, organisasjonsform, ... }
API->>API: Verify business exists and is active
else Demo
API->>API: Format validation only (9 digits, unique)
end
API->>DB: Check org_number uniqueness
API->>DB: INSERT merchant (user_id, business_name, org_number, bank_account, fee_rate=0.01)
API->>DB: UPDATE users SET role = 'merchant' WHERE id = ?
API->>API: Generate QR URI: drop://pay/{merchantId}
API-->>App: 201 { merchant: { id, businessName, orgNumber, qrUri } }
App->>App: Show success screen<br/>(QR code display, "Vis min QR-kode" button)
App->>App: Navigate to merchant dashboard
3. Merchant Dashboard Components
3.1 Component Diagram
graph TD
subgraph "Merchant Dashboard"
Header["Header<br/>'VELKOMMEN' + business name<br/>+ Settings button"]
PeriodFilter["PeriodFilter<br/>(I dag / Uke / Maaned)"]
RevenueCard["RevenueCard<br/>(green gradient)"]
QRButton["QRButton<br/>'Vis min QR-kode'"]
TransactionList["TransactionList<br/>'Dagens transaksjoner'"]
BottomNav["BottomNav"]
end
subgraph "Revenue Card"
TotalRevenue["Total omsetning<br/>(4xl Fraunces font)"]
StatsGrid["Stats Grid (2 cols)"]
TxCount["Transaksjoner<br/>(count)"]
FeesInfo["Gebyrer betalt<br/>(NOK amount)"]
end
subgraph "Transaction Item"
CustomerIcon["CheckCircle2<br/>(green)"]
CustomerName["Customer Name<br/>(partially anonymized)"]
TxTime["Timestamp"]
TxAmount["Amount<br/>(+NOK, green)"]
end
RevenueCard --> TotalRevenue
RevenueCard --> StatsGrid
StatsGrid --> TxCount
StatsGrid --> FeesInfo
TransactionList --> CustomerIcon
TransactionList --> CustomerName
TransactionList --> TxTime
TransactionList --> TxAmount
4. Business Verification Checklist
4.1 Registration Requirements
| Requirement | Field | Validation | Demo | Production |
|---|---|---|---|---|
| Business name | businessName |
validateName() — 1-100 chars, at least one letter, no HTML/script |
Format check only | Format check |
| Organization number | orgNumber |
Exactly 9 digits, unique in DB | Format + uniqueness | Brønnøysund API lookup |
| Business address | address |
Optional, sanitized to 300 chars | Optional | Required for settlement |
| Payout bank account | bankAccount |
Required, non-empty | Format check | IBAN/account validation |
| User authentication | JWT | Valid BankID session | Required | Required |
| KYC status | user.kycStatus |
Must be approved |
Auto-approved via BankID | BankID verification |
4.2 Brønnøysundregistrene Verification (Production)
| Check | API | Response Field | Pass Criteria |
|---|---|---|---|
| Business exists | GET /enhetsregisteret/api/enheter/{orgNr} |
organisasjonsnummer |
Matches input |
| Business is active | Same | registreringsdatoEnhetsregisteret |
Not null |
| Business type | Same | organisasjonsform.kode |
AS, ENK, NUF, DA, ANS |
| Business name match | Same | navn |
Approximate match to submitted name |
5. Settlement Schedule
5.1 Settlement Schedule Table
| Period | Settlement Day | Payout Time | Details |
|---|---|---|---|
| Daily transactions | T+1 | 08:00 CET | Next business day after transaction |
| Weekend transactions | Monday | 08:00 CET | Batched for Monday payout |
| Holiday transactions | Next business day | 08:00 CET | Following Norwegian business day |
5.2 Settlement Calculation
| Field | Formula | Example |
|---|---|---|
| Gross revenue | Sum of all QR payment amounts | 4 350 NOK |
| Merchant fee | Gross x 1% (fee_rate) | 43.50 NOK |
| Net payout | Gross - fee | 4 306.50 NOK |
| Payout account | merchant.bankAccount |
IBAN or Norwegian account |
5.3 Merchant Dashboard API
Endpoint: GET /api/merchants/dashboard?period={today|week|month}
Response:
{
"data": {
"revenue": 4350,
"transactionCount": 12,
"fees": 43.5,
"netRevenue": 4306.5,
"nextPayout": "2026-02-22T07:00:00.000Z",
"payoutTime": "Neste virkedag kl. 08:00"
}
}
6. QR Code Generation
| Property | Value |
|---|---|
| Format | URI: drop://pay/{merchantId} |
| Encoding | Standard QR code (alphanumeric) |
| Generation | Client-side (from returned qrUri) |
| Display | "Vis min QR-kode" button on merchant dashboard |
| Printing | Merchant can screenshot or print for in-store display |
QR endpoint: GET /api/merchants/qr
{
"data": {
"merchantId": "mer_a1b2c3d4e5f6g7h8",
"businessName": "Ahmetov Kebab",
"qrValue": "drop://pay/mer_a1b2c3d4e5f6g7h8",
"address": "Gronlandsleiret 44, 0190 Oslo"
}
}
7. Merchant Transaction Monitoring
7.1 Transaction List
Endpoint: GET /api/merchants/transactions?page=1&limit=20
| Field | Value | Privacy |
|---|---|---|
| Customer name | First name + last initial | Partially anonymized (e.g., "Ola N.") |
| Amount | Positive NOK value | Full amount shown |
| Status | "Vellykket" (green) | Color-coded |
| Timestamp | HH:MM format | Time only for today's transactions |
7.2 Period Filtering
| Period | API Value | Dashboard Label | Aggregation |
|---|---|---|---|
| Today | today |
I dag | Sum of today's transactions |
| This week | week |
Uke | Mon-Sun aggregation |
| This month | month |
Maaned | Calendar month aggregation |
8. UI Components (Web)
8.1 Merchant Dashboard Layout
| Section | Component | Description |
|---|---|---|
| Header | Business name (Fraunces) + Settings icon | Welcome greeting + gear icon |
| Period tabs | Button group (I dag, Uke, Maaned) | Green active, gray inactive |
| Revenue card | Green gradient card (#0B6E35 to #095a2b) | Total omsetning (4xl), stats grid |
| QR button | Full-width green button with QrCode icon | "Vis min QR-kode" |
| Transaction list | Card list with CheckCircle2 icons | Customer name, time, +amount (green) |
| Navigation | BottomNav (5 tabs) | Standard bottom navigation |
8.2 Figma Reference
Source of truth: mockups/figma-make-export/src/app/screens/MerchantDashboard.tsx
- Welcome header with business name
- Period filter tabs (I dag / Uke / Maaned)
- Green gradient revenue card with stats
- QR code button
- Transaction list with customer names
9. Role Upgrade Flow
| Step | Before | After |
|---|---|---|
| 1 | User has role = 'user' |
Same |
| 2 | User submits merchant registration | Same |
| 3 | API validates and creates merchant record | role = 'merchant' |
| 4 | User's JWT still has old role | Valid until refresh |
| 5 | On next token refresh / re-login | New JWT has role = 'merchant' |
GET /api/merchants/dashboard— requiresrole = 'merchant'GET /api/merchants/qr— requiresrole = 'merchant'GET /api/merchants/transactions— requiresrole = 'merchant'
10. Platform Differences
| Feature | Web | Mobile |
|---|---|---|
| Merchant registration | Full form via web UI | Not implemented |
| Merchant dashboard | Dedicated screen with stats | Not implemented |
| QR code display | Button to show QR | Not implemented |
| Transaction monitoring | List with period filter | Not implemented |
| Settlement view | Inline in dashboard stats | Not implemented |
11. Accessibility Considerations (WCAG 2.1 AA)
| Requirement | Implementation |
|---|---|
| Form validation | Registration form shows inline error messages |
| Revenue card | Uses both visual (bold text) and semantic (heading) for amounts |
| Period tabs | Active tab indicated by color AND aria-selected |
| Transaction list | Each item has descriptive text (customer, amount, status) |
| QR code | Alt text: "QR-kode for {businessName}" |
| Color contrast | White text on green gradient meets 4.5:1 |
| Settings icon | Has aria-label "Innstillinger" |
12. Cross-References
- Merchant registration API:
POST /api/merchants/register— See API Reference - Merchant dashboard API:
GET /api/merchants/dashboard— See API Reference - Merchant QR API:
GET /api/merchants/qr— See API Reference - Merchant transactions API:
GET /api/merchants/transactions— See API Reference - Merchants schema:
merchantstable — See Database Schema - Transactions schema:
transactionstable — See Database Schema - Component overview: See component-overview.md
- Figma merchant dashboard:
mockups/figma-make-export/src/app/screens/MerchantDashboard.tsx - QR payment flow: See flow-qr-payment.md
- Authentication flow: See flow-login-authentication.md
Transaction History Flow
Flow: Transaction History
Document: LLD-003 Version: 1.0 Date: 2026-02-21 Author: Frontend Architect (AI Agent) Status: Draft Scope: Transaction list rendering, filtering, pagination, transaction detail view, receipt download, and dispute initiation
1. Overview
The transaction history view provides users with a chronological list of all their financial transactions (remittances and QR payments). The list supports filtering by type and status, date-based grouping, infinite scroll pagination, and drill-down to transaction detail with receipt download.
Key capabilities:
- Paginated transaction list (20 per page, max 50)
- Filter by type: All, Remittance, QR Payment
- Filter by status: Processing, Completed, Failed
- Date grouping: I dag, I gar, Denne uken, Eldre
- Transaction detail view with exchange rate info
- Receipt download (JSON receipt)
- Complaint/dispute initiation via
/complaints
2. Transaction List Load + Filter + Detail View
2.1 Sequence Diagram
sequenceDiagram
actor User
participant App as Drop App<br/>(/transactions)
participant API as Drop API
participant DB as Database
User->>App: Navigate to /transactions
App->>App: useAuth() — verify authenticated
App->>API: GET /api/transactions?page=1&limit=20
API->>API: JWT verification (cookie/Bearer)
API->>DB: SELECT transactions WHERE user_id = ?<br/>ORDER BY created_at DESC LIMIT 20
API-->>App: { data: [...], pagination: { page: 1, limit: 20, total: N } }
App->>App: groupByDate(transactions)<br/>→ "I dag", "I gar", "Denne uken", "Eldre"
App->>App: Render grouped transaction list
User->>App: Tap filter "Overforinger"
App->>API: GET /api/transactions?type=remittance&limit=50
API->>DB: SELECT WHERE type = 'remittance'
API-->>App: { data: [...filtered...] }
App->>App: Re-group and render
User->>App: Scroll to bottom (infinite scroll)
App->>API: GET /api/transactions?page=2&limit=20&type=remittance
API-->>App: { data: [...more...], pagination: { page: 2 } }
App->>App: Append to list, re-group
User->>App: Tap transaction row
App->>API: GET /api/transactions/{id}
API->>DB: SELECT transaction with exchange_rate info
API-->>App: { data: { ...fullDetails } }
App->>App: Show transaction detail modal/page
User->>App: Tap "Last ned kvittering"
App->>API: GET /api/transactions/{id}/receipt
API-->>App: { data: { receipt } }
App->>App: Download/display receipt
User->>App: Tap "Klag" (dispute)
App->>App: Navigate to /complaints<br/>with transaction context
3. Transaction List Components
3.1 Component Diagram
graph TD
subgraph "Transaction History Page"
PageHeader["PageHeader<br/>Back button + 'Transaksjonshistorikk' title"]
FilterTabs["FilterTabs<br/>(Tabs component)"]
TransactionList["TransactionList<br/>(scrollable area)"]
LoadMore["LoadMore Trigger<br/>(infinite scroll sentinel)"]
end
subgraph "Filter Tabs"
TabAll["Alle<br/>(all types)"]
TabRemittance["Overforinger<br/>(type=remittance)"]
TabQR["QR-betalinger<br/>(type=qr_payment)"]
end
subgraph "Transaction List Items"
DateGroup["DateGroupHeader<br/>('I DAG', 'I GAR', etc.)"]
TransactionCard["TransactionCard"]
end
subgraph "Transaction Card"
TxIcon["TypeIcon<br/>(ArrowUpRight, ScanLine, Clock)"]
TxInfo["TxInfo<br/>(name, type label)"]
TxAmount["TxAmount<br/>(amount + status badge)"]
end
subgraph "Transaction Detail"
DetailHeader["DetailHeader<br/>(back + title)"]
DetailSummary["DetailSummary<br/>(type, status, date)"]
AmountBreakdown["AmountBreakdown<br/>(send, receive, rate, fee)"]
RecipientInfo["RecipientInfo<br/>(name, country, bank)"]
ReceiptButton["ReceiptButton<br/>('Last ned kvittering')"]
DisputeButton["DisputeButton<br/>('Klag')"]
end
FilterTabs --> TabAll
FilterTabs --> TabRemittance
FilterTabs --> TabQR
TransactionList --> DateGroup
DateGroup --> TransactionCard
TransactionCard --> TxIcon
TransactionCard --> TxInfo
TransactionCard --> TxAmount
TransactionCard -->|tap| DetailHeader
DetailHeader --> DetailSummary
DetailSummary --> AmountBreakdown
AmountBreakdown --> RecipientInfo
RecipientInfo --> ReceiptButton
RecipientInfo --> DisputeButton
4. Filter Options
4.1 Filter Options Table
| Filter | API Parameter | Value | Label (Norwegian) | Default |
|---|---|---|---|---|
| All transactions | (none) | — | Alle | Yes |
| Remittances only | type |
remittance |
Overforinger | No |
| QR payments only | type |
qr_payment |
QR-betalinger | No |
| Processing status | status |
processing |
Behandles | No |
| Completed status | status |
completed |
Fulfort | No |
| Failed status | status |
failed |
Mislykket | No |
4.2 Pagination Parameters
| Parameter | Type | Default | Min | Max | Description |
|---|---|---|---|---|---|
page |
int | 1 | 1 | — | Page number |
limit |
int | 20 | 1 | 50 | Items per page |
5. Transaction Status Display Mapping
| Status | Label (Norwegian) | Color | Icon | Background |
|---|---|---|---|---|
completed |
Fulfort | #0B6E35 (green) |
ArrowUpRight / ScanLine | #F8FAFC |
processing |
Behandles | #D4A017 (gold) |
Clock | #FEF3C7 |
failed |
Mislykket | #EF4444 (red) |
X | #FEF2F2 |
5.1 Transaction Type Icons
| Type | Icon | Color | Description |
|---|---|---|---|
remittance (sent) |
ArrowUpRight | #0B6E35 |
Outgoing international transfer |
qr_payment |
ScanLine | #0B6E35 |
QR code payment to merchant |
remittance (processing) |
Clock | #D4A017 |
Transfer in progress |
5.2 Amount Display
| Condition | Format | Color | Example |
|---|---|---|---|
| Outgoing (sent) | -{amount} kr |
#0F172A (dark) |
-2 000 kr |
| Incoming (received) | +{amount} kr |
#10B981 (green) |
+5 000 kr |
| Processing | -{amount} kr |
#0F172A (dark) |
-3 000 kr |
6. Date Grouping Logic
The groupByDate() function categorizes transactions into temporal groups:
| Group | Label | Condition |
|---|---|---|
| Today | I DAG | createdAt is today |
| Yesterday | I GAR | createdAt is yesterday |
| This Week | DENNE UKEN | createdAt is within current week (Mon-Sun) |
| Older | Date string (e.g., "12. OKT") | All older transactions, grouped by date |
7. Transaction Detail View
7.1 Remittance Detail Fields
| Field | Source | Example |
|---|---|---|
| Transaction ID | data.id |
tx_rem_a1b2c3d4e5f6g7h8 |
| Type | data.type |
Overforing |
| Status | data.status |
Fullfort |
| Send Amount | data.sendAmount + data.sendCurrency |
2 000 NOK |
| Receive Amount | data.receiveAmount + data.receiveCurrency |
23 400 RSD |
| Exchange Rate | data.exchangeRate |
1 NOK = 11.70 RSD |
| Fee | data.fee |
10.00 NOK (0.5%) |
| Total Cost | data.total |
2 010 NOK |
| Recipient | data.recipientName |
Mama Jasmina |
| Destination | data.recipientCountry |
Serbia |
| Created | data.createdAt |
21. feb 2026 kl. 14:32 |
| Completed | data.completedAt |
21. feb 2026 kl. 14:35 |
7.2 QR Payment Detail Fields
| Field | Source | Example |
|---|---|---|
| Transaction ID | data.id |
tx_qr_a1b2c3d4e5f6g7h8 |
| Type | data.type |
QR-betaling |
| Status | data.status |
Fullfort |
| Amount | data.amount |
129 NOK |
| Fee | data.fee |
1.29 NOK (1%) |
| Merchant | data.merchantName |
Ahmetov Kebab |
| Source Account | data.fromAccount |
DNB |
| Created | data.createdAt |
21. feb 2026 kl. 12:15 |
7.3 Receipt Endpoint
GET /api/transactions/{id}/receipt returns a structured receipt:
{
"data": {
"transactionId": "tx_rem_1",
"date": "2026-02-21T14:32:00.000Z",
"type": "remittance",
"amount": 2000,
"currency": "NOK",
"fee": 10,
"exchangeRate": 11.7,
"receiveAmount": 23400,
"receiveCurrency": "RSD",
"recipient": { "name": "Mama Jasmina", "country": "RS" },
"reference": "tx_rem_1",
"status": "completed",
"completedAt": "2026-02-21T14:35:00.000Z"
}
}
8. Dispute Initiation
Users can initiate a dispute from the transaction detail view by navigating to the complaints page:
| Step | Action |
|---|---|
| 1 | User taps "Klag" on transaction detail |
| 2 | Navigate to /complaints with pre-filled category = "transaction" |
| 3 | User fills subject and description |
| 4 | POST /api/complaints { category: "transaction", subject, description } |
| 5 | Confirmation: "Vi behandler klagen din innen 15 virkedager" (Finansavtaleloven 3-53) |
9. Platform Differences
| Feature | Web (/transactions) |
Mobile (history.js) |
|---|---|---|
| Filter tabs | Tabs component (Alle, Overforinger, QR-betalinger) | Custom buttons (Alle, Sendinger, QR) |
| Pagination | Infinite scroll with limit=50 | FlatList with pull-to-refresh |
| Transaction detail | Inline expansion or modal | Separate screen (not yet implemented) |
| Receipt download | API call + browser download | Not implemented |
| Dispute link | Navigate to /complaints | Not implemented |
| Date grouping | groupByDate() utility | Same pattern |
| Refresh | Re-fetch on filter change | Pull-to-refresh via RefreshControl |
10. Accessibility Considerations (WCAG 2.1 AA)
| Requirement | Implementation |
|---|---|
| List semantics | Transaction list uses semantic list markup |
| Filter announcement | Active filter tab announced to screen readers |
| Amount polarity | Positive/negative amounts use text prefix (+/-) in addition to color |
| Status indication | Status uses both color AND text label (not color alone) |
| Tap targets | Transaction cards are full-width, min 48px height |
| Loading state | Skeleton placeholders shown during initial load |
| Empty state | "Ingen transaksjoner" message when list is empty |
| Keyboard nav | Tab cycles through filters, then transaction items |
11. Cross-References
- Transaction list API:
GET /api/transactions— See API Reference - Transaction detail API:
GET /api/transactions/{id}— See API Reference - Receipt API:
GET /api/transactions/{id}/receipt— See API Reference - Complaints API:
POST /api/complaints— See API Reference - Transaction schema:
transactionstable — See Database Schema - Component overview: See component-overview.md
- Figma transaction history:
mockups/figma-make-export/src/app/screens/TransactionHistory.tsx - Web page:
src/drop-app/src/app/transactions/page.tsx— See PAGES.md - Mobile screen:
src/drop-mobile/app/history.js— See MOBILE-APP.md - QR payment flow: See flow-qr-payment.md
Profile & Settings Flow
Flow: Profile & Settings
Document: LLD-004 Version: 1.0 Date: 2026-02-21 Author: Frontend Architect (AI Agent) Status: Draft Scope: Profile page, personal information display, KYC status, settings management, GDPR data export, and account deletion flow
1. Overview
The profile section provides users with personal information display (sourced from BankID), KYC verification status, and configurable settings. It also handles GDPR compliance features including data export (right to portability) and account deletion (right to erasure) with mandatory AML data retention notices.
Profile sub-pages (web):
/profile— Hub with user info and menu/profile/personal— BankID-verified personal details (read-only)/profile/security— Security settings and active devices/profile/notifications— Push and email notification toggles/profile/language— Language selection (nb, en, bs, sq)
2. Profile Load + Settings Update
2.1 Sequence Diagram
sequenceDiagram
actor User
participant App as Drop App<br/>(/profile)
participant API as Drop API
participant DB as Database
User->>App: Navigate to /profile
App->>App: useAuth() — verify authenticated
App->>API: GET /api/auth/me
API->>DB: SELECT user + bank_accounts
API-->>App: { user: { id, firstName, lastName, email, kycStatus, ... } }
App->>App: Render profile hub<br/>(avatar initials, name, email, menu items)
User->>App: Tap "Personlig informasjon"
App->>App: Navigate to /profile/personal
App->>App: Display BankID-verified fields (read-only)
User->>App: Tap "Varsler"
App->>App: Navigate to /profile/notifications
App->>API: GET /api/settings
API->>DB: SELECT settings WHERE user_id = ?
alt Settings exist
API-->>App: { data: { currency, language, pushEnabled, emailEnabled } }
else No settings
API->>DB: INSERT default settings (NOK, nb, push=true, email=true)
API-->>App: { data: { default settings } }
end
App->>App: Render notification toggles
User->>App: Toggle push notifications OFF
App->>App: Update local state immediately
App->>API: PATCH /api/settings { pushEnabled: false }
API->>DB: UPDATE settings SET push_enabled = 0
API-->>App: 200 { updated settings }
alt API failure
App->>App: Revert toggle to previous state
end
User->>App: Tap "Sprak" (Language)
App->>App: Navigate to /profile/language
App->>API: GET /api/settings
API-->>App: { language: "nb" }
App->>App: Show language list with current selection
User->>App: Select "English", tap "Lagre"
App->>API: PATCH /api/settings { language: "en" }
API-->>App: 200 { updated }
3. Account Deletion Flow
3.1 Sequence Diagram
sequenceDiagram
actor User
participant App as Drop App
participant API as Drop API
participant DB as Database
User->>App: Navigate to /profile then "Slett konto"
App->>App: Show deletion warning dialog<br/>"Er du sikker? Dette kan ikke angres."
App->>App: Show AML retention notice<br/>"Data beholdes i 5 aar iht. hvitvaskingsloven"
User->>App: Confirm "Ja, slett kontoen min"
App->>API: DELETE /api/user/account
API->>DB: BEGIN TRANSACTION
API->>DB: UPDATE users SET deleted_at = datetime('now')
API->>DB: UPDATE sessions SET revoked = 1 WHERE user_id = ?
API->>DB: INSERT data_access_requests<br/>(type=erasure, status=completed)
API->>DB: COMMIT
API-->>App: 200 { message: "Account scheduled for deletion",<br/>retentionNote: "Data retained for 5 years per AML requirements" }
App->>App: Clear auth cookie/token
App->>App: Show confirmation screen
App->>App: Navigate to /login after 5 seconds
3.2 Account Deletion Process Flow
flowchart TD
A[User requests account deletion] --> B{Confirmation dialog}
B -->|Cancel| C[Return to profile]
B -->|Confirm| D[Show AML retention notice]
D --> E{User acknowledges retention}
E -->|Cancel| C
E -->|Proceed| F[DELETE /api/user/account]
F --> G[Soft-delete user record<br/>deleted_at = now]
G --> H[Revoke all active sessions]
H --> I[Create erasure request record]
I --> J[Clear auth cookie/token]
J --> K[Show confirmation screen]
K --> L[Redirect to /login]
style D fill:#FEF3C7
style G fill:#FEF2F2
4. Settings Matrix
4.1 User Settings
| Setting | API Field | Type | Options | Default | Persistence |
|---|---|---|---|---|---|
| Display currency | currency |
string | EUR, USD, GBP, BAM, CHF, PLN, NOK, RSD, TRY, PKR | NOK | PATCH /api/settings |
| Language | language |
string | nb (Norsk Bokmal), en (English), bs (Bosanski), sq (Shqip) | nb | PATCH /api/settings |
| Push notifications | pushEnabled |
boolean | true/false | true | PATCH /api/settings |
| Email notifications | emailEnabled |
boolean | true/false | true | PATCH /api/settings |
4.2 Personal Information (Read-Only from BankID)
| Field | Source | Editable | Display Format |
|---|---|---|---|
| First name | BankID ID token | No | Plain text |
| Last name | BankID ID token | No | Plain text |
| User registration / BankID | No | Plain text | |
| Phone | User registration | No | +47 XXX XX XXX |
| Date of birth | BankID pid (national ID) | No | DD. MMMM YYYY (e.g., "15. mars 1995") |
| KYC status | System (auto via BankID) | No | Badge: "Verifisert med BankID" (green) |
4.3 Security Settings (UI Display Only)
| Setting | Current Value | Status | Actionable |
|---|---|---|---|
| Password | "Sist endret: Aldri" | Info display | Change button (planned) |
| BankID verification | Active | Green badge | N/A |
| Vipps verification | Not activated | Gray badge | Planned (Phase 2) |
| Active devices | iPhone 15 Pro (active), MacBook Pro (yesterday) | Live info | View/revoke (planned) |
5. GDPR Rights Mapping
| GDPR Right | Article | Implementation | API Endpoint | Status |
|---|---|---|---|---|
| Right to access (innsyn) | Art. 15 | Full data export as JSON | GET /api/user/data-export |
Implemented |
| Right to rectification (retting) | Art. 16 | BankID data is authoritative; address via support | N/A | Via support |
| Right to erasure (sletting) | Art. 17 | Soft-delete with 5-year AML retention | DELETE /api/user/account |
Implemented |
| Right to data portability | Art. 20 | Same as data export (JSON format) | GET /api/user/data-export |
Implemented |
| Right to withdraw consent | Art. 7 | Consent toggle + withdrawal tracking | POST /api/consents { granted: false } |
Implemented |
| Right to lodge complaint | Art. 77 | Link to Datatilsynet on privacy page | N/A | Link provided |
5.1 Data Export Contents
GET /api/user/data-export returns:
| Section | Data |
|---|---|
user |
id, email, first_name, last_name, phone, date_of_birth, kyc_status, role, created_at |
transactions |
All transaction records |
recipients |
All saved recipients |
bankAccounts |
Linked bank accounts (masked numbers) |
settings |
Currency, language, notification preferences |
consents |
All consent records with timestamps |
5.2 Data Retention After Deletion
| Data Category | Retention Period | Legal Basis |
|---|---|---|
| Transaction records | 5 years | Hvitvaskingsloven (AML) |
| KYC/identity data | 5 years | Hvitvaskingsloven (AML) |
| AML alerts and STR reports | 5 years | Hvitvaskingsloven (AML) |
| Consent records | 5 years | GDPR proof of consent |
| User profile (soft-deleted) | 5 years | Legal obligation |
| Settings, preferences | Deleted immediately | No retention requirement |
| Notification history | Deleted immediately | No retention requirement |
6. UI Components
6.1 Profile Hub (/profile)
| Element | Component | Source |
|---|---|---|
| Avatar | Green gradient circle with initials | Inline (gradient from #0B6E35 to #095a2b) |
| Edit button | Pen icon on avatar | Pen (lucide) |
| User info | Name (Fraunces font) + email | Text |
| Account section | Menu items with chevrons | ChevronRight (lucide) |
| Settings section | Menu items with chevrons | Bell, Shield, Globe (lucide) |
| Bottom navigation | 5-tab bar | BottomNav component |
6.2 Notification Settings (/profile/notifications)
| Element | Component | Behavior |
|---|---|---|
| Push toggle | Custom switch | Immediate PATCH, revert on failure |
| Email toggle | Custom switch | Immediate PATCH, revert on failure |
6.3 Language Settings (/profile/language)
| Element | Component | Behavior |
|---|---|---|
| Language list | Radio selection with green checkmark | Local state update |
| Save button | Green "Lagre" button | PATCH on click |
6.4 Figma Reference
Source of truth: mockups/figma-make-export/src/app/screens/Profile.tsx
- User avatar with initials and edit button
- Account section (Personlig informasjon, Bankkontoer)
- Settings section (Varsler, Sikkerhet, Sprak)
7. Platform Differences
| Feature | Web | Mobile |
|---|---|---|
| Profile layout | Hub with 4 sub-pages | Single screen with inline settings |
| Personal info | Dedicated /profile/personal page | Inline section |
| Security settings | Dedicated /profile/security page | Not implemented |
| Notification settings | Dedicated /profile/notifications page | Inline in profile |
| Language | Dedicated /profile/language page | "Sprak" in settings menu |
| Recipients list | Not on profile (separate /recipients) | Inline "Mine mottakere" section |
| GDPR export | Via /profile or API | Not implemented |
| Account deletion | Via /profile | Not implemented |
| Logout | Confirmation dialog | Simple button with token clear |
8. Accessibility Considerations (WCAG 2.1 AA)
| Requirement | Implementation |
|---|---|
| Form labels | All settings have visible labels |
| Toggle state | Push/email toggles announce on/off state to screen readers |
| Read-only fields | Personal info fields use disabled attribute with visible label |
| KYC badge | Uses both color (green) and icon (ShieldCheck) for verification status |
| Navigation | Sub-page back buttons have aria-label "Tilbake" |
| Destructive action | Account deletion requires explicit confirmation dialog |
| Language names | Languages listed in their own script for recognition |
| Color contrast | Green badge (#0B6E35) on white meets 4.5:1 |
9. Cross-References
- User data API:
GET /api/auth/me— See API Reference - Settings API:
GET/PATCH /api/settings— See API Reference - Data export API:
GET /api/user/data-export— See API Reference - Account deletion API:
DELETE /api/user/account— See API Reference - Consents API:
GET/POST /api/consents— See API Reference - Settings schema:
settingstable — See Database Schema - Data access requests schema:
data_access_requeststable — See Database Schema - Component overview: See component-overview.md
- Figma profile screen:
mockups/figma-make-export/src/app/screens/Profile.tsx - Web profile pages:
src/drop-app/src/app/profile/— See PAGES.md - Mobile profile screen:
src/drop-mobile/app/(tabs)/profile.js— See MOBILE-APP.md - Authentication flow: See flow-login-authentication.md
Notifications Flow
Flow: Notifications
Document: LLD-005 Version: 1.0 Date: 2026-02-21 Author: Frontend Architect (AI Agent) Status: Draft Scope: Push notification delivery, in-app notification center, notification types, read/unread state, deep linking, and permission handling
1. Overview
Drop's notification system provides users with transaction alerts, security notifications, and system updates. The current implementation consists of an in-app notification center (bell icon) with read/unread state management. Push notifications via Expo Push are planned for mobile but not yet implemented.
Current state:
- In-app notification center: Implemented (web)
- Push notifications: Not yet implemented (planned via Expo Push for mobile)
- Deep linking from notifications: Not yet configured
- Notification preferences: Implemented (push/email toggles in settings)
2. Push Notification Delivery (Planned Architecture)
2.1 Sequence Diagram — Push Notification Flow
sequenceDiagram
actor User
participant Mobile as Expo App
participant API as Drop API
participant DB as Database
participant Push as Expo Push<br/>Service
participant APNS as APNs / FCM
Note over Mobile,APNS: Setup Phase (on login)
Mobile->>Mobile: Request notification permission
Mobile->>Push: Register for push token
Push-->>Mobile: { expoPushToken }
Mobile->>API: POST /api/push-token { token, platform }
API->>DB: INSERT push_tokens (user_id, token, platform)
Note over API,APNS: Trigger Phase (on event)
API->>API: Transaction completed / security event
API->>DB: INSERT notification (user_id, type, title, body)
API->>DB: SELECT push_tokens WHERE user_id = ?
API->>Push: POST /send { to: expoPushToken, title, body, data }
Push->>APNS: Forward to APNs (iOS) / FCM (Android)
APNS-->>Mobile: Push notification delivered
Note over Mobile: Receive Phase
Mobile->>Mobile: Display system notification
User->>Mobile: Tap notification
Mobile->>Mobile: Deep link to relevant screen
Mobile->>API: PATCH /api/notifications { notificationIds: [id] }
API->>DB: UPDATE notifications SET read = 1
2.2 In-App Notification Center (Current Implementation)
sequenceDiagram
actor User
participant App as Drop App<br/>(/notifications)
participant API as Drop API
participant DB as Database
User->>App: Tap bell icon (dashboard) or navigate to /notifications
App->>App: useAuth() — verify authenticated
App->>API: GET /api/notifications
API->>DB: SELECT notifications WHERE user_id = ?<br/>ORDER BY created_at DESC
API-->>App: { data: [ { id, type, title, body, read, createdAt }, ... ] }
App->>App: Group by date (I DAG, I GAR, older)
App->>App: Render notification cards with icons
Note over App: Auto-mark as read on page load
App->>App: Collect unread notification IDs
App->>API: PATCH /api/notifications<br/>{ notificationIds: [unread IDs] }
API->>DB: UPDATE notifications SET read = 1<br/>WHERE id IN (?) AND user_id = ?
API-->>App: 200 OK (fire-and-forget)
3. Notification Center Components
3.1 Component Diagram
graph TD
subgraph "Notification Center Page"
Header["Header<br/>Back button + 'Varsler' title"]
NotificationList["NotificationList"]
EmptyState["EmptyState<br/>Bell icon + 'Ingen varsler enna'"]
end
subgraph "Notification List"
DateGroup["DateGroupHeader<br/>('I DAG', 'I GAR', date)"]
NotificationCard["NotificationCard"]
end
subgraph "Notification Card"
TypeIcon["TypeIcon<br/>(colored circle + icon)"]
Content["Content<br/>(title, body, timestamp)"]
UnreadDot["UnreadDot<br/>(blue indicator)"]
end
subgraph "Dashboard Integration"
BellIcon["Bell Icon<br/>(header, with badge count)"]
end
NotificationList --> DateGroup
DateGroup --> NotificationCard
NotificationCard --> TypeIcon
NotificationCard --> Content
NotificationCard --> UnreadDot
BellIcon -->|navigate| Header
4. Notification Type Table
| Type | Icon | Icon Color | Background | Title Example | Body Example | Priority |
|---|---|---|---|---|---|---|
transaction_complete |
ArrowUpRight | #0B6E35 (green) |
#F0FDF4 |
"Overforing til Mama Jasmina fullfort" | "2 000 kr sendt til Serbia" | Normal |
qr_payment |
ScanLine | #D4A017 (gold) |
#FEF3C7 |
"QR-betaling hos Ahmetov Kebab" | "129 kr betalt" | Normal |
security |
Smartphone | #3B82F6 (blue) |
#EFF6FF |
"Ny palogging fra iPhone" | "Oslo, Norge" | High |
rate_update |
TrendingUp | #D4A017 (gold) |
#FEF3C7 |
"Valutakurs oppdatert" | "1 NOK = 11.70 RSD" | Low |
system |
Bell | #6B7280 (gray) |
#F3F4F6 |
"Systemoppdatering" | "Drop er oppdatert til v0.2.0" | Low |
promotional |
— | #6B7280 (gray) |
#F3F4F6 |
"Nytt tilbud" | "0% gebyr denne uken!" | Low |
4.1 Priority Levels
| Priority | Behavior | Push | In-App |
|---|---|---|---|
| High | Immediate delivery, sound, badge | Yes (when implemented) | Top of list, bold styling |
| Normal | Standard delivery | Yes (when implemented) | Normal styling |
| Low | Batched delivery | Optional (user preference) | Normal styling |
5. Deep Link Routing Table (Planned)
| Notification Type | Deep Link Target | Web Route | Mobile Route |
|---|---|---|---|
transaction_complete |
Transaction detail | /transactions?id={txId} |
/(tabs)/history?id={txId} |
qr_payment |
Transaction detail | /transactions?id={txId} |
/(tabs)/history?id={txId} |
security |
Security settings | /profile/security |
/(tabs)/profile |
rate_update |
Send money (with rate) | /send |
/(tabs)/send |
system |
Notification center | /notifications |
/notifications |
promotional |
Landing or feature page | / or feature URL |
App home |
5.1 Deep Link Format
| Platform | Format | Example |
|---|---|---|
| Web | URL path | https://getdrop.no/transactions?id=tx_rem_1 |
| Mobile (planned) | Custom scheme | drop://transactions/tx_rem_1 |
| Expo push data | JSON payload | { "type": "transaction_complete", "targetId": "tx_rem_1" } |
6. Permission Handling
6.1 Notification Permission Flow
| Platform | Permission Request | First Time | Denied | Settings Redirect |
|---|---|---|---|---|
| iOS (Expo) | Notifications.requestPermissionsAsync() |
System dialog: "Drop would like to send you notifications" | Returns { status: 'denied' } |
Linking.openSettings() |
| Android (Expo) | Notifications.requestPermissionsAsync() |
System dialog (Android 13+): "Allow Drop to send you notifications?" | Returns { status: 'denied' } |
Linking.openSettings() |
| Web | Notification.requestPermission() |
Browser prompt: "getdrop.no wants to show notifications" | Blocked; user must reset in browser settings | Site settings via address bar |
6.2 Permission State Machine
stateDiagram-v2
[*] --> NotDetermined: First launch
NotDetermined --> Requesting: App requests permission
Requesting --> Granted: User allows
Requesting --> Denied: User denies
Granted --> Active: Push token registered
Active --> Disabled: User toggles off in Drop settings
Disabled --> Active: User toggles on in Drop settings
Denied --> SettingsRedirect: App shows "enable in settings" prompt
SettingsRedirect --> Granted: User enables in OS settings
SettingsRedirect --> Denied: User keeps disabled
7. Notification Preferences (Settings Integration)
Users control notification delivery via /profile/notifications:
| Setting | API Field | Effect |
|---|---|---|
| Push notifications ON | pushEnabled: true |
Push tokens active, notifications delivered |
| Push notifications OFF | pushEnabled: false |
Push tokens retained but not used for delivery |
| Email notifications ON | emailEnabled: true |
Email alerts sent for high-priority events |
| Email notifications OFF | emailEnabled: false |
No email alerts |
API: PATCH /api/settings { pushEnabled: boolean, emailEnabled: boolean }
8. Time Formatting
| Condition | Format | Example |
|---|---|---|
| Today | "I dag kl. HH:MM" | "I dag kl. 14:32" |
| Yesterday | "I gar kl. HH:MM" | "I gar kl. 18:45" |
| Older | "DD.MM.YYYY kl. HH:MM" | "19.02.2026 kl. 09:00" |
9. Platform Differences
| Feature | Web | Mobile |
|---|---|---|
| Notification center | /notifications page with BottomNav |
Not implemented |
| Bell icon badge | Dashboard header (unread count) | Not implemented |
| Push notifications | Not applicable (web push planned) | Not implemented (Expo Push planned) |
| Auto-read on view | Yes (marks all unread as read on page load) | N/A |
| Deep linking | URL-based routing | Not configured |
| Notification grouping | Date-based (I DAG, I GAR) | N/A |
| Permission handling | Browser Notification API | Expo Notifications API |
10. Data Schema
10.1 Notifications Table
| Column | Type | Description |
|---|---|---|
id |
TEXT PK | Format: noti_ + 16 hex chars |
user_id |
TEXT FK | References users(id) |
type |
TEXT | transaction_complete, qr_payment, security, rate_update |
title |
TEXT | Notification title (Norwegian) |
body |
TEXT | Notification body text |
read |
INTEGER | 0 = unread, 1 = read |
created_at |
TEXT | ISO timestamp |
10.2 API Endpoints
| Method | Endpoint | Purpose |
|---|---|---|
| GET | /api/notifications |
List all notifications for user |
| PATCH | /api/notifications |
Mark notifications as read (max 100 IDs) |
11. Accessibility Considerations (WCAG 2.1 AA)
| Requirement | Implementation |
|---|---|
| Badge count | Bell icon badge uses aria-label "X uleste varsler" |
| Read/unread | Unread dot uses both visual indicator (blue dot) and aria attributes |
| Notification list | Semantic list markup with role="list" |
| Empty state | Descriptive text "Ingen varsler enna" with Bell icon |
| Time formatting | Relative time ("I dag kl. 14:32") for recent, absolute for older |
| Auto-read | Fire-and-forget PATCH does not interrupt user reading |
| Push permission | Clear explanation before requesting system permission |
12. Cross-References
- Notifications API:
GET/PATCH /api/notifications— See API Reference - Settings API:
PATCH /api/settings(push/email toggles) — See API Reference - Notifications schema:
notificationstable — See Database Schema - Settings schema:
settingstable — See Database Schema - Component overview: See component-overview.md
- Figma notifications screen:
mockups/figma-make-export/src/app/screens/Notifications.tsx - Web notifications page:
src/drop-app/src/app/notifications/page.tsx— See PAGES.md - Profile settings flow: See flow-profile-settings.md
Low-Level Design Document
Low-Level Design Document
Project: Drop Module/Component: Transactions Module (Remittance + QR Payment) Version: 1.0 Date: 2026-02-23 Author: Petter Graff, Senior Enterprise Architect Status: Approved Reviewers: Alem Bašić (CEO), John (AI Director) Related HLD: HLD Document
Document History
| Version | Date | Author | Changes |
|---|---|---|---|
| 0.1 | 2026-02-21 | Banking Architecture Team | Initial draft from source code |
| 1.0 | 2026-02-23 | Petter Graff | Filled with real Drop data |
1. Module Overview
Module: transactions
Service/Repo: drop-api — src/drop-api/src/routes/transactions.ts
Team Owner: ALAI — Backend
Single Responsibility: Processes all financial operations — remittance (international money transfer via PISP) and QR payments (domestic merchant payments via PISP) — using the PSD2 pass-through model where Drop never holds funds.
Boundaries:
- Owns: Transaction records (
transactionstable), exchange rates (exchange_ratestable), fee calculation, pre-payment disclosure, idempotency enforcement, PISP payment initiation orchestration - Does NOT own: User authentication (auth module), recipient management (
recipientstable owned by recipients route), bank account balance display (bank_accounts route / AISP), merchant registration (merchants route) - Delegates to: BankID auth middleware (JWT validation), Open Banking PISP API (actual payment execution), audit_log (compliance side-effect), notifications (user alerting)
Key Business Rules:
- Drop never deducts money from the Drop DB balance except as a cached AISP value — all real deductions happen at the user's bank via PISP
- Every transaction requires
kyc_status = 'approved'on the initiating user - Remittance amounts: 100 NOK minimum, 50,000 NOK maximum; fee = 0.5% of send amount
- QR payments: fee = merchant
fee_rate(default 1%); validated via HMAC QR code idempotency_key(unique index ontransactions) prevents double-charging on retry
2. Class / Module Diagram
classDiagram
class TransactionsRoute {
-authMiddleware: Middleware
-rateLimiter: Middleware
+POST /v1/transactions/remittance(body: RemittanceDto): Response
+POST /v1/transactions/qr-payment(body: QRPaymentDto): Response
+POST /v1/transactions/disclosure(body: DisclosureDto): Response
+GET /v1/transactions(query: TransactionFilter): Response
+GET /v1/transactions/:id(): Response
-validateRemittanceInput(dto: RemittanceDto): void
-validateQRPaymentInput(dto: QRPaymentDto): void
}
class RemittanceDto {
+recipientId: string
+amount: number
+bankAccountId: string
+currency: string
}
class QRPaymentDto {
+merchantId: string
+amount: number
+qrData: string
}
class Transaction {
+id: string
+user_id: string
+type: "remittance" | "qr_payment"
+status: "processing" | "completed" | "failed"
+amount: number
+currency: string
+fee: number
+recipient_id: string
+merchant_id: string
+exchange_rate: number
+send_amount: number
+receive_amount: number
+receive_currency: string
+idempotency_key: string
+created_at: string
}
class Database {
<<abstraction>>
+query(sql, params): T[]
+getOne(sql, params): T
+run(sql, params): RunResult
+transaction(fn): void
}
class PISPClient {
<<external>>
+initiatePayment(paymentRequest): PaymentResponse
+getPaymentStatus(paymentId): PaymentStatus
}
TransactionsRoute --> RemittanceDto
TransactionsRoute --> QRPaymentDto
TransactionsRoute --> Transaction
TransactionsRoute --> Database
TransactionsRoute --> PISPClient
3. Database Schema
3.1 Tables
transactions
Purpose: Records all financial operations. Append-only — status updates are the only writes after creation.
| Column | Type | Nullable | Default | Constraints | Description |
|---|---|---|---|---|---|
id |
TEXT |
NO | — | PK, format: tx_<hex16> |
Transaction identifier |
user_id |
TEXT |
NO | — | FK → users(id) |
Initiating user |
type |
TEXT |
NO | — | CHECK('remittance','qr_payment') | Transaction type |
status |
TEXT |
NO | 'processing' |
CHECK('processing','completed','failed') | Payment status |
amount |
REAL |
NO | — | NOT NULL | Send amount in NOK (stored in øre equivalent) |
currency |
TEXT |
YES | 'NOK' |
— | Source currency (always NOK at MVP) |
fee |
REAL |
YES | 0 |
— | Fee in NOK (0.5% remittance, merchant rate for QR) |
recipient_id |
TEXT |
YES | NULL | FK → recipients(id) |
For remittances; NULL for QR |
merchant_id |
TEXT |
YES | NULL | FK → merchants(id) |
For QR payments; NULL for remittance |
send_amount |
REAL |
YES | NULL | — | Amount sent in source currency |
receive_amount |
REAL |
YES | NULL | — | Amount received in destination currency |
receive_currency |
TEXT |
YES | NULL | — | Destination currency (e.g., RSD, EUR) |
exchange_rate |
REAL |
YES | NULL | — | Exchange rate at time of transaction |
description |
TEXT |
YES | NULL | — | Optional user-provided description |
idempotency_key |
TEXT |
YES | NULL | UNIQUE | Prevents duplicate payments on retry |
created_at |
TEXT |
NO | datetime('now') |
— | Transaction timestamp |
Indexes:
| Index Name | Columns | Type | Rationale |
|---|---|---|---|
transactions_pkey |
id |
B-tree (PK) | Primary key lookup |
idx_transactions_user |
user_id |
B-tree | Filter all transactions per user (high frequency) |
idx_tx_idempotency |
idempotency_key |
Unique B-tree | Prevent duplicate payment on API retry |
idx_transactions_recipient |
recipient_id |
B-tree | Lookup by recipient |
idx_transactions_merchant |
merchant_id |
B-tree | Lookup by merchant |
exchange_rates
Purpose: Stores current NOK-to-foreign currency exchange rates for the 6 supported remittance corridors.
| Column | Type | Nullable | Default | Constraints | Description |
|---|---|---|---|---|---|
id |
INTEGER |
NO | auto | PK | Surrogate key |
from_currency |
TEXT |
NO | — | NOT NULL | Always 'NOK' at MVP |
to_currency |
TEXT |
NO | — | NOT NULL | Target currency (RSD, BAM, PLN, PKR, TRY, EUR) |
rate |
REAL |
NO | — | NOT NULL | Exchange rate: 1 NOK = N target currency units |
updated_at |
TEXT |
YES | — | — | Last rate update timestamp |
Indexes:
| Index Name | Columns | Type | Rationale |
|---|---|---|---|
idx_rates_currency |
from_currency, to_currency |
Composite B-tree | Fast rate lookup by currency pair |
3.2 Enums (CHECK constraints in SQLite, native ENUMs in PostgreSQL migration)
-- transaction type
CHECK(type IN ('remittance', 'qr_payment'))
-- transaction status
CHECK(status IN ('processing', 'completed', 'failed'))
3.3 Migration Notes
- Migration: Included in
db.tsinitializeDatabase()— runs on startup (SQLite) or via separate migration script (PostgreSQL) - Zero-downtime: YES — only
INSERTandUPDATE statusneeded;CREATE INDEX CONCURRENTLYfor PostgreSQL - Backfill required: NO — new tables
- Estimated migration time: < 1 second (SQLite), < 5 seconds (PostgreSQL)
4. API Contract
Base Path: /v1/transactions
POST /v1/transactions/remittance
Summary: Initiate international money transfer (PISP) from user's bank account to a saved recipient
Authentication: Bearer JWT required (authMiddleware)
Rate Limit: 10 req/60s per IP + 3 req/60s per user
Request Body:
{
"recipientId": "rec_abc123def456gh78",
"amount": 2000,
"bankAccountId": "ba_abc123def456gh78",
"currency": "NOK"
}
Success Response — 201 Created:
{
"data": {
"id": "tx_rem_abc123def456gh78",
"type": "remittance",
"status": "processing",
"amount": 2000,
"fee": 10,
"receiveAmount": 20340,
"receiveCurrency": "RSD",
"exchangeRate": 10.17,
"estimatedDelivery": "2-4 business days",
"scaRedirect": "https://dnb.no/sca/pay/abc123",
"createdAt": "2026-02-23T10:00:00.000Z"
}
}
Error Responses:
| Status | Code | Description |
|---|---|---|
400 |
validation_error |
Missing or invalid fields (amount, recipientId) |
401 |
unauthorized |
Missing or expired JWT |
403 |
kyc_required |
User kyc_status is not approved |
403 |
insufficient_balance |
Cached AISP balance < amount + fee |
404 |
recipient_not_found |
recipientId does not belong to this user |
409 |
duplicate_transaction |
Idempotency key collision — returns existing transaction |
422 |
amount_out_of_range |
Amount < 100 NOK or > 50,000 NOK |
429 |
rate_limited |
Exceeded 10 req/60s per IP or 3 req/60s per user |
502 |
pisp_unavailable |
Open Banking PISP API unreachable |
500 |
internal_error |
Unexpected server error |
POST /v1/transactions/qr-payment
Summary: Initiate QR merchant payment (PISP) from user's bank account
Authentication: Bearer JWT required
Request Body:
{
"merchantId": "mer_abc123def456gh78",
"amount": 450
}
Success Response — 201 Created:
{
"data": {
"id": "tx_qr_abc123def456gh78",
"type": "qr_payment",
"status": "completed",
"amount": 450,
"fee": 4.5,
"merchantName": "Café Oslo AS",
"createdAt": "2026-02-23T10:00:00.000Z"
}
}
Error Responses:
| Status | Code | Description |
|---|---|---|
400 |
validation_error |
Missing or invalid fields |
401 |
unauthorized |
Missing or expired JWT |
403 |
kyc_required |
User kyc_status not approved |
404 |
merchant_not_found |
merchantId not found or inactive |
500 |
internal_error |
Unexpected error |
POST /v1/transactions/disclosure
Summary: Pre-payment disclosure — returns fee, exchange rate, receive amount (PSD2 Art. 45/46 compliance)
Authentication: Bearer JWT required
Request Body:
{
"type": "remittance",
"amount": 2000,
"recipientId": "rec_abc123def456gh78"
}
Success Response — 200 OK:
{
"data": {
"sendAmount": 2000,
"sendCurrency": "NOK",
"fee": 10,
"feePercentage": 0.5,
"exchangeRate": 10.17,
"receiveAmount": 20340,
"receiveCurrency": "RSD",
"totalCost": 2010,
"estimatedDelivery": "2-4 business days"
}
}
GET /v1/transactions
Summary: List authenticated user's transactions with pagination
Query Parameters:
| Parameter | Type | Default | Description |
|---|---|---|---|
page |
integer |
1 |
Page number (1-based) |
limit |
integer |
20 |
Items per page (max 50) |
type |
string |
— | Filter: remittance or qr_payment |
status |
string |
— | Filter: processing, completed, failed |
Success Response — 200 OK:
{
"data": {
"transactions": [
{
"id": "tx_rem_abc123",
"type": "remittance",
"status": "completed",
"amount": 2000,
"fee": 10,
"receiveAmount": 20340,
"receiveCurrency": "RSD",
"recipientName": "Marko Petrovic",
"createdAt": "2026-02-23T10:00:00.000Z"
}
],
"total": 15,
"page": 1,
"limit": 20
}
}
5. Algorithm Specifications
5.1 Fee Calculation — Remittance
Purpose: Calculate 0.5% fee on remittance, rounded to 2 decimal places Complexity: Time O(1) | Space O(1)
function calculateRemittanceFee(sendAmountNOK: number): number
FEE_RATE = 0.005 // 0.5%
fee = sendAmountNOK * FEE_RATE
return Math.round(fee * 100) / 100 // Round to 2 decimal places
function calculateReceiveAmount(sendAmountNOK: number, exchangeRate: number): number
netSend = sendAmountNOK // Fee taken from send amount, not receive
receive = netSend * exchangeRate
return Math.round(receive) // Round to whole units of target currency
Edge Cases:
- Minimum amount: 100 NOK → fee = 0.50 NOK
- Maximum amount: 50,000 NOK → fee = 250 NOK
- Rate not found: Return 404 — do not proceed to transaction
5.2 Idempotency Key Generation
Purpose: Prevent double-charging on network retry or duplicate form submission
Format: {userId}:{amount}:{recipientId}:{minuteTimestamp}
function generateIdempotencyKey(userId, amount, recipientId): string
minuteTimestamp = Math.floor(Date.now() / 60000) // Changes every 60s
key = `${userId}:${amount}:${recipientId}:${minuteTimestamp}`
return key
// Unique index on transactions.idempotency_key prevents duplicate
// If INSERT fails with UNIQUE constraint → return existing transaction
6. Sequence Diagrams
6.1 Remittance Initiation Flow
sequenceDiagram
autonumber
actor Client as Client (Web/Mobile)
participant RL as Rate Limiter
participant Auth as Auth Middleware
participant Route as Transactions Route
participant DB as Database
participant PISP as Open Banking PISP
Client->>RL: POST /v1/transactions/remittance
RL->>RL: Check rate_limits (10/IP, 3/user per 60s)
alt Rate limit exceeded
RL-->>Client: 429 Too Many Requests
end
RL->>Auth: Forward request
Auth->>Auth: Extract JWT from Bearer header / cookie
Auth->>DB: SELECT session WHERE token_hash = ? AND revoked = 0
Auth->>DB: SELECT user WHERE id = ? AND deleted_at IS NULL
Auth-->>Route: user context {userId, role, kycStatus}
Route->>Route: Validate body: recipientId, amount (100-50000), bankAccountId
alt Validation fails
Route-->>Client: 400 validation_error
end
alt kyc_status != 'approved'
Route-->>Client: 403 kyc_required
end
Route->>DB: SELECT * FROM recipients WHERE id = ? AND user_id = ?
alt Recipient not found
Route-->>Client: 404 recipient_not_found
end
Route->>DB: SELECT rate FROM exchange_rates WHERE to_currency = ?
Route->>DB: SELECT * FROM bank_accounts WHERE id = ? AND user_id = ? AND is_primary = 1
Route->>Route: Calculate fee (0.5%), total cost, receive amount
alt balance < totalCost
Route-->>Client: 403 insufficient_balance
end
Route->>DB: BEGIN TRANSACTION
Route->>DB: UPDATE bank_accounts SET balance = balance - totalCostInOere WHERE balance >= ?
Route->>DB: INSERT INTO transactions (status='processing', idempotency_key=?)
Route->>DB: INSERT INTO audit_log (action='transaction.create')
Route->>DB: INSERT INTO notifications (title='Overføring startet')
Route->>DB: COMMIT
Route->>PISP: POST /v1/payments/cross-border-credit-transfers
PISP-->>Route: {paymentId, transactionStatus: "RCVD", scaRedirect}
Route-->>Client: 201 {transactionId, status: "processing", scaRedirect}
6.2 QR Payment Flow
sequenceDiagram
autonumber
actor Client as Client (Mobile)
participant Route as Transactions Route
participant DB as Database
Client->>Route: POST /v1/transactions/qr-payment {merchantId, amount}
Route->>DB: Verify JWT session
Route->>DB: SELECT * FROM merchants WHERE id = ? AND status = 'active'
alt Merchant not found
Route-->>Client: 404 merchant_not_found
end
Route->>DB: SELECT * FROM bank_accounts WHERE user_id = ? AND is_primary = 1
Route->>Route: Calculate fee = amount * merchant.fee_rate
Route->>Route: Calculate total = amount + fee
Route->>DB: BEGIN TRANSACTION
Route->>DB: UPDATE bank_accounts SET balance = balance - total
Route->>DB: INSERT INTO transactions (type='qr_payment', status='completed')
Route->>DB: INSERT INTO audit_log (action='qr_payment.create')
Route->>DB: INSERT INTO notifications (title='Betaling registrert')
Route->>DB: COMMIT
Route-->>Client: 201 {transactionId, status: "completed", merchantName}
7. State Diagrams
stateDiagram-v2
[*] --> processing: POST /v1/transactions/remittance (PISP initiated)
processing --> completed: PISP webhook — payment confirmed by bank
processing --> failed: PISP webhook — payment rejected (insufficient funds, SCA timeout, SCA cancelled)
processing --> failed: 5-minute SCA timeout — no callback received
completed --> [*]
failed --> [*]
Note: QR payments go directly from creation to completed (synchronous in MVP — no PISP webhook for domestic transfers in mock mode).
State Transition Rules:
| From | To | Trigger | Guard Condition | Side Effect |
|---|---|---|---|---|
| (none) | processing | POST /v1/transactions/remittance | KYC approved, balance sufficient | Deduct cached balance, create audit log, send notification |
| processing | completed | PISP webhook or QR sync completion | paymentId matches transaction | Update status, send completion notification |
| processing | failed | PISP webhook rejection or 5-min timeout | paymentId matches, status RJCT | Restore cached balance (re-sync AISP), send failure notification |
8. Error Handling Strategy
8.1 Error Classification
| Error Type | HTTP Status | Retry? | Log Level | Alert? |
|---|---|---|---|---|
| ValidationError | 400 | No | INFO | No |
| UnauthorizedError | 401 | No | WARN | No |
| KYCRequired | 403 | No | INFO | No |
| InsufficientBalance | 403 | No | INFO | No |
| RecipientNotFound | 404 | No | INFO | No |
| DuplicateTransaction | 409 | No | INFO | No |
| AmountOutOfRange | 422 | No | INFO | No |
| RateLimited | 429 | After Retry-After | WARN | No |
| PISPUnavailable | 502 | Yes (3x backoff) | ERROR | Yes (if sustained > 5 min) |
| DatabaseError | 500 | Yes (1x) | ERROR | Yes |
| UnexpectedError | 500 | No | ERROR | Yes |
8.2 Error Response Format
{
"error": "kyc_required",
"message": "Du må fullføre identitetsverifisering før du kan sende penger.",
"details": []
}
8.3 Retry & Fallback Strategy
PISP API call failure:
→ Retry with exponential backoff: [1s, 2s, 4s]
→ Max retries: 3
→ Circuit breaker: Open after 3 failures in 60s window → 60s cooldown
→ Fallback: Return 502 to client — payment cannot proceed without PISP
→ Alert: Sentry alert if circuit remains open > 5 minutes
→ Idempotency: PISP call uses X-Request-ID = idempotency_key to prevent double-payment on retry
9. Concurrency & Thread Safety
| Concern | Scenario | Mitigation |
|---|---|---|
| Double payment | Client retries POST /v1/transactions/remittance after network timeout | Unique index on idempotency_key — second INSERT fails with UNIQUE constraint → return existing transaction |
| Balance race condition | Two simultaneous payments from same account | DB transaction with UPDATE bank_accounts SET balance = balance - X WHERE balance >= X — atomic check-and-deduct |
| Exchange rate staleness | Rate changes between disclosure and payment | Rate locked at payment initiation time; user sees pre-payment disclosure; rate used is from DB at payment time |
10. Performance Considerations
| Operation | Target (p99) | Current Baseline | Optimization |
|---|---|---|---|
POST /v1/transactions/remittance |
< 500ms (local) + PISP latency | ~50ms DB operations | DB transaction atomic; PISP call is async from user perspective |
GET /v1/transactions |
< 100ms | ~20ms | Index idx_transactions_user on user_id; pagination limits result set |
POST /v1/transactions/disclosure |
< 50ms | ~10ms | Two DB reads (recipient + exchange rate); no external API call |
POST /v1/transactions/qr-payment |
< 200ms | ~40ms | Synchronous completion in mock mode; PISP async in production |
Known bottlenecks:
- PISP API latency: 200-2000ms external call — mitigated by async SCA redirect pattern
- Exchange rate reads: High frequency but 6 rows only — fully cached in PostgreSQL buffer pool
11. Dependencies
Internal Dependencies
| Dependency | Type | Purpose | Fallback if unavailable |
|---|---|---|---|
middleware/auth.ts |
Synchronous | JWT validation + user context | None — request rejected with 401 |
middleware/rate-limit.ts |
Synchronous | IP + user rate limiting | None — request rejected with 429 |
lib/db.ts |
Required | All data access (query, run, transaction) | None — module unavailable |
External Dependencies
| Dependency | Version | Purpose | Fallback if unavailable |
|---|---|---|---|
| PostgreSQL | 16 | Primary data store | SQLite (dev only) |
| Open Banking PISP (Neonomics/ASPSP) | Berlin Group v1.3.12+ | Payment initiation | None — return 502, payment cannot proceed |
| Open Banking AISP | Berlin Group v1.3.12+ | Pre-payment balance verification | Use cached bank_accounts.balance with staleness warning |
12. Configuration Parameters
| Variable | Type | Default | Required | Description |
|---|---|---|---|---|
DATABASE_URL |
string |
— | No (SQLite default) | PostgreSQL connection string |
NEXT_PUBLIC_SERVICE_MODE |
string |
mock |
No | mock = simulate PISP; production = real PISP calls |
OPEN_BANKING_API_URL |
string |
— | Yes (prod) | Neonomics or ASPSP base URL |
OPEN_BANKING_CLIENT_ID |
string |
— | Yes (prod) | eIDAS client identifier |
OPEN_BANKING_CLIENT_SECRET |
string |
— | Yes (prod) | eIDAS client secret |
13. Testing Approach
| Test Type | Tool | Coverage Target | Location |
|---|---|---|---|
| Unit tests | Vitest | > 80% business logic | src/drop-api/src/__tests__/unit/transactions/ |
| Integration tests | Supertest | Key payment flows | src/drop-api/src/__tests__/integration/transactions/ |
Key test scenarios:
- Remittance — success path (mock PISP)
- Remittance — KYC not approved → 403
- Remittance — amount < 100 NOK → 422
- Remittance — amount > 50,000 NOK → 422
- Remittance — recipient not found → 404
- Remittance — duplicate request (idempotency key) → 409 with existing transaction
- QR payment — success path
- QR payment — merchant inactive → 404
- Disclosure — calculates fee, exchange rate, receive amount correctly
- PISP circuit breaker — 3 failures → open → 502 (integration test, Phase 2)
- Concurrent payment — balance race condition handled correctly (Phase 2)
Approval
| Role | Name | Date | Signature |
|---|---|---|---|
| Author | Petter Graff | 2026-02-23 | |
| Module Owner | John (AI Director) | ||
| Security Review | |||
| Tech Lead | John (AI Director) |
LLD: Withdrawal Flow
Withdrawal Request Flow (Angrerett)
Purpose
Implements the user's right of withdrawal (angrerett) as required by Norwegian consumer protection law (angrerettloven). Users can submit a withdrawal request to cancel their account or service agreement within the statutory cooling-off period.
Sequence Diagram
sequenceDiagram
participant U as User (App)
participant API as Drop API
participant Auth as Auth Middleware
participant DB as PostgreSQL
participant Audit as Audit Log
U->>API: POST /withdrawal { reason, comment }
API->>Auth: Validate JWT token
Auth-->>API: user context
alt Invalid JSON body
API-->>U: 400 bad_request
end
API->>API: Sanitize reason (max 100 chars)
API->>API: Sanitize comment (max 1000 chars)
API->>API: Validate reason against VALID_REASONS
alt Invalid reason
API-->>U: 400 validation_error
end
API->>DB: INSERT INTO withdrawal_requests (id, user_id, reason, comment)
DB-->>API: OK
API->>Audit: Log WITHDRAWAL_REQUEST action
Audit->>DB: INSERT INTO audit_log
API-->>U: 201 { success: true, id }
Database Schema
withdrawal_requests table
| Column | Type | Constraints |
|---|---|---|
| id | TEXT | PRIMARY KEY (prefix: wr_) |
| user_id | TEXT | NOT NULL, REFERENCES users(id) |
| reason | TEXT | Nullable |
| comment | TEXT | Nullable |
| status | TEXT | DEFAULT 'pending', CHECK IN ('pending','processing','completed','rejected') |
| created_at | TIMESTAMPTZ | DEFAULT NOW() |
Index: idx_withdrawal_requests_user on user_id.
Valid Withdrawal Reasons
| Value | Description |
|---|---|
not_needed |
User no longer needs the service |
alternative |
User found an alternative service |
not_satisfied |
User is not satisfied with the service |
other |
Other reason (details in comment field) |
"" (empty) |
No reason provided |
Request Processing
- Authentication -- request must include a valid JWT token (authMiddleware).
- Input validation -- reason is checked against the allowlist; both reason and comment are sanitized via
sanitizeTextwith length limits. - Record creation -- a new
withdrawal_requestsrow is inserted with statuspending. - Audit logging -- an audit log entry is created with action
WITHDRAWAL_REQUEST, including the reason and the requester's IP address.
Status Lifecycle
pending --> processing --> completed
\-> rejected
- pending -- initial state after user submits request.
- processing -- staff/admin has begun reviewing the request.
- completed -- withdrawal has been executed, account closed or service cancelled.
- rejected -- request was denied (e.g., outside cooling-off period, regulatory hold).
Error States
| Scenario | HTTP Status | Error Code |
|---|---|---|
| Missing/invalid JWT | 401 | unauthorized |
| Malformed JSON body | 400 | bad_request |
| Invalid reason value | 400 | validation_error |
| Database write failure | 500 | internal_error |
Edge Cases
- Duplicate requests -- no uniqueness constraint on user_id; a user can submit multiple withdrawal requests. Business logic should handle deduplication at the review stage.
- Already deleted user -- the foreign key on user_id ensures the user must exist. If the user record has
deleted_atset, the auth middleware should reject the request before it reaches this route. - AML retention -- even after withdrawal is completed, transaction records and AML-related data must be retained for 5 years per hvitvaskingsloven. The data retention cron (
/cron/retention) handles anonymization after the retention period expires.
Cross-References
- Angrerettloven -- Norwegian Act on the Right of Withdrawal (consumer protection).
- Data retention -- See
src/drop-api/src/routes/cron.tsretention endpoint anddocs/architecture/lld/flow-kyc-aml.mdfor AML retention requirements. - Audit logging -- See
src/drop-api/src/lib/audit.tsfor audit log implementation.
LLD: Middleware Lifecycle Flow
Middleware Lifecycle — Low-Level Design
Document: LLD-MIDDLEWARE
Status: Approved
Last updated: 2026-02-21
Author: Standards Architect
Applies to: Drop API (Hono) — src/drop-api/src/app.ts
Overview
The Drop API uses Hono as its HTTP framework. Middleware is organized into two layers: global middleware applied to every request via app.use("*"), and per-route middleware applied within individual route handlers. This document describes the complete execution order.
Source of truth: src/drop-api/src/app.ts
Middleware Execution Order
flowchart TD
A["Incoming HTTP Request"] --> B["1. CORS Middleware\n(global)"]
B --> C["2. Request ID Middleware\n(global)"]
C --> D["3. Client IP Middleware\n(global)"]
D --> E["4. Route Matching\n(/v1/* or /api/*)"]
E --> F{Route found?}
F -->|No| G["404 Not Found"]
F -->|Yes| H["5. Per-Route Middleware\n(auth / rateLimit / featureGate)"]
H --> I["6. Route Handler"]
I --> J["Response"]
I -.->|Error thrown| K["Global Error Handler\n(app.onError)"]
K --> J
style A fill:#f5f5f5,stroke:#333
style B fill:#ffd93d,stroke:#333
style C fill:#ffd93d,stroke:#333
style D fill:#ffd93d,stroke:#333
style H fill:#6bcb77,stroke:#333
style I fill:#4d96ff,stroke:#333,color:#fff
style K fill:#ff6b6b,stroke:#333,color:#fff
Global Middleware (Applied to Every Request)
These are registered in app.ts with app.use("*") and execute in registration order, top-to-bottom.
1. CORS (hono/cors)
Source: app.ts:23-30
Configures Cross-Origin Resource Sharing headers for browser-based clients.
| Setting | Value |
|---|---|
| Allowed origins | http://localhost:3000, http://localhost:3001, process.env.APP_URL |
| Credentials | true (cookies sent cross-origin) |
The credentials: true setting is required because the web app sends JWT tokens in httpOnly cookies. Empty strings from unset env vars are filtered out.
2. Request ID
Source: app.ts:33-38
Generates or propagates a unique request identifier for distributed tracing.
| Behavior | Detail |
|---|---|
| Header checked | x-request-id |
| Fallback | crypto.randomUUID() |
| Context variable | c.get("requestId") |
| Response header | x-request-id (echoed back) |
Downstream middleware and route handlers access the request ID via c.get("requestId") for structured logging and audit trails.
3. Client IP
Source: app.ts:41-47
Extracts the originating client IP address from proxy headers.
| Priority | Header | Processing |
|---|---|---|
| 1st | x-real-ip |
Trimmed |
| 2nd | x-forwarded-for |
First entry, trimmed |
| Fallback | — | 127.0.0.1 |
The extracted IP is stored as c.get("clientIp") and used by rate limiting and audit logging.
Note: The rate-limit.ts module also exports a getClientIp(c) helper that performs the same extraction. Some route handlers use getClientIp(c) directly instead of c.get("clientIp").
4. Global Error Handler
Source: app.ts:50, middleware/error-handler.ts:16-23
Registered via app.onError(globalErrorHandler). This is not middleware in the traditional sense — it is an error boundary that catches any unhandled exceptions thrown during request processing.
| Error Type | Response |
|---|---|
HTTPException (Hono) |
Returns the exception's status and message |
| All other errors | Logs via logger.error, reports to Sentry via captureError, returns 500 Internal Server Error with generic message |
The error handler never leaks stack traces or internal details to the client.
Route Mounting
Source: app.ts:53-72
All API routes are mounted under a versioned prefix:
| Mount Point | Purpose |
|---|---|
/v1/* |
Primary API path (mobile + new clients) |
/api/* |
Backward compatibility during migration |
Both mount points serve the identical route handlers — /api is an alias for /v1.
Mounted Route Groups
| Path | Route Module | Primary Middleware |
|---|---|---|
/v1/auth |
authRoutes |
Rate limiting (inline) |
/v1/health |
healthRoutes |
None (public) |
/v1/transactions |
transactionRoutes |
authMiddleware + rate limiting |
/v1/recipients |
recipientRoutes |
authMiddleware |
/v1/rates |
rateRoutes |
None (public) |
/v1/cards |
cardRoutes |
authMiddleware + feature gate |
/v1/merchants |
merchantRoutes |
merchantMiddleware |
/v1/settings |
settingsRoutes |
authMiddleware |
/v1/notifications |
notificationRoutes |
authMiddleware |
/v1/user |
userRoutes |
authMiddleware |
/v1/admin |
adminRoutes |
adminMiddleware + rate limiting |
/v1/consents |
consentRoutes |
authMiddleware |
/v1/complaints |
complaintRoutes |
authMiddleware |
/v1/cron |
cronRoutes |
Varies |
/v1/withdrawal |
withdrawalRoutes |
authMiddleware |
Per-Route Middleware
Per-route middleware is applied within individual route files, not globally. It executes after the global middleware chain.
Authentication Middleware (middleware/auth.ts)
Three variants, all following the same pattern: extract JWT, verify token + session, set c.set("user", ...).
| Middleware | Role Check | Used By |
|---|---|---|
authMiddleware |
Any authenticated user | Most routes (transactions, recipients, settings, etc.) |
merchantMiddleware |
role === 'merchant' |
Merchant routes |
adminMiddleware |
role === 'admin' |
Admin routes (audit, screening, STR) |
Flow:
- Extract bearer token from
Authorizationheader or cookie - Verify JWT signature (HS256) and check session in
sessionstable - If invalid or expired: return
401 Unauthorized - If role mismatch (merchant/admin variants): return
403 Forbidden - Set
c.set("user", authUser)for downstream handlers
Rate Limiting (middleware/rate-limit.ts)
Rate limiting is not a Hono middleware function — it is a utility called inline within route handlers.
// Example from transactions.ts
if (!(await rateLimit(ip, 10))) {
return c.json({ error: "rate_limited", message: "Too many requests" }, 429);
}
| Parameter | Description |
|---|---|
ip |
Rate limit key (usually client IP, sometimes user:{id}) |
limit |
Maximum requests per window |
windowMs |
Window duration in ms (default: 60000 = 1 minute) |
Rate limit state is persisted in the rate_limits database table (SQLite/PostgreSQL). Expired entries are cleaned up every 100 checks.
Per-endpoint limits:
| Endpoint | Key | Limit | Window |
|---|---|---|---|
POST /transactions/remittance |
IP | 10/min | 60s |
POST /transactions/remittance |
user:{id} |
3/min | 60s |
POST /transactions/qr-payment |
IP | 10/min | 60s |
POST /transactions/qr-payment |
user:{id} |
3/min | 60s |
GET /admin/audit |
IP | 30/min | 60s |
GET /admin/screening |
IP | 30/min | 60s |
POST /admin/screening |
IP | 10/min | 60s |
GET /admin/str |
IP | 30/min | 60s |
POST /admin/str |
IP | 10/min | 60s |
PATCH /admin/str |
IP | 10/min | 60s |
Feature Gates (lib/feature-flags.ts)
Feature gates control access to unreleased functionality. Like rate limiting, they are called inline within route handlers, not as Hono middleware.
// Example from cards.ts
if (!isEnabled("virtualCards")) {
return c.json({ error: "not_found", message: "Feature not available" }, 404);
}
| Flag | Default | Controls |
|---|---|---|
virtualCards |
false |
Card creation, listing, detail, cancellation |
physicalCards |
false |
Physical card ordering |
cardDetails |
false |
Card detail endpoint |
cardFreeze |
false |
Card freeze/unfreeze |
cardPin |
false |
Card PIN management |
spendingLimits |
false |
Spending limit management |
notifications |
true |
Notification endpoints |
merchantDashboard |
true |
Merchant dashboard |
Flags are read from environment variables (FF_VIRTUAL_CARDS=true) with fallback to compiled defaults. The featureGate() helper throws an HTTPException(404) for disabled features, which the global error handler catches.
Complete Request Lifecycle (Sequence Diagram)
sequenceDiagram
participant Client
participant CORS as CORS Middleware
participant ReqID as Request ID Middleware
participant IP as Client IP Middleware
participant Router as Hono Router
participant Auth as Auth Middleware
participant RL as Rate Limiter
participant FG as Feature Gate
participant Handler as Route Handler
participant DB as Database
participant ErrH as Error Handler
Client->>CORS: HTTP Request
CORS->>CORS: Check origin, set CORS headers
CORS->>ReqID: next()
ReqID->>ReqID: Extract/generate x-request-id
ReqID->>IP: next()
IP->>IP: Extract client IP from headers
IP->>Router: next()
Router->>Router: Match route (/v1/* or /api/*)
alt Public route (health, rates)
Router->>Handler: Direct execution
else Authenticated route
Router->>Auth: authMiddleware / adminMiddleware / merchantMiddleware
Auth->>DB: Verify JWT + session
alt Token invalid
Auth-->>Client: 401 Unauthorized
else Token valid
Auth->>Auth: Set c.user
Auth->>RL: Check rate limit (inline)
alt Rate exceeded
RL-->>Client: 429 Too Many Requests
else Within limit
RL->>FG: Check feature flag (if applicable)
alt Feature disabled
FG-->>Client: 404 Feature not available
else Feature enabled
FG->>Handler: Execute route logic
Handler->>DB: Query/mutation
Handler-->>Client: JSON response
end
end
end
end
Note over Handler,ErrH: If any error is thrown
Handler-->>ErrH: Unhandled error
ErrH->>ErrH: Log + Sentry report
ErrH-->>Client: 500 Internal Server Error
Input Validation
Input validation is not middleware — it is a collection of utility functions in middleware/validation.ts called directly by route handlers.
| Function | Purpose | Used By |
|---|---|---|
sanitizeText(text, maxLength) |
Strip HTML tags, control characters, truncate | All text input fields |
validatePhone(phone) |
International phone format (+ prefix, 8-15 digits) |
User profile |
validateAmount(amount) |
Positive number, max 2 decimal places | Transactions |
validateIBAN(iban) |
ISO 13616 IBAN checksum validation | Bank accounts |
validatePIN(pin) |
Exactly 4 digits | Card PIN |
validateEmail(email) |
Basic email format | Registration |
validateCurrency(currency) |
Whitelist: EUR, USD, GBP, BAM, CHF, PLN, NOK, RSD, TRY, PKR | Transactions |
validateName(name) |
Non-empty, contains letters, no script injection | Recipients |
validateLanguage(lang) |
Whitelist: nb, en, bs, sq | Settings |
auditLog(...) |
Insert audit trail record | All significant actions |
Cross-References
- Security Architecture — Trust boundaries, STRIDE, application security controls
- Authentication — JWT, session management, BankID OIDC
- API Reference — Endpoint specifications and security requirements
- Login Authentication Flow — BankID OIDC authentication detail
Architecture Decision Records (ADR)
All architectural decisions and rationale
Architecture Decision Record — ADR-013
Architecture Decision Record — ADR-013
Project: Drop ADR Number: ADR-013 Title: Neonomics as Open Banking Aggregator for AISP/PISP Version: 1.0 Date: 2026-02-23 Author: Petter Graff, Senior Enterprise Architect Status: Proposed Reviewers: Alem Bašić (CEO), John (AI Director)
Document History
| Version | Date | Author | Changes |
|---|---|---|---|
| 0.1 | 2026-02-23 | Petter Graff | Initial draft |
ADR Numbering Scheme
Convention: ADR-{NNN}-{short-slug}.md — e.g., ADR-013-neonomics-open-banking-aggregator.md
Store in: docs/architecture/adr/
1. Context
1.1 Situation
Drop is a PSD2 pass-through payment application (ADR-003) that requires AISP (read bank balances) and PISP (initiate payments from user's bank) capabilities to function in production. The current MVP uses mock AISP/PISP — the NEXT_PUBLIC_SERVICE_MODE=mock flag returns simulated balances and payment confirmations without contacting real banks.
To go live (Phase 2), Drop must connect to the actual Open Banking APIs of Norwegian and Nordic banks (DNB, SpareBank 1, Nordea, Handelsbanken, etc.) using the Berlin Group NextGenPSD2 standard. Two strategic approaches exist: direct ASPSP integration (per-bank) or aggregator integration (single API covering all banks).
The previous planned provider, Swan (a BaaS provider), has been deprecated and removed from the codebase — it was not aligned with the PSD2 pass-through model (Swan offered embedded banking, not TPP-style AISP/PISP).
1.2 Forces & Constraints
Technical forces:
- Drop must integrate with 10+ Norwegian banks to provide meaningful coverage — each bank has slightly different PSD2 API implementations despite the Berlin Group standard
- eIDAS QWAC and QSeal certificates (required for direct ASPSP integration) take 4-8 weeks to obtain from Buypass or Commfides
- Drop's current backend is a single Hono API — minimal engineering bandwidth for multi-bank integration maintenance
Business forces:
- Time-to-market is critical; Phase 2 Open Banking MVP must launch within 3-4 months of Finanstilsynet license/agent arrangement
- Each direct bank integration requires a bilateral agreement and developer portal onboarding (2-6 weeks each)
- Revenue model depends on remittance transaction volume — delayed Open Banking = zero transaction revenue
Compliance & regulatory:
- Finanstilsynet PISP/AISP license not yet obtained; Drop may operate as an agent under a licensed PSP while license is pending
- GDPR: Data processed by aggregator requires a DPA (Data Processing Agreement) with the aggregator
- PSD2 RTS: SCA (Strong Customer Authentication) must be ASPSP-side — aggregator must support scaRedirect flow (not screen-scraping)
Existing decisions that constrain this:
- ADR-003: PSD2 pass-through model — constrains us to regulated TPP AISP/PISP, not BaaS wallet
- ADR-005: Monolith-first — constrains us to a single integration point, not microservice-per-bank
1.3 Problem Statement
We need to decide: Which Open Banking connectivity strategy should Drop adopt for Phase 2 production AISP/PISP — direct per-ASPSP integration or a single Open Banking aggregator?
2. Decision
We will: Use Neonomics as the primary Open Banking aggregator for both AISP (balance reads) and PISP (payment initiation) in the Norwegian and Nordic market.
Rationale (summary): Neonomics provides a single REST API endpoint covering 90%+ of Norwegian banks under the Berlin Group NextGenPSD2 standard, handles eIDAS certificate management and bank onboarding, and is a Norwegian company with strong local regulatory relationships — reducing Drop's time-to-market from 6-9 months (direct per-bank) to 6-8 weeks (aggregator contract + integration).
3. Alternatives Considered
Option A: Neonomics Aggregator ← Selected
Description: Contract with Neonomics (Norwegian Open Banking aggregator, Oslo HQ) for a single HTTPS REST API covering Norwegian + Nordic banks. Neonomics holds its own PISP/AISP registration with Finanstilsynet and eIDAS certificates — Drop operates as an agent or technology partner under Neonomics' regulatory umbrella initially.
Pros:
- Nordic focus: Deep coverage of DNB, SpareBank 1, Nordea, Sbanken, Handelsbanken, Skandiabanken (90%+ Norwegian market)
- Norwegian company: Strong FSA relationships, Norwegian language support, local regulatory expertise
- Single API: One integration maintains coverage across all banks — bank API updates handled by Neonomics
- eIDAS certs handled by Neonomics: Removes 4-8 week cert procurement from Drop's critical path
- Agent arrangement possible: Drop can operate under Neonomics' license while applying for own license
Cons:
- Per-transaction cost: Aggregator charges per API call / transaction — reduces margin compared to direct ASPSP integration at scale
- Data through third party: All AISP data transits Neonomics infrastructure — requires GDPR DPA
- Neonomics dependency: If Neonomics is acquired or raises prices, switching is a 2-3 month project
Cost/Effort: Contract negotiation 2-4 weeks; technical integration 2-4 weeks — total 6-8 weeks Risk: MEDIUM — Neonomics is a funded startup (not a Tier-1 bank); business continuity risk mitigated by Tink as backup
Option B: Direct Per-ASPSP Integration
Description: Integrate directly with each Norwegian bank's PSD2 developer portal: DNB API, SpareBank 1 Open Banking, Nordea Open Banking, etc. Each bank has its own onboarding process, bilateral agreement, and API specifics.
Pros:
- Lower per-transaction cost at scale: No aggregator margin once integrated
- Data stays bilateral: No third-party aggregator processes user financial data
- Full control: No dependency on aggregator's uptime or pricing
Cons:
- Time: Each bank takes 2-6 weeks to onboard — covering 5 banks = 10-30 weeks minimum
- Ongoing maintenance: Each bank independently updates their API — Drop must track all changes
- eIDAS certificates: Drop must obtain QWAC + QSeal independently (4-8 weeks, ~€3,000-5,000/year)
- Finanstilsynet license: Cannot make direct ASPSP calls without own license or agent arrangement — blocks Phase 2
Cost/Effort: 6-12 months engineering time + bank agreements + cert procurement Risk: HIGH — Timeline risk is critical; direct integration too slow for Phase 2 target
Why not chosen: Timeline incompatible with Phase 2 launch target; Direct integration is the long-term (Phase 3+) optimization once scale justifies the margin savings.
Option C: Tink (Visa) Aggregator
Description: Use Tink (acquired by Visa in 2022), the largest European Open Banking aggregator with 6,000+ banks across the EU.
Pros:
- Broadest coverage: 6,000+ banks — future-proof for any European expansion
- Visa backing: Financial stability, enterprise SLAs
Cons:
- Non-Norwegian HQ: Less Nordic specialization; support in English only
- Enterprise pricing: Higher minimum spend than Neonomics
- GDPR: Data processed in Sweden (EU) — adequate, but Neonomics processes in Norway
Cost/Effort: Similar to Neonomics — 6-8 weeks integration Why not chosen: Neonomics preferred for Phase 2 Norwegian launch due to local regulatory relationships and Norwegian bank specialization. Tink retained as Backup/Phase 3 (EU expansion) option.
Comparison Matrix
| Criterion | Weight | Option A: Neonomics (Selected) | Option B: Direct ASPSP | Option C: Tink |
|---|---|---|---|---|
| Time-to-market | 5 | 5 | 1 | 4 |
| Norwegian bank coverage | 5 | 5 | 3 | 4 |
| Per-transaction cost (3yr) | 3 | 3 | 5 | 3 |
| GDPR compliance complexity | 3 | 4 | 5 | 4 |
| Regulatory / license path | 4 | 5 | 2 | 4 |
| Vendor stability | 3 | 3 | 5 | 5 |
| Engineering effort | 4 | 5 | 1 | 4 |
| Weighted Total | 131 | 77 | 112 |
Score: 1 (poor) to 5 (excellent)
4. Consequences
4.1 Positive Consequences
- Phase 2 Open Banking live within 8 weeks of contract signing vs. 6-12 months for direct integration
- No eIDAS certificate management burden until Drop obtains its own Finanstilsynet license
- Single endpoint to maintain — bank API changes are Neonomics' responsibility
- Norwegian regulatory expertise from partner reduces compliance risk
4.2 Negative Consequences
- Aggregator per-transaction fee reduces remittance margin by ~0.1-0.3% at scale — Mitigation: Renegotiate pricing at 10K+ monthly transactions; plan direct integration for Phase 3
- GDPR DPA with Neonomics required before any AISP data can transit their infrastructure — Mitigation: DPA negotiated as part of commercial contract
- Vendor concentration risk — Mitigation: Document Tink integration as a 6-week fallback migration path
4.3 Neutral / Secondary Effects
- Drop's Hono API adds a single Neonomics client module (
lib/openbanking/neonomics.ts) — clean encapsulation means provider can be swapped - AISP and PISP are separate Neonomics API product lines — may have different pricing tiers
4.4 Technical Debt Created
- The
mock-swan.tsfile (deprecated Swan mock) must be removed and replaced with amock-openbanking.tscompatible with the Neonomics API schema — plan in Phase 2 sprint 1 - Acceptable because: Mock removal is low-risk (test infrastructure only) and unblocks clean integration
5. Compliance Impact
| Regulation | Impact | Notes |
|---|---|---|
| GDPR | MEDIUM | AISP balance data and PISP payment data transit Neonomics — GDPR DPA required; Neonomics is EU/EEA entity |
| PSD2 (Betalingstjenesteloven) | HIGH | Neonomics holds PISP/AISP registration; Drop operates as agent/technology partner until own license obtained |
| AML (Hvitvaskingsloven) | LOW | Transaction monitoring remains Drop's responsibility regardless of aggregator |
| DORA | MEDIUM | Neonomics is a critical third-party ICT provider — must be documented in Drop's ICT risk management framework |
Data residency implications: Neonomics processes data in Norway/EEA — no cross-border transfer to non-adequate countries.
6. Performance Impact
| Metric | Before (mock) | After (Neonomics production) | Source |
|---|---|---|---|
| AISP balance read latency | ~10ms (mock) | ~200-500ms (Neonomics + ASPSP) | Neonomics SLA + Berlin Group typical |
| PISP initiation latency | ~10ms (mock) | ~300-800ms (Neonomics + ASPSP) | Neonomics SLA |
| PISP SCA redirect latency | N/A (mock) | User-dependent (BankID at ASPSP) | External |
| Availability | N/A (mock) | 99.5% SLA (Neonomics) | Neonomics commercial SLA |
Performance testing plan: Load test AISP balance reads with 100 concurrent users against Neonomics sandbox before production launch.
7. Migration / Implementation Notes
7.1 Migration Plan
Phase 2a (Weeks 1-2): Contract + setup
- [ ] Sign Neonomics commercial contract
- [ ] Sign GDPR DPA
- [ ] Obtain Neonomics sandbox API credentials
- [ ] Create lib/openbanking/neonomics.ts client module
Phase 2b (Weeks 3-4): AISP integration (balance reads)
- [ ] Implement AISP consent flow (POST /v1/consents)
- [ ] Implement balance read (GET /v1/accounts/{id}/balances)
- [ ] Update bank_accounts table (add consent_id, consent_expires_at columns)
- [ ] Integration test against Neonomics sandbox with DNB test account
Phase 2c (Weeks 5-6): PISP integration (payment initiation)
- [ ] Implement PISP payment initiation (POST /v1/payments/sepa-credit-transfers)
- [ ] Implement SCA redirect flow + payment status polling
- [ ] Add payment webhook receiver for async status updates
- [ ] Integration test: full remittance flow against Neonomics sandbox
Phase 2d (Weeks 7-8): Production readiness
- [ ] Switch NEXT_PUBLIC_SERVICE_MODE from 'mock' to 'production'
- [ ] Production credentials (Neonomics production API key) in AWS Secrets Manager
- [ ] Remove deprecated mock-swan.ts
- [ ] Monitoring: Sentry alerts for Neonomics API errors
- [ ] Circuit breaker: 3 failures in 60s → 60s cooldown
7.2 Rollback Strategy
Can we roll back? YES — Feature flag NEXT_PUBLIC_SERVICE_MODE reverts to mock mode instantly
Rollback steps:
- Set
NEXT_PUBLIC_SERVICE_MODE=mockin AWS Secrets Manager - Restart App Runner instances — rollback complete in < 5 minutes
- Users see cached balance; payments queue for retry when production mode re-enabled
7.3 Feature Flags
| Flag | Purpose | Default |
|---|---|---|
NEXT_PUBLIC_SERVICE_MODE |
mock = simulated AISP/PISP; production = Neonomics live |
mock |
OPEN_BANKING_PROVIDER |
Future: switch between Neonomics and Tink | neonomics |
8. Related ADRs
| ADR | Relationship | Notes |
|---|---|---|
| ADR-003 | Prerequisite | PSD2 pass-through model requires AISP/PISP provider |
| ADR-005 | Constrained by | Single monolith means single aggregator integration point |
| ADR-007 | Related | BankID used for Drop login SCA; ASPSP-side SCA (for PISP) is separate via Neonomics scaRedirect |
9. Review Date
Next review: 2026-08-23 (6 months post-decision) or when Neonomics pricing changes by > 50%
Review trigger conditions:
- If Drop processes > 10,000 monthly transactions: evaluate direct ASPSP integration economics
- If Neonomics raises per-transaction pricing by > 50%: evaluate Tink migration
- If Neonomics experiences > 3 outages in 30 days: activate Tink fallback
Superseded by: — (fill in if this ADR is later superseded)
Approval
| Role | Name | Date | Signature |
|---|---|---|---|
| Author | Petter Graff | 2026-02-23 | |
| Tech Lead | John (AI Director) | ||
| Security (if compliance impact) | |||
| CEO | Alem Bašić |
ADR-001: Consolidate Backends
ADR-001: Consolidate to Single Backend
Status: Accepted Date: 2026-02-12 Deciders: John (AI Director), Alem (CEO) Category: Architecture
Context
The Drop codebase contained two competing middleware implementations creating confusion about which was authoritative:
-
lib/middleware.ts-- A simple, monolithic file used by all 24 API routes. Provided cookie-based JWT auth (requireAuth,requireMerchant), basic rate limiting via SQLite-backedrate_limitstable, IP extraction fromX-Forwarded-For, and standardized JSON error responses (jsonError). -
lib/middleware/directory -- A more robust, modular implementation withauth-middleware.ts(Bearer token auth, in-memory rate limiting withX-RateLimit-*headers),error-handler.ts(typedAppErrorclass with predefined error constructors), andvalidation.ts(comprehensive input validation: phone, amount, IBAN, PIN, email, currency, sanitization). This directory was completely unused by any route.
Additionally, FontelePay (an earlier project iteration) resided at src/fontelepay/ with its own .git/, node_modules/, and independent codebase, blurring project boundaries.
The SOURCE-STATUS.md analysis confirmed that Drop source code had never been properly committed to version control. A clean rebuild using build artifacts as specification was the recommended path.
graph LR
subgraph before["Before (Dual Middleware)"]
routes["24 API Routes"] --> mw1["lib/middleware.ts<br/>(cookie JWT, basic rate limit)"]
unused["UNUSED"] --> mw2["lib/middleware/<br/>(Bearer JWT, typed errors, validation)"]
end
subgraph after["After (Consolidated)"]
routes2["24 API Routes"] --> mw3["lib/middleware.ts<br/>(cookie JWT, persistent rate limit,<br/>CSRF, session revocation)"]
routes2 --> val["lib/middleware/validation.ts<br/>(input validation, sanitization)"]
end
before -->|"ADR-001"| after
Decision
-
Consolidate middleware into a single implementation, incorporating the best parts of both:
- Cookie-based JWT auth from
lib/middleware.ts(matches current route expectations) - Typed errors and input validation from
lib/middleware/directory - Persistent rate limiting via
rate_limitsSQLite table (replacing in-memoryMap)
- Cookie-based JWT auth from
-
Remove duplicate code after consolidation to eliminate confusion about which implementation is authoritative.
-
Separate FontelePay from Drop's
src/directory (see ADR-002). -
Remove dead code including any orphaned backend variants and unused files.
Consequences
Positive
- Single source of truth for middleware behavior
- Eliminates developer confusion about which middleware to use
- Typed errors (
AppError,Errors.*) improve debugging and error reporting - Input validation (
validateName,sanitizeText,validateAmount) applied consistently - Clean separation between Drop and FontelePay projects
- Reduced codebase size and maintenance burden
Negative
- All 24 API routes needed import path updates during rebuild
- Risk of regression if middleware behavior changed subtly during consolidation
- FontelePay separation required updating any shared references
Risks
- Behavioral drift: Consolidated middleware may behave differently from original in edge cases. Mitigation: test suite covering auth, transactions, cards, and merchants.
- Import breakage: Route import paths change. Mitigation: rebuild approach writes routes fresh against consolidated middleware.
References
- ADR-002: Separate FontelePay -- Companion decision for FontelePay extraction
- Middleware Documentation -- Current middleware reference
- Security Architecture -- Security controls using consolidated middleware
- Original source:
comms/decisions/ADR-001-consolidate-backends.md
ADR-002: Separate FonTelePay
ADR-002: Separate FontelePay from Drop Repository
Status: Accepted Date: 2026-02-12 Deciders: John (AI Director) Category: Architecture
Context
FontelePay is an earlier iteration of the payment concept that predates the Drop rebrand. It resided at src/fontelepay/ inside the Drop product directory with:
- Its own
.git/repository with independent commit history - Its own
node_modules/(527 entries) - Its own
.claude/configuration directory - Separate documentation, R&D research, and project structure
- Multiple subdirectories: frontend, backend, mobile, AI, infrastructure, security, marketing, sales, support, legal, design, rnd, team
This created several problems:
| Problem | Impact |
|---|---|
| Project confusion | Developers could not distinguish "Drop source" from "FontelePay source" |
| Git conflicts | Nested .git/ directory caused submodule-like behavior without proper configuration |
| Build interference | FontelePay's node_modules/ could interfere with Drop's build process |
| Size bloat | FontelePay's 527 dependency packages inflated the Drop directory |
graph TB
subgraph before["Before: Nested Repository"]
drop_dir["Drop/"]
drop_dir --> src["src/"]
src --> drop_app["drop-app/ (Drop)"]
src --> fp["fontelepay/ (FontelePay)"]
fp --> fp_git[".git/ (separate repo)"]
fp --> fp_nm["node_modules/ (527 pkgs)"]
fp --> fp_claude[".claude/"]
fp --> fp_rnd["rnd/ (market research)"]
end
subgraph after["After: Clean Separation"]
products["ALAI/products/"]
products --> drop_clean["Drop/<br/>Clean codebase"]
products --> fp_separate["FontelePay/<br/>Preserved independently"]
end
before -->|"ADR-002"| after
Decision
Move FontelePay out of the Drop directory to its own location:
- Move
src/fontelepay/to~/ALAI/products/FontelePay/(or archive) - Preserve FontelePay's git history by keeping its
.git/intact during move - Keep only a reference document in Drop pointing to FontelePay's new location
- Copy relevant research documents (particularly
rnd/mobilebank-research/) to Drop'srnd/directory if directly relevant
Consequences
Positive
- Clean Drop directory with only Drop-related source code
- No nested git repository confusion
- Smaller directory footprint for Drop
- Clear project boundaries for all developers and AI agents
- FontelePay research preserved independently for future reference
Negative
- Any scripts or references pointing to
src/fontelepay/paths break - Need to verify no Drop code depends on FontelePay modules
- Historical context of FontelePay as Drop's predecessor may be lost if not documented
Risks
- Reference breakage: Scripts or docs pointing to old FontelePay path. Mitigation: search-and-replace across codebase during migration.
- Lost context: FontelePay's role as Drop's predecessor forgotten. Mitigation: this ADR documents the relationship.
References
- ADR-001: Consolidate Backends -- Companion decision for middleware cleanup
- ADR-003: PSD2 Pass-through Model -- Architectural direction that superseded FontelePay's wallet model
- Original source:
comms/decisions/ADR-002-separate-fontelepay.md
ADR-003: PSD2 Pass-Through Model
ADR-003: Adopt PSD2 Pass-through Model (No Wallet)
Status: Accepted Date: 2026-02-12 Deciders: Alem (CEO), John (AI Director) Category: Architecture
Context
The original Drop codebase implemented a wallet model where:
- Users had a local balance stored in the
usersdatabase table - Users could "top up" their wallet via
/api/users/top-up(no payment verification) - Transactions deducted from local balance
- Drop effectively held customer funds
This wallet model had significant regulatory implications under Norwegian law:
| Aspect | Wallet Model (EMI) | Pass-through Model (PISP/AISP) |
|---|---|---|
| License type | E-money Institution (EMI) | PISP/AISP registration |
| Norwegian law | Finansforetaksloven | Betalingstjenesteloven |
| Initial capital | 350,000 EUR | 20,000-50,000 EUR |
| Timeline to license | 12-18 months | 6-12 months |
| Fund safeguarding | Required (segregated accounts or insurance) | Not needed |
| PCI-DSS scope | Full (card data stored) | Minimal (no card data) |
The alternative PSD2 pass-through model positions Drop as a Payment Initiation Service Provider (PISP) and Account Information Service Provider (AISP) where Drop never holds customer funds.
graph LR
subgraph wallet["Wallet Model (Rejected)"]
user1["User"] -->|"Top-up"| drop_wallet["Drop Wallet<br/>(holds funds)"]
drop_wallet -->|"Pay"| merchant1["Merchant"]
drop_wallet -->|"Send"| receiver1["Receiver"]
end
subgraph passthrough["Pass-through Model (Adopted)"]
user2["User"] -->|"PISP: Initiate payment"| bank["User's Bank<br/>(holds funds)"]
bank -->|"Execute transfer"| merchant2["Merchant"]
bank -->|"Execute transfer"| receiver2["Receiver"]
drop_pt["Drop<br/>(orchestrator)"] -.->|"AISP: Read balance"| bank
drop_pt -.->|"PISP: Initiate"| bank
end
classDef rejected fill:#FFCDD2,stroke:#C62828
classDef adopted fill:#C8E6C9,stroke:#2E7D32
class user1,drop_wallet,merchant1,receiver1 rejected
class user2,bank,merchant2,receiver2,drop_pt adopted
Decision
Drop adopts the PSD2 pass-through model. Specifically:
- No wallet: Remove all local balance, top-up, and fund-holding functionality
- AISP for balance: User sees their bank account balance via Open Banking API (read-only). The
bank_accounts.balancefield stores a cached AISP read -- not a Drop-held balance - PISP for payments: Remittance and QR payments are initiated from the user's own bank account via Open Banking payment initiation with SCA
- No card storage: Cards feature gated behind feature flags (all default
false); future card issuance via PCI-compliant partner only - BankID for SCA: Strong Customer Authentication via Norwegian BankID replaces email+password for all financial operations
Code Impact
| Feature | Wallet Model (removed) | Pass-through Model (current) |
|---|---|---|
| Balance | Local balance column in users table |
bank_accounts.balance = cached AISP read from bank |
| Top-up | /api/users/top-up endpoint |
Removed -- no top-up needed |
| Remittance | Deduct from local balance | POST /api/transactions/remittance triggers PISP |
| QR Payment | Deduct from local balance | POST /api/transactions/qr-payment triggers PISP |
| Cards | Stored locally (PAN, CVV in DB) | Feature-flagged; future partner integration (token-only) |
| Auth | Email + password (single factor) | BankID OIDC for SCA |
| Transaction | Local DB update only | Local record + bank payment confirmation |
sequenceDiagram
participant User
participant Drop
participant BankID
participant Bank
participant Recipient
Note over User,Recipient: PSD2 Pass-through Remittance Flow
User->>Drop: Initiate remittance (amount, recipient)
Drop->>Drop: Fee disclosure (0.5%)
Drop->>User: Show total cost + exchange rate
User->>Drop: Confirm payment
Drop->>BankID: SCA challenge (amount + payee)
BankID->>User: Authenticate (BankID app)
User->>BankID: Approve
BankID->>Drop: SCA confirmed
Drop->>Bank: PISP: Initiate payment
Bank->>Bank: Debit user account
Bank->>Drop: Payment status: processing
Drop->>Drop: Record transaction (status: processing)
Bank->>Recipient: Transfer funds (SEPA/SWIFT)
Bank->>Drop: Payment status: completed
Drop->>Drop: Update transaction (status: completed)
Drop->>User: Notification: transfer complete
Consequences
Positive
- Lower regulatory barrier to market entry (PISP/AISP vs EMI license)
- Faster licensing timeline (6-12 months vs 12-18 months)
- Lower capital requirements (20-50K EUR vs 350K EUR)
- No PCI-DSS card data storage obligations
- No fund safeguarding requirements (no funds to protect)
- Simpler security model -- Drop cannot lose customer funds
- Users keep their money in their trusted bank until payment execution
Negative
- Dependent on banking partner / BaaS provider for Open Banking API access
- User experience may be slower (bank confirmation for each payment vs instant local deduction)
- Cannot offer instant transfers (limited by bank processing times: 1-2 days SEPA, 2-4 days SWIFT)
- Revenue model changes: no float income from held funds
- BankID integration adds complexity and requires BankID Norge partnership
Risks
- Banking partner dependency: If no Norwegian bank provides Open Banking access, Drop cannot function. Mitigation: SpareBank1 already pitched; Swan (BaaS) as backup provider.
- UX friction: Each payment requires bank authentication via SCA. Mitigation: BankID app provides smooth mobile flow; consider session-based consent for repeat payments within limits.
- Corridor coverage: PISP may not support all 30+ target countries directly. Mitigation: use licensed remittance partner for non-SEPA corridors.
References
- System Context (C4 Level 1) -- Shows Drop's external system relationships
- Open Banking Integration -- AISP/PISP integration specification
- Security Architecture -- Security controls for pass-through model
- Compliance Status -- Regulatory compliance tracking
- Roadmap -- Phase 2 banking integration plan
- Original source:
comms/decisions/ADR-003-psd2-passthrough-model.md
ADR-004: JWT HTTPOnly Cookies
ADR-004: JWT Storage in httpOnly Cookies
Status: Accepted Date: 2026-02-21 Deciders: John (AI Director) Category: Security
Context
Drop is a financial application handling payment initiation, bank account data, and personal information. Secure token storage is critical -- token theft enables full account takeover including payment initiation from the victim's bank account.
The two primary options for JWT storage in browser-based SPAs are:
| Option | XSS Risk | CSRF Risk | Implementation Complexity |
|---|---|---|---|
localStorage |
HIGH -- any XSS payload can read tokens | None | Low |
httpOnly cookie |
None -- JavaScript cannot access | Medium -- requires CSRF protection | Medium |
Given that Drop processes financial data and operates under PSD2, XSS-based token theft would be catastrophic -- an attacker could initiate payments from a user's bank account. CSRF is a more constrained attack vector with well-understood mitigations.
The mobile app (Expo SDK 54) uses Bearer tokens stored in AsyncStorage since cookies are not practical for native apps, but the attack surface is fundamentally different (no XSS in native context).
Decision
Store JWTs in httpOnly cookies for the web application. Use Bearer tokens for the mobile API.
| Property | Value | Rationale |
|---|---|---|
httpOnly |
true |
Prevents JavaScript access, eliminates XSS token theft |
secure |
true (production) |
HTTPS-only transport |
sameSite |
"Lax" |
CSRF defense (allows BankID redirect back) |
maxAge |
604,800 (7d) | Session lifetime |
path |
"/" |
Full application scope |
Implementation note: The actual implementation uses
maxAge=604800(7d) andSameSite=Lax(changed from the originally specified strict/24h to support BankID OIDC redirect flows).
CSRF protection layers:
sameSite: "Lax"-- browser refuses to send cookie on cross-origin POST requests- Origin header validation against allowed origins whitelist (
app.ts:23-30CORS middleware) - CSRF token generation available (
generateCsrfToken()) for additional protection
graph TB
subgraph localStorage["localStorage (Rejected)"]
xss["XSS Attack"] -->|"document.cookie<br/>or localStorage.getItem()"| steal["Token Stolen"]
steal --> takeover["Account Takeover<br/>+ Payment Initiation"]
end
subgraph httpOnly["httpOnly Cookie (Adopted)"]
xss2["XSS Attack"] -->|"Cannot access<br/>httpOnly cookie"| blocked["BLOCKED"]
csrf["CSRF Attack"] -->|"Cross-origin request"| samesite["sameSite: strict<br/>BLOCKED by browser"]
end
classDef danger fill:#FFCDD2,stroke:#C62828
classDef safe fill:#C8E6C9,stroke:#2E7D32
class xss,steal,takeover danger
class blocked,samesite safe
Consequences
Positive
- XSS cannot steal authentication tokens (critical for fintech)
sameSite: strictprovides strong CSRF protection with minimal implementation overhead- React's built-in output escaping + CSP headers provide defense-in-depth
- Aligns with OWASP recommendations for secure session management
- Session revocation via
sessionstable allows server-side token invalidation
Negative
- Slightly more complex CSRF handling compared to Bearer tokens
- Cookie-based auth requires different handling for server-side requests (SSR)
- Cannot share tokens across subdomains without
sameSiteadjustment - Mobile app requires separate Bearer token flow (dual auth pattern)
Risks
- CSP bypass: If CSP includes
unsafe-inlineorunsafe-eval, XSS risk increases even with httpOnly cookies (attacker could make API calls from victim's browser). Mitigation: tighten CSP with nonce-based script loading for production.
References
- Security Architecture -- Full security controls documentation
- Authentication System -- Auth flow implementation details
- Middleware Documentation -- CSRF and auth middleware
- ADR-007: BankID OIDC Auth -- Authentication provider decision
- OWASP Session Management Cheat Sheet
ADR-005: Monolith First
ADR-005: Monolith-First Architecture
Status: Accepted Date: 2026-02-21 Deciders: John (AI Director), Alem (CEO) Category: Architecture
Context
Drop needs to go from concept to demo-ready product as quickly as possible. The team is small (1 human CEO + AI agents), and the initial scope is well-defined: 10 screens, ~24 API endpoints, single SQLite database.
Three architecture patterns were considered:
| Pattern | Deployment Complexity | Dev Speed | Scaling | Team Size Fit |
|---|---|---|---|---|
| Microservices | High (orchestration, service mesh, API gateway) | Slow (interface contracts, distributed testing) | Excellent | Large teams |
| Modular monolith | Low (single deploy unit) | Fast (shared code, simple debugging) | Good (extract later) | Small teams |
| Serverless functions | Medium (cold starts, state management) | Medium | Good (per-function) | Any |
The current scope (7 core pages + API routes) does not justify the operational overhead of microservices. The codebase is ~24 API route files and ~19 database tables -- well within what a single deployment can handle.
graph TB
subgraph monolith["Drop Monolith (Current)"]
nextjs["Next.js 15<br/>App Router"]
nextjs --> pages["Frontend Pages<br/>(10 screens, React 19)"]
nextjs --> api["API Routes<br/>(24 endpoints)"]
api --> db["SQLite / PostgreSQL<br/>(19 tables)"]
end
subgraph future["Future Extraction (If Needed)"]
web["Web Frontend<br/>(Next.js)"]
mobile_api["Mobile API<br/>(Hono v4)"]
payment_svc["Payment Service<br/>(PISP orchestration)"]
banking_svc["Banking Service<br/>(AISP integration)"]
shared_db["PostgreSQL<br/>(shared or per-service)"]
end
monolith -->|"Extract when:<br/>- Team grows to 5+ devs<br/>- 10K+ concurrent users<br/>- Independent deploy needed"| future
classDef current fill:#C8E6C9,stroke:#2E7D32
classDef future_style fill:#E3F2FD,stroke:#1565C0
class nextjs,pages,api,db current
class web,mobile_api,payment_svc,banking_svc,shared_db future_style
Decision
Start as a modular monolith. Extract microservices only when scaling demands it.
The monolith is structured with clear module boundaries to make future extraction feasible:
| Module | Responsibility | Files |
|---|---|---|
auth/ |
Authentication (BankID OIDC, JWT, sessions) | api/auth/*, lib/auth.ts |
transactions/ |
Remittance and QR payment processing | api/transactions/* |
merchants/ |
Merchant registration and dashboard | api/merchants/* |
cards/ |
Card management (feature-flagged) | api/cards/* |
compliance/ |
GDPR, AML, complaints | api/consents/*, api/complaints/*, api/user/* |
lib/ |
Shared: middleware, DB, validation, feature flags | lib/* |
Extraction triggers (when to consider splitting):
- Team grows beyond 5 developers working on different modules simultaneously
- Single-digit millisecond response time required for specific endpoints
- Independent deployment cadence needed (e.g., payment processing updated hourly, auth monthly)
- Database contention from concurrent writes exceeds SQLite/single-PostgreSQL capacity
Consequences
Positive
- Fastest path from concept to working demo
- Simple deployment: single Docker container on AWS App Runner
- No distributed system complexity (no service discovery, circuit breakers, distributed tracing)
- Easy debugging: single process, single log stream, single database
- Shared code: middleware, validation, and DB access used consistently across all routes
- Low operational cost at current scale
Negative
- All modules must deploy together (no independent deployment)
- Single point of failure (if the monolith crashes, everything is down)
- Scaling is all-or-nothing (cannot scale payment processing independently)
- Module boundaries are convention-based, not enforced by process isolation
Risks
- Boundary erosion: Without process isolation, module boundaries may erode over time. Mitigation: clear file organization, code review, and this ADR as a reminder.
- Scaling ceiling: Monolith will hit throughput limits at high concurrency. Mitigation: PostgreSQL handles concurrent writes; App Runner auto-scales horizontally.
References
- Architecture Document -- Section 1.2: Architecture Style
- System Context (C4 Level 1) -- High-level system view
- Container Diagram (C4 Level 2) -- Internal container structure
- ADR-012: AWS App Runner -- Deployment target for monolith
- Martin Fowler, "MonolithFirst" (2015)
ADR-006: SQLite to PostgreSQL
ADR-006: SQLite for Development, PostgreSQL for Production
Status: SUPERSEDED by ADR-014: PostgreSQL-Only Architecture (2026-03-03) Date: 2026-02-21 Deciders: John (AI Director) Category: Database
Context
Drop requires a database strategy that supports rapid local development while being production-ready for a financial application. The key tension is between developer velocity (zero-config local setup) and production reliability (concurrent writes, ACID transactions, managed backups).
| Database | Local Setup | Concurrent Writes | Managed Services | Financial Compliance |
|---|---|---|---|---|
| SQLite | Zero config, file-based | Poor (single writer) | None | Not suitable for production |
| PostgreSQL | Docker required | Excellent (MVCC) | AWS RDS, Aurora | Industry standard for fintech |
| MySQL | Docker required | Good | AWS RDS, Aurora | Common but less feature-rich |
Drop's data model includes 19 tables with foreign keys, transactions requiring atomicity (balance deduction + transaction record), and compliance tables needing reliable concurrent access for audit logging. SQLite handles development workloads but cannot support concurrent writes from multiple App Runner instances.
Decision
Use SQLite (better-sqlite3) for development and PostgreSQL for production, with a dual-driver abstraction layer.
Driver detection is automatic based on environment:
const USE_PG = !!process.env.DATABASE_URL;
When DATABASE_URL is set (production), PostgreSQL is used. Otherwise, SQLite with WAL mode is used.
graph LR
subgraph dev["Development"]
app_dev["Drop App"] --> dal["Database Access Layer<br/>(db.ts)"]
dal --> sqlite["SQLite<br/>(better-sqlite3)<br/>./data/drop.db"]
end
subgraph prod["Production"]
app_prod["Drop App (x N)"] --> dal2["Database Access Layer<br/>(db.ts)"]
dal2 --> pg["PostgreSQL<br/>(AWS RDS)<br/>DATABASE_URL"]
end
dal -.->|"Same API:<br/>query(), getOne(),<br/>run(), transaction()"| dal2
See ADR-010: Dual Database Driver for the abstraction layer details.
Consequences
Positive
- Zero-config local development:
npm run devjust works, no Docker needed for DB - Production-grade concurrent access with PostgreSQL MVCC
- AWS RDS provides automated backups, point-in-time recovery (critical for financial data)
- Same application code runs against both databases via abstraction layer
- SQLite WAL mode provides good read performance during development
Negative
- SQL compatibility layer adds complexity (see ADR-010)
- Subtle behavioral differences between SQLite and PostgreSQL (e.g., type coercion, datetime handling)
- Cannot test PostgreSQL-specific features locally without Docker
- Must test against both databases in CI
Risks
- SQL dialect drift: A query that works in SQLite may fail in PostgreSQL. Mitigation: dual-driver abstraction normalizes SQL; CI tests against both.
- Performance characteristics differ: SQLite is faster for single-connection workloads. Mitigation: performance testing against PostgreSQL before production launch.
References
- ADR-010: Dual Database Driver -- Abstraction layer implementation
- Database Schema -- Full schema documentation
- Database Design -- Database architecture decisions
- Migration Strategy -- SQLite to PostgreSQL migration plan
ADR-007: BankID OIDC Auth
ADR-007: BankID as Sole Authentication Provider
Status: Accepted Date: 2026-02-21 Deciders: Alem (CEO), John (AI Director) Category: Security
Context
Drop is a financial application operating under PSD2 in Norway. PSD2 mandates Strong Customer Authentication (SCA) for payment initiation and account access. SCA requires two of three factors: knowledge (something you know), possession (something you have), and inherence (something you are).
Authentication options considered:
| Option | SCA Compliant | KYC Built-in | Norwegian Coverage | Implementation |
|---|---|---|---|---|
| BankID | Yes (possession + knowledge/biometric) | Yes (national ID verified) | ~4.5M users (90%+ adult pop.) | OIDC standard |
| Vipps Login | Partial (depends on config) | Partial (phone-verified) | ~4.3M users | OIDC standard |
| Email + Password | No (single factor) | No | Universal | Simple |
| Email + OTP | Partial (possession) | No | Universal | Medium |
BankID provides the strongest combination: SCA compliance (BankID app = possession, PIN = knowledge, biometric = inherence), built-in identity verification (national ID / fodselsnummer), and near-universal adoption in Norway. Using BankID as the sole auth provider eliminates the need for a separate KYC step -- identity is verified at login.
The original Drop codebase used email + password authentication, which is inadequate for PSD2 compliance and provides no identity verification.
Decision
Use BankID OIDC as the sole authentication provider for Drop. Remove email/password login.
Authentication architecture:
| Platform | Flow | Token Storage | Token Lifetime |
|---|---|---|---|
| Web (Next.js BFF) | BankID OIDC redirect flow | httpOnly cookie (drop_token) |
7 days |
| Mobile (Expo) | BankID OIDC with deep link callback | AsyncStorage (Bearer token) | 7 days |
User creation is automatic on first BankID login:
- Parse
pid(fodselsnummer, 11 digits) from BankID ID token - Hash
pidwith SHA-256 for storage (national_id_hashcolumn) - Check for existing user by
national_id_hash - If new: create user with
kyc_status = 'approved',kyc_method = 'bankid' - Verify age >= 18 from
pidbirthdate encoding
sequenceDiagram
participant User
participant Drop as Drop (BFF)
participant BankID as BankID OIDC
User->>Drop: GET /api/auth/bankid
Drop->>Drop: Generate state + nonce
Drop->>Drop: Set bankid_state cookie
Drop->>User: Redirect URL to BankID
User->>BankID: Authenticate (app/code device)
Note over User,BankID: SCA: possession (device) +<br/>knowledge (PIN) or inherence (biometric)
BankID->>User: Redirect to callback with code
User->>Drop: GET /api/auth/bankid/callback?code=&state=
Drop->>Drop: Verify state vs cookie
Drop->>BankID: Exchange code for tokens
BankID->>Drop: ID token + access token
Drop->>Drop: Verify ID token (JWKS)
Drop->>Drop: Extract pid, verify age >= 18
Drop->>Drop: Find or create user
Drop->>Drop: Create session, set JWT cookie
Drop->>User: 302 Redirect to /dashboard
Deprecated endpoints (return 410 Gone):
POST /auth/login-- replaced by BankID OIDCPOST /auth/register-- automatic via BankIDPOST /auth/verify-otp-- not needed
Consequences
Positive
- Full PSD2 SCA compliance out of the box
- Identity verification (KYC) built into authentication -- no separate KYC step for basic verification
- Near-universal adoption in Norway (~4.5M BankID users)
- Eliminates password-related attack vectors (credential stuffing, brute force, phishing)
- National ID hash enables user deduplication across auth providers (Vipps in Phase 2)
- Industry-standard OIDC protocol -- well-documented, well-supported
Negative
- Users without BankID cannot use Drop (excludes some demographics: very young, recent immigrants)
- Dependency on BankID infrastructure availability
- BankID integration requires BankID Norge agreement and certificate
- Development requires mock OIDC flow (
BANKID_MOCK=true) since real BankID needs production credentials - More complex auth flow compared to email/password
Risks
- BankID outage: If BankID is down, no one can log in. Mitigation: Vipps Login planned as Phase 2 fallback (
auth_providerfield supports multiple providers). - Demographic exclusion: Users without BankID (e.g., new residents) cannot register. Mitigation: Vipps Login + Sumsub manual KYC as alternatives in Phase 2.
References
- Authentication System -- Full auth implementation documentation
- BankID OIDC Integration -- Integration specification
- ADR-004: JWT httpOnly Cookies -- Token storage decision
- ADR-003: PSD2 Pass-through -- SCA requirement origin
- Security Architecture -- Session management details
- BankID Norge OIDC documentation
ADR-008: Hono API Framework
ADR-008: Hono v4 for Mobile API
Status: Accepted Date: 2026-02-21 Deciders: John (AI Director) Category: Backend
Context
Drop has two client platforms with different API needs:
| Platform | Auth Pattern | Token Storage | API Style | Deployment |
|---|---|---|---|---|
| Web (Next.js) | Cookie-based JWT via BFF | httpOnly cookie | Next.js API Routes (collocated) | Vercel / App Runner |
| Mobile (Expo) | Bearer token | AsyncStorage | REST API (separate process) | App Runner |
Next.js API Routes work well for the web BFF pattern (server-side rendering + API in one deployment), but mobile needs a lightweight, standalone REST API with Bearer token authentication and mobile-specific concerns (deep link callbacks, longer token lifetimes).
Frameworks considered for the mobile API:
| Framework | Performance | TypeScript | Edge Compatible | Bundle Size | Ecosystem |
|---|---|---|---|---|---|
| Hono v4 | Excellent (minimal overhead) | First-class | Yes (Workers, Deno, Bun) | ~14KB | Growing fast |
| Express 5 | Good (mature) | Requires @types | No (Node-only) | ~200KB | Massive |
| Fastify 5 | Excellent (schema validation) | Good (built-in types) | No (Node-only) | ~300KB | Large |
| Elysia | Excellent (Bun-native) | First-class | Bun only | ~20KB | Small |
Hono was selected for its TypeScript-first design, minimal overhead, and edge compatibility. The mobile API runs as a separate Hono server on App Runner alongside the Next.js web app.
Decision
Use Hono v4 for the mobile REST API. Keep Next.js API Routes for the web BFF.
graph TB
subgraph clients["Client Platforms"]
web["Web Browser<br/>(Next.js SSR + CSR)"]
mobile["Mobile App<br/>(Expo SDK 54)"]
end
subgraph backend["Backend Services"]
nextjs_api["Next.js BFF<br/>API Routes (/api/*)<br/>Cookie auth, SSR"]
hono_api["Hono v4 API<br/>REST (/v1/*)<br/>Bearer auth, mobile-optimized"]
end
subgraph shared["Shared Layer"]
db["Database Access (db.ts)"]
bankid["BankID OIDC (bankid.ts)"]
validation["Validation (validation.ts)"]
end
web --> nextjs_api
mobile --> hono_api
nextjs_api --> db
nextjs_api --> bankid
hono_api --> db
hono_api --> bankid
nextjs_api --> validation
hono_api --> validation
| Aspect | Next.js API Routes | Hono v4 API |
|---|---|---|
| Base path | /api/ |
/v1/ |
| Auth | Cookie JWT (httpOnly) | Bearer token (Authorization header) |
| Token lifetime | 7 days | 7 days |
| BankID callback | HTTP redirect to /dashboard |
JSON response with token |
| Rate limiting | SQLite-backed (persistent) | Database-backed (SQLite rate_limits table, persistent) |
| Deployment | Vercel or App Runner | App Runner (standalone) |
Consequences
Positive
- Mobile API is lightweight and fast (Hono ~14KB, minimal middleware overhead)
- TypeScript-first with excellent type inference for request/response
- Edge-compatible runtime means future flexibility (Cloudflare Workers, Deno Deploy)
- Clear separation between web BFF (cookie auth) and mobile API (Bearer auth)
- Shared business logic prevents code duplication
Negative
- Two API servers to maintain (Next.js + Hono)
- Two deployment targets on App Runner
- Shared library updates must be tested against both frameworks
- Smaller ecosystem compared to Express (fewer middleware packages)
Risks
- Diverging behavior: Same endpoint implemented twice may behave differently. Mitigation: shared database access layer and validation utilities ensure consistent business logic.
- Hono ecosystem maturity: Hono is newer than Express/Fastify. Mitigation: Hono v4 is stable and backed by Cloudflare; core routing and middleware are well-tested.
References
- Container Diagram (C4 Level 2) -- Shows both API containers
- Authentication System -- Web vs mobile auth flows
- API Reference -- Next.js API endpoints
- ADR-005: Monolith First -- Overall architecture approach
- Hono v4 documentation: hono.dev
ADR-009: Feature Flag System
ADR-009: Custom Feature Flag System
Status: Accepted Date: 2026-02-21 Deciders: John (AI Director) Category: Backend
Context
Drop needs feature flags for several reasons:
- Gradual rollout: Cards feature requires a card issuing partner before activation -- must be gated
- Kill switches: Ability to disable features instantly in production if compliance or operational issues arise
- Development: Feature-in-progress code can be merged to main without being user-visible
- A/B testing: Future capability for comparing payment flows
Feature flag approaches considered:
| Approach | Cost | Complexity | Server+Client | Targeting | Audit |
|---|---|---|---|---|---|
| Custom (env vars) | Free | Low | Yes (NEXT_PUBLIC_) | No | Via deploy history |
| LaunchDarkly | $10/seat/mo | Medium | Yes | Yes (per-user) | Yes |
| Unleash (self-hosted) | Free (OSS) | High (infra) | Yes | Yes | Yes |
| ConfigCat | Free tier | Low | Yes | Yes | Yes |
At Drop's current stage (pre-launch, no production users), a custom system based on environment variables provides exactly what is needed: server+client flag availability, zero operational overhead, and type-safe TypeScript integration. User-level targeting is not needed until there are users to target.
Decision
Implement a custom feature flag system using NEXT_PUBLIC_FF_* environment variables, with type-safe TypeScript wrappers for server and client access.
Architecture:
graph TB
subgraph env["Environment Variables"]
vars["NEXT_PUBLIC_FF_VIRTUAL_CARDS=false<br/>NEXT_PUBLIC_FF_PHYSICAL_CARDS=false<br/>NEXT_PUBLIC_FF_NOTIFICATIONS=true<br/>..."]
end
subgraph server["Server-Side (API Routes)"]
isEnabled["isEnabled('virtualCards')"]
featureGate["featureGate('physicalCards')"]
getAllFlags["getAllFlags()"]
end
subgraph client["Client-Side (React)"]
useFlag["useFeatureFlag('notifications')"]
useFlags["useFeatureFlags()"]
end
vars -->|"Build-time inline<br/>(NEXT_PUBLIC_ prefix)"| server
vars -->|"Build-time inline<br/>(NEXT_PUBLIC_ prefix)"| client
featureGate -->|"Returns 404<br/>if disabled"| api_route["API Route<br/>(e.g., POST /api/cards/[id]/physical)"]
Flag registry (feature-flags.ts:27-36):
| Flag | Env Var | Default | Purpose |
|---|---|---|---|
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 |
Card detail view |
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 |
Spending limit controls |
notifications |
NEXT_PUBLIC_FF_NOTIFICATIONS |
true |
Push notifications |
merchantDashboard |
NEXT_PUBLIC_FF_MERCHANT_DASHBOARD |
true |
Merchant dashboard |
Server-side API route protection via featureGate():
const gate = featureGate("physicalCards");
if (gate) return gate; // Returns 404: "Feature not available"
Client-side conditional rendering via useFeatureFlag():
const cardsEnabled = useFeatureFlag("virtualCards");
if (!cardsEnabled) return null;
Consequences
Positive
- Zero infrastructure cost and operational overhead
- Type-safe TypeScript API prevents flag name typos at compile time
- Works on both server (API routes) and client (React hooks) via
NEXT_PUBLIC_prefix featureGate()provides consistent 404 behavior for disabled API endpoints- Flags are immutable per deployment (changed via environment variable update + redeploy)
- All card-related features safely gated while awaiting card issuing partner
Negative
- No per-user targeting (all users see the same flags)
- Flag changes require redeployment (not runtime-configurable)
- No built-in audit trail of flag changes (relies on deployment history)
- No gradual percentage-based rollout capability
NEXT_PUBLIC_prefix exposes flag names to client (but values are public anyway)
Risks
- Stale flags: Flags left enabled/disabled long after they should be changed. Mitigation: feature tracking system (
features.ts) monitors implementation status; quarterly flag cleanup reviews. - Build-time lock-in: Flags are inlined at build time, so the same build cannot have different flag values. Mitigation: acceptable for current deployment model (one build per environment).
References
- Feature Flags Documentation -- Full API reference and flag listing
- API Reference -- Routes using
featureGate() - Security Architecture -- Feature flags section
- ADR-005: Monolith First -- Single deployment model
ADR-010: Dual Database Driver
ADR-010: Dual Database Driver Abstraction
Status: SUPERSEDED by ADR-014: PostgreSQL-Only Architecture (2026-03-03) Date: 2026-02-21 Deciders: John (AI Director) Category: Database
Context
Per ADR-006, Drop uses SQLite for development and PostgreSQL for production. This creates a challenge: the application code must work correctly against both database engines, which have different SQL dialects, parameter binding, and transaction semantics.
The naive approach -- maintaining two separate codebases or using an ORM -- has drawbacks:
| Approach | Type Safety | SQL Control | Performance | Complexity |
|---|---|---|---|---|
| Raw SQL per driver | Low (string SQL) | Full | Optimal | High (2x code) |
| ORM (Prisma/Drizzle) | High | Limited | Good (with overhead) | Medium |
| Thin abstraction layer | Medium | Full | Optimal | Low |
An ORM would add a dependency, a build step (Prisma generate), and limit SQL flexibility for complex compliance queries. A thin abstraction layer provides the best balance: same SQL syntax where possible, automatic translation where not.
Decision
Implement a thin database abstraction layer (db.ts) that exposes a unified API and transparently converts SQL between SQLite and PostgreSQL dialects.
Driver detection at startup:
const USE_PG = !!process.env.DATABASE_URL;
graph TB
subgraph app["Application Code"]
routes["API Routes"]
routes -->|"query(), getOne(),<br/>run(), transaction()"| dal["Database Access Layer<br/>(db.ts)"]
end
subgraph dal_internals["Abstraction Layer Internals"]
dal --> detect{"DATABASE_URL<br/>set?"}
detect -->|"Yes"| pg_driver["PostgreSQL Driver<br/>(pg pool)"]
detect -->|"No"| sqlite_driver["SQLite Driver<br/>(better-sqlite3)"]
dal --> convert["SQL Converter"]
convert -->|"? → $1,$2..."| pg_driver
convert -->|"datetime('now') →<br/>CURRENT_TIMESTAMP"| pg_driver
end
subgraph databases["Databases"]
pg_driver --> pg["PostgreSQL<br/>(production)"]
sqlite_driver --> sqlite["SQLite<br/>(development)"]
end
Unified API
| Function | Signature | Purpose |
|---|---|---|
query<T> |
(sql, params?) -> Promise<T[]> |
SELECT, returns array of rows |
getOne<T> |
(sql, params?) -> Promise<T | null> |
SELECT, returns first row or null |
run |
(sql, params?) -> Promise<{changes}> |
INSERT/UPDATE/DELETE |
runIgnore |
(sql, params?) -> Promise<{changes}> |
INSERT OR IGNORE / ON CONFLICT DO NOTHING |
runUpsert |
(sql, conflictCol, updateCols, params?) -> Promise<{changes}> |
INSERT OR REPLACE / ON CONFLICT DO UPDATE |
transaction<T> |
(fn) -> Promise<T> |
Atomic transaction wrapper |
initDb |
() -> Promise<void> |
Schema creation + seed data |
getDriver |
() -> "pg" | "sqlite" |
Current driver type |
SQL Translation Rules (db.ts:50-59)
| SQLite Syntax | PostgreSQL Equivalent | Handled By |
|---|---|---|
? placeholders |
$1, $2, $3, ... |
Automatic in query()/run() |
INSERT OR IGNORE INTO |
INSERT INTO ... ON CONFLICT DO NOTHING |
runIgnore() |
INSERT OR REPLACE INTO |
INSERT INTO ... ON CONFLICT (col) DO UPDATE SET |
runUpsert() |
datetime('now') |
CURRENT_TIMESTAMP |
Automatic in SQL string |
INTEGER AUTOINCREMENT |
SERIAL |
Schema initialization |
TEXT dates |
TIMESTAMPTZ |
Schema initialization |
Consequences
Positive
- Application code is database-agnostic -- same queries work against both engines
- Zero-config local development (SQLite), production-grade in deployment (PostgreSQL)
- No ORM overhead or code generation step
- Full SQL control for complex compliance queries (joins across audit tables)
- Transparent parameter binding conversion
- Transaction semantics unified across both drivers
Negative
- SQL must be compatible with both dialects (no PostgreSQL-specific features like arrays, JSON operators, CTEs with RETURNING)
- Subtle behavioral differences may cause bugs (e.g., SQLite type affinity vs PostgreSQL strict typing)
runUpsert()API is slightly awkward compared to native SQL- Cannot use advanced PostgreSQL features (partial indexes, LISTEN/NOTIFY, materialized views) through the abstraction
Risks
- Silent data differences: SQLite may accept data that PostgreSQL rejects (e.g., inserting text into INTEGER column). Mitigation: CI tests against both databases.
- Transaction isolation: SQLite uses serialized transactions (one writer), PostgreSQL uses MVCC. Code that works under SQLite serialization may have race conditions under PostgreSQL MVCC. Mitigation: explicit row locking (
FOR UPDATE) in critical paths like balance deduction.
References
- ADR-006: SQLite to PostgreSQL -- Database strategy decision
- Database Schema -- Table definitions for both dialects
- Migration Strategy -- Data migration plan
- Database Design -- Database architecture
ADR-011: Expo Mobile Framework
ADR-011: Expo SDK 54 for Mobile App
Status: Accepted Date: 2026-02-21 Deciders: John (AI Director), Alem (CEO) Category: Mobile
Context
Drop requires a mobile app for iOS and Android. The mobile app is the primary interface for remittance and QR payments -- users scan QR codes with their phone camera and approve payments via BankID on the same device.
Mobile framework options considered:
| Framework | Cross-Platform | Code Sharing (Web) | OTA Updates | Camera/QR | BankID Integration | Dev Experience |
|---|---|---|---|---|---|---|
| Expo SDK 54 | iOS + Android | High (React shared) | Yes (EAS Update) | expo-camera | expo-web-browser | Excellent |
| React Native (bare) | iOS + Android | High (React shared) | Manual | react-native-camera | Custom deep links | Good |
| Flutter | iOS + Android | None (Dart vs TS) | No native OTA | camera plugin | Custom deep links | Good |
| Native (Swift/Kotlin) | Separate codebases | None | App Store only | Native APIs | Native SDKs | Platform-specific |
Key factors in the decision:
- Code sharing: Drop's web app uses React 19. Expo enables sharing React components, hooks, types, and business logic between web and mobile.
- BankID flow: Mobile BankID authentication requires opening a secure browser (
expo-web-browser) and handling deep link callbacks (drop://auth/callback). Expo provides both natively. - QR scanning: Core feature requires camera access.
expo-cameraprovides this with barcode scanning built in. - OTA updates: Financial apps need rapid hotfix deployment. Expo Application Services (EAS) provides over-the-air JavaScript bundle updates without App Store review.
- Team capacity: AI-driven development team benefits from a single language (TypeScript) across all platforms.
Decision
Use Expo SDK 54 with managed workflow for the Drop mobile app.
graph TB
subgraph mobile["Mobile App (Expo SDK 54)"]
screens["Screens<br/>(10 screens matching web)"]
hooks["Shared Hooks<br/>(useAuth, useTransactions)"]
types["Shared TypeScript Types"]
screens --> camera["expo-camera<br/>(QR scanning)"]
screens --> browser["expo-web-browser<br/>(BankID auth)"]
screens --> notif["expo-notifications<br/>(push alerts)"]
screens --> storage["AsyncStorage<br/>(Bearer token)"]
screens --> linking["expo-linking<br/>(deep links: drop://)"]
end
subgraph web["Web App (Next.js 15)"]
web_screens["Screens<br/>(10 screens)"]
web_hooks["Shared Hooks"]
web_types["Shared TypeScript Types"]
end
subgraph backend["Backend"]
hono["Hono v4 API<br/>(/v1/* - Bearer auth)"]
nextjs["Next.js BFF<br/>(/api/* - Cookie auth)"]
end
hooks -.->|"Shared React code"| web_hooks
types -.->|"Shared types"| web_types
mobile --> hono
web --> nextjs
classDef expo fill:#E3F2FD,stroke:#1565C0
classDef web_style fill:#C8E6C9,stroke:#2E7D32
classDef backend_style fill:#FFF3E0,stroke:#E65100
class screens,hooks,types,camera,browser,notif,storage,linking expo
class web_screens,web_hooks,web_types web_style
class hono,nextjs backend_style
Key Expo Modules Used
| Module | Purpose | Drop Feature |
|---|---|---|
expo-camera |
Camera access + barcode scanning | QR payment scanning |
expo-web-browser |
Secure in-app browser | BankID OIDC authentication |
expo-notifications |
Push notification handling | Transaction alerts, payment receipts |
expo-linking |
Deep link handling (drop://) |
BankID callback, notification deep links |
@react-native-async-storage |
Persistent key-value store | Bearer token storage |
expo-secure-store |
Encrypted storage | Sensitive data (future biometric) |
expo-local-authentication |
Biometric auth | App unlock (Phase 2) |
Mobile-Specific Auth Flow
The mobile BankID flow differs from web:
GET /v1/auth/bankid/initiate?platform=mobilereturns{ redirectUrl, state }- Open BankID in
expo-web-browser(secure, isolated browser) - BankID redirects to
drop://auth/callback?code=&state= expo-linkingcatches the deep linkPOST /v1/auth/bankid/callbackexchanges code for Bearer token- Token stored in
AsyncStorage(7-day lifetime)
Consequences
Positive
- Single language (TypeScript) across web, mobile, and backend
- High code reuse: shared types, hooks, and validation logic with web app
- OTA updates via EAS enable rapid hotfixes without App Store review cycle
- Managed workflow eliminates native build complexity
expo-cameraprovides built-in barcode scanning for QR paymentsexpo-web-browserprovides secure BankID integration- Large React Native ecosystem for additional modules
Negative
- Expo managed workflow limits access to some native APIs (can eject if needed)
- App size larger than pure native (~25MB vs ~5MB)
- JavaScript bridge performance for heavy computation (not a concern for Drop's use case)
- Must use Expo-compatible packages (some React Native packages require ejection)
- EAS build service adds to CI costs
Risks
- Expo SDK upgrade breakage: Major Expo SDK upgrades can break packages. Mitigation: managed workflow handles most upgrades; test thoroughly before upgrading.
- App Store rejection: Financial apps face stricter App Store review. Mitigation: ensure compliance with App Store Review Guidelines section 3.1 (payments) and 5.1 (privacy).
- Performance on low-end devices: React Native may lag on older Android devices. Mitigation: minimal animations, lazy loading, optimized list rendering.
References
- ADR-008: Hono API Framework -- Mobile API backend
- Authentication System -- Mobile BankID flow
- Component Overview (C4 Level 3) -- Mobile app components
- ADR-007: BankID OIDC Auth -- Authentication provider
- Expo documentation: docs.expo.dev
ADR-012: AWS App Runner Deploy
ADR-012: AWS App Runner for Deployment
Status: Accepted Date: 2026-02-21 Deciders: John (AI Director), Alem (CEO) Category: Infrastructure
Context
Drop needs a deployment target for its backend services (Next.js BFF and Hono mobile API). The deployment platform must support Docker containers, auto-scaling, HTTPS termination, and be cost-effective at low initial traffic with the ability to scale.
Deployment options considered:
| Platform | Container Support | Auto-scaling | Min Cost | Operational Overhead | Cold Start |
|---|---|---|---|---|---|
| AWS App Runner | Yes (ECR/source) | Automatic | ~$5/mo (min instances) | Very low | Warm (min instance) |
| AWS ECS/Fargate | Yes (ECR) | Manual config (target tracking) | ~$10/mo (Fargate) | Medium (task defs, services, ALB) | Warm |
| AWS Lambda | Yes (container image) | Automatic (per-request) | ~$0 (free tier) | Low | Cold start problem |
| Vercel | No (serverless functions) | Automatic | Free tier | Very low | Cold start for API |
| Railway | Yes (Dockerfile) | Automatic | ~$5/mo | Very low | Warm |
| Fly.io | Yes (Dockerfile) | Automatic | ~$5/mo | Low | Warm |
Key factors:
- WebSocket/long connections: App Runner supports them; Lambda does not (29s timeout)
- PostgreSQL connectivity: App Runner runs in VPC, can connect to RDS; Lambda requires NAT gateway ($32/mo)
- Operational simplicity: App Runner is "push container, get HTTPS endpoint" -- no load balancer, target group, or service mesh to configure
- Cost at scale: App Runner pricing is straightforward (vCPU-hour + memory-hour); ECS/Fargate pricing is similar but with more configuration
- AWS ecosystem: PostgreSQL on RDS, secrets in Secrets Manager, logs in CloudWatch -- all in same account
Vercel was used for the landing page (static) and is excellent for Next.js, but its serverless function model is not ideal for the Hono API or long-running database connections.
Decision
Use AWS App Runner for backend deployment. Keep Vercel for the landing page (static site).
graph TB
subgraph edge["Edge Layer"]
cf["Cloudflare<br/>DNS + CDN + WAF + DDoS"]
end
subgraph aws["AWS (eu-north-1)"]
subgraph apprunner["App Runner"]
nextjs["Next.js BFF<br/>Web app + API routes<br/>(1-10 instances)"]
hono["Hono API<br/>Mobile REST API<br/>(1-10 instances)"]
end
subgraph data["Data Layer"]
rds["RDS PostgreSQL<br/>(production DB)"]
secrets["Secrets Manager<br/>(JWT_SECRET, BANKID creds)"]
end
subgraph monitoring["Monitoring"]
cw["CloudWatch<br/>(logs, metrics)"]
end
end
subgraph vercel["Vercel"]
landing["Landing Page<br/>getdrop.no<br/>(static)"]
end
cf --> nextjs
cf --> hono
cf --> landing
nextjs --> rds
hono --> rds
nextjs --> secrets
hono --> secrets
nextjs --> cw
hono --> cw
classDef edge_style fill:#FFF3E0,stroke:#E65100
classDef aws_style fill:#E3F2FD,stroke:#1565C0
classDef vercel_style fill:#F3E5F5,stroke:#6A1B9A
class cf edge_style
class nextjs,hono,rds,secrets,cw aws_style
class landing vercel_style
App Runner Configuration
| Setting | Value | Rationale |
|---|---|---|
| Region | eu-north-1 (Stockholm) |
Closest AWS region to Norway; GDPR data residency |
| Source | ECR (Docker image) | Pushed by GitHub Actions CI/CD |
| CPU | 1 vCPU | Sufficient for current load |
| Memory | 2 GB | Room for Node.js heap + DB connections |
| Min instances | 1 | Eliminates cold start; ~$5/mo baseline |
| Max instances | 10 | Auto-scales based on concurrent requests |
| Port | 3000 (Next.js), 3001 (Hono) | Default Node.js ports |
| Health check | GET /api/health (also available at /v1/health) |
Returns DB connectivity status |
| Auto deploy | Yes (on ECR push) | CI/CD pushes new image, App Runner deploys |
Deployment Pipeline
graph LR
push["git push"] --> gha["GitHub Actions"]
gha --> build["Docker build<br/>+ TypeScript check<br/>+ Lint + Test"]
build --> ecr["Push to ECR"]
ecr --> apprunner["App Runner<br/>auto-deploy"]
apprunner --> health["Health check<br/>GET /api/health"]
health -->|"Healthy"| live["Live traffic"]
health -->|"Unhealthy"| rollback["Auto-rollback<br/>to previous version"]
Consequences
Positive
- Minimal operational overhead: no load balancers, target groups, or service meshes to manage
- Automatic HTTPS with AWS-managed TLS certificates
- Auto-scaling based on concurrent requests (0 config beyond min/max instances)
- VPC connectivity to RDS PostgreSQL without NAT gateway
- Automatic rollback on failed health checks
- CloudWatch integration for logs and metrics out of the box
- Cost-effective: ~$5/mo baseline with 1 min instance, scales linearly
Negative
- Less configurability than ECS/Fargate (no custom networking, task placement, or sidecar containers)
- Limited to HTTP/HTTPS workloads (no TCP/UDP services)
- Newer AWS service with fewer community resources and examples
- No built-in blue/green deployment (App Runner does rolling updates). Note:
deployment-architecture.mddescribes blue/green as aspirational — it would need custom implementation. - Vendor lock-in to AWS (container is portable, but App Runner config is not)
Risks
- App Runner regional availability: Service may not be available in all regions. Mitigation:
eu-north-1(Stockholm) is supported. - Scaling latency: New instances take 30-60 seconds to provision. Mitigation: maintain 1 min instance for baseline traffic; pre-scale before expected traffic events.
- Cost at scale: App Runner pricing can exceed ECS/Fargate for high-throughput workloads. Mitigation: evaluate migration to ECS/Fargate if monthly compute exceeds $200.
References
- Deployment Architecture -- Full deployment topology
- System Context (C4 Level 1) -- Infrastructure components
- ADR-005: Monolith First -- Single deployment model
- ADR-008: Hono API Framework -- Mobile API deployment
- AWS App Runner documentation
ADR Overview
Architecture Decision Records (ADRs)
Project: Drop -- Fintech Payment App Last updated: 2026-03-03 Maintainer: Standards Architect
What are ADRs?
Architecture Decision Records capture significant technical decisions made during Drop's development. Each ADR documents the context, the decision itself, and its consequences -- providing a historical record of why the system is built the way it is.
ADRs are immutable once accepted. If a decision is reversed, the original ADR is marked Superseded and a new ADR is created referencing it.
ADR Index
| ADR | Title | Status | Date | Category |
|---|---|---|---|---|
| ADR-001 | Consolidate to Single Backend | Accepted | 2026-02-12 | Architecture |
| ADR-002 | Separate FontelePay from Drop Repository | Accepted | 2026-02-12 | Architecture |
| ADR-003 | Adopt PSD2 Pass-through Model (No Wallet) | Accepted | 2026-02-12 | Architecture |
| ADR-004 | JWT Storage in httpOnly Cookies | Accepted | 2026-02-21 | Security |
| ADR-005 | Monolith-First Architecture | Accepted | 2026-02-21 | Architecture |
| ADR-006 | SQLite for Dev, PostgreSQL for Production | Superseded by ADR-014 | 2026-02-21 | Database |
| ADR-007 | BankID as Sole Authentication Provider | Accepted | 2026-02-21 | Security |
| ADR-008 | Hono v4 for Mobile API | Accepted | 2026-02-21 | Backend |
| ADR-009 | Custom Feature Flag System | Accepted | 2026-02-21 | Backend |
| ADR-010 | Dual Database Driver Abstraction | Superseded by ADR-014 | 2026-02-21 | Database |
| ADR-011 | Expo SDK 54 for Mobile App | Accepted | 2026-02-21 | Mobile |
| ADR-012 | AWS App Runner for Deployment | Accepted | 2026-02-21 | Infrastructure |
| ADR-014 | PostgreSQL-Only Architecture (Drizzle ORM) | Accepted | 2026-02-26 | Database |
ADR Lifecycle
stateDiagram-v2
[*] --> Proposed : Author drafts ADR
Proposed --> Accepted : Team reviews and approves
Proposed --> Rejected : Team rejects proposal
Accepted --> Deprecated : No longer relevant
Accepted --> Superseded : New ADR replaces this one
Rejected --> [*]
Deprecated --> [*]
Superseded --> [*]
ADR Template
Use this template when proposing a new ADR. Save as ADR-NNN-short-title.md in this directory.
# ADR-NNN: Title
**Status:** Proposed | Accepted | Deprecated | Superseded by [ADR-XXX](ADR-XXX-title.md)
**Date:** YYYY-MM-DD
**Deciders:** [Names and roles]
**Category:** Architecture | Security | Database | Backend | Frontend | Mobile | Infrastructure
---
## Context
What is the issue that we are seeing that motivates this decision?
Include technical background, constraints, and forces at play.
## Decision
What is the change that we are proposing and/or doing?
State the decision clearly and concisely.
## Consequences
### Positive
- List benefits of this decision
### Negative
- List drawbacks and trade-offs
### Risks
- List risks and their mitigations
## References
- Link to related ADRs, documents, or external resources
- Link to implementation PRs or tasks
Guidelines for Proposing ADRs
-
When to write an ADR: Any decision that affects the system architecture, technology choices, security model, or data model and would be hard to reverse.
-
Scope: One ADR per decision. If a decision has multiple parts, consider splitting into separate ADRs.
-
Numbering: Use sequential three-digit numbers (001, 002, ...). Never reuse numbers.
-
Review process: Draft as
Proposed, share with the team. Once approved by the AI Director (John) and/or CEO (Alem), change status toAccepted. -
Superseding: When reversing a decision, create a new ADR and update the old one's status to
Superseded by [ADR-XXX]. Never delete old ADRs. -
Context matters: Future readers need to understand why the decision was made. Include constraints, alternatives considered, and the reasoning.
Cross-References
- Architecture Document -- Main architecture overview
- System Context (C4 Level 1) -- System context diagram
- Compliance Status -- Regulatory compliance tracking
Integration Specifications
Third-party integration architecture and flows
BankID OIDC Integration
BankID OIDC Integration
Version: 1.0 Date: 2026-02-21 Author: Banking Architecture Team Status: Approved Applies to: Drop — Authentication via Norwegian BankID
1. Overview
Drop uses BankID OIDC (OpenID Connect) as its sole authentication method. BankID provides Strong Customer Authentication (SCA) out of the box — combining possession (mobile device / code generator) with knowledge (personal code) or inherence (biometrics).
Email/password login has been removed. All deprecated auth endpoints return 410 Gone.
| Property | Value |
|---|---|
| Identity Provider | BankID (prod: auth.bankid.no) |
| Protocol | OpenID Connect 1.0 (Authorization Code Flow) |
| Auth endpoint | https://auth.bankid.no/auth/realms/prod/protocol/openid-connect/auth |
| Token endpoint | https://auth.bankid.no/auth/realms/prod/protocol/openid-connect/token |
| JWKS endpoint | https://auth.bankid.no/auth/realms/prod/protocol/openid-connect/certs |
| Issuer | https://auth.bankid.no/auth/realms/prod |
| Scopes | openid profile |
| Response type | code (authorization code flow) |
| Source code | src/drop-api/src/lib/bankid.ts |
2. BankID OIDC Flow
2.1 Web Flow (Next.js BFF)
sequenceDiagram
participant B as Browser
participant N as Next.js BFF<br/>/api/auth/bankid/*
participant BID as BankID OIDC<br/>(auth.bankid.no)
B->>N: GET /api/auth/bankid
Note over N: Rate limit check (10/min per IP)
N->>N: Generate state (crypto.randomUUID)<br/>Generate nonce (crypto.randomUUID)
N->>N: Set httpOnly cookie: bankid_state={state}
N-->>B: {redirectUrl: "https://auth.bankid.no/auth/...?<br/>client_id=X&redirect_uri=Y&response_type=code<br/>&scope=openid+profile&state=Z&nonce=N"}
B->>BID: Browser redirects to BankID authorize URL
Note over B,BID: User authenticates with BankID<br/>(BankID app / code generator / biometrics)
BID-->>B: 302 redirect to /api/auth/bankid/callback<br/>?code=AUTH_CODE&state=Z
B->>N: GET /api/auth/bankid/callback?code=AUTH_CODE&state=Z
N->>N: Verify state matches bankid_state cookie
N->>BID: POST /token<br/>{grant_type: authorization_code,<br/>code: AUTH_CODE, redirect_uri: Y,<br/>client_id: X, client_secret: S}
BID-->>N: {id_token: "eyJ...", access_token: "...",<br/>token_type: "Bearer", expires_in: 300}
N->>N: Verify id_token signature via JWKS
N->>N: Validate: issuer, audience, expiry, nonce
N->>N: Extract pid (fødselsnummer) from claims
N->>N: Parse birthdate from pid, verify age >= 18
N->>N: findOrCreateUser(pid, name)
N->>N: Create session (sessions table)
N->>N: Issue Drop JWT, set httpOnly cookie (drop_token)
N-->>B: 302 redirect to /dashboard
2.2 Mobile Flow (Hono API + Expo)
sequenceDiagram
participant M as Mobile App<br/>(Expo)
participant H as Hono API<br/>/v1/auth/bankid/*
participant BID as BankID OIDC
M->>H: GET /v1/auth/bankid/initiate?platform=mobile
Note over H: Rate limit check (10/min per IP)
H->>H: Generate state + nonce
H-->>M: {redirectUrl: "https://auth.bankid.no/...",<br/>state: "Z"}
M->>M: Store state in memory
M->>BID: Open BankID in secure browser<br/>(expo-web-browser)
Note over M,BID: User authenticates with BankID
BID-->>M: Deep link: drop://auth/callback<br/>?code=AUTH_CODE&state=Z
M->>M: Verify state matches stored state
M->>H: POST /v1/auth/bankid/callback<br/>{code: AUTH_CODE, state: Z, platform: "mobile"}
H->>BID: POST /token (exchange code)
BID-->>H: {id_token, access_token}
H->>H: Verify id_token via JWKS
H->>H: Extract pid, verify age >= 18
H->>H: findOrCreateUser(pid, name)
H->>H: Create session
H->>H: Issue Drop JWT (7-day expiry)
H-->>M: {token: "eyJ...", data: {id, name, role}}
M->>M: Store token in AsyncStorage
3. Token Lifecycle
3.1 Drop JWT (Issued After BankID Auth)
stateDiagram-v2
[*] --> Issued: BankID auth success<br/>→ createSession() + signJWT()
Issued --> Active: Token in use<br/>(cookie or Bearer header)
Active --> Active: API request<br/>→ verifySession() passes
Active --> Expired: 7d expiry reached<br/>(all clients)
Active --> Revoked: User logs out<br/>→ revokeAllSessions()
Active --> Revoked: Session marked revoked<br/>(admin action)
Expired --> Refreshed: POST /auth/refresh<br/>→ new JWT + new session
Expired --> [*]: User must re-authenticate
Revoked --> [*]: User must re-authenticate
Refreshed --> Active
3.2 JWT Payload Structure
interface DropJwtPayload {
userId: string; // "usr_a1b2c3d4e5f6g7h8"
email: string; // "usr_xxx@bankid.drop.local" (placeholder)
role: string; // "user" | "merchant"
iat: number; // Issued at (Unix timestamp)
exp: number; // Expiry (iat + 7d, all clients)
iss?: string; // "drop-api" (Hono only)
aud?: string; // "drop" (Hono only)
}
3.3 Token Lifetimes
| Platform | Token Location | Lifetime | Refresh |
|---|---|---|---|
| Web | httpOnly cookie (drop_token) |
24 hours | POST /api/auth/refresh |
| Mobile | Bearer header (AsyncStorage) | 7 days | POST /v1/auth/refresh |
3.4 Session Tracking
Every JWT has a corresponding record in the sessions table:
| Column | Purpose |
|---|---|
id |
Session ID (ses_<hex16>) |
user_id |
FK to users.id |
token_hash |
SHA-256 hash of the JWT string |
expires_at |
Token expiry timestamp |
revoked |
0 = active, 1 = revoked |
On every authenticated request, the middleware:
- Extracts JWT from cookie (web) or Authorization header (mobile)
- Verifies JWT signature and expiry with
jose.jwtVerify() - Hashes the JWT with SHA-256
- Looks up the session by
token_hash - Verifies
revoked = 0andexpires_at > now - Rejects the request if any check fails
4. BankID Claims Mapping
4.1 ID Token Claims
| BankID Claim | Type | Drop Usage | Stored In |
|---|---|---|---|
sub |
string | Subject identifier (BankID internal) | Fallback for pid |
pid |
string | Norwegian fødselsnummer (11 digits) | Hashed as users.national_id_hash (SHA-256) |
name |
string | Full name (e.g., "Ola Nordmann") | Split into users.first_name + users.last_name |
birthdate |
string | ISO date (not always present) | Parsed from pid instead (more reliable) |
iss |
string | Issuer URL | Validated against expected issuer |
aud |
string | Client ID | Validated against BANKID_CLIENT_ID |
exp |
number | Token expiry | Validated (must be in future) |
iat |
number | Issued at | Validated (must be reasonable) |
nonce |
string | Anti-replay | Matched against value sent in auth request |
4.2 PID (Fødselsnummer) Processing
The pid (personal identification number) is an 11-digit Norwegian national ID that encodes the birthdate:
Format: DDMMYYNNNCC
DD = Day of birth (01-31)
MM = Month of birth (01-12)
YY = Year of birth (2 digits)
NNN = Individual number (000-999)
CC = Check digits (Luhn variant)
Century derivation (from individual number):
NNN 000-499 → born 1900-1999
NNN 500-999 → born 2000-2099
Source: bankid.ts:37-51 (parseBirthdateFromPid)
Processing pipeline:
- Parse birthdate from pid digits → ISO date string (
YYYY-MM-DD) - Verify age >= 18 by comparing birthdate to current date (
isOver18()) - Hash pid with SHA-256 for storage:
crypto.createHash("sha256").update(pid).digest("hex") - Never store raw pid — only the hash exists in the database (
users.national_id_hash)
4.3 User Deduplication
Users are identified by national_id_hash (SHA-256 of their fødselsnummer). This ensures:
- The same person logging in on different devices gets the same account
- Re-authentication after token expiry reconnects to the existing account
- Future Vipps Login integration can match users by the same pid hash
Source: bankid.ts:208-249 (findOrCreateUser)
5. User Creation (Auto-Registration)
BankID login automatically creates user accounts. There is no separate registration step.
| Field | Value | Source |
|---|---|---|
id |
usr_<hex16> |
randomId("usr") |
email |
usr_xxx@bankid.drop.local |
Placeholder (BankID doesn't provide email) |
password_hash |
EIDONLY |
Sentinel — no password authentication |
auth_provider |
bankid |
Indicates BankID-only auth |
first_name |
First word of name claim |
Split from BankID name |
last_name |
Remaining words of name claim |
Split from BankID name |
date_of_birth |
Parsed from pid | parseBirthdateFromPid(pid) |
kyc_status |
approved |
BankID = verified identity |
kyc_method |
bankid |
KYC via BankID |
kyc_verified_at |
Current timestamp | Set on creation |
national_id_hash |
SHA-256(pid) | For user deduplication |
role |
user |
Default; upgradable to merchant |
6. SCA Compliance
6.1 PSD2 SCA Requirements Met by BankID
| PSD2 RTS Requirement | BankID Implementation | Status |
|---|---|---|
| Two of three factors (knowledge, possession, inherence) | BankID app (possession) + personal code (knowledge) or biometrics (inherence) | Met |
| Independence of factors | Separate device (BankID app) + separate knowledge factor | Met |
| Dynamic linking (for PISP) | Amount and payee displayed in BankID app during payment approval | Met (ASPSP-side) |
| Authentication code specific to amount + payee | BankID generates unique code per transaction | Met (ASPSP-side) |
| Session timeout | BankID sessions expire (configurable by ASPSP) | Met |
| Max 5 failed attempts | BankID locks after failed attempts | Met (BankID-side) |
6.2 Drop's SCA Scope
Drop performs SCA at two levels:
- Drop authentication (login): BankID OIDC — satisfies SCA for account access
- Payment SCA (PISP): Delegated to ASPSP — user re-authenticates with BankID at the bank for each payment (see open-banking-aisp-pisp.md)
7. Error Handling
7.1 Error Matrix
| Error Scenario | HTTP Status | Error Code | User Message (Norwegian) | Recovery Action |
|---|---|---|---|---|
| BankID auth cancelled by user | 400 | bankid_cancelled |
"Du avbrøt BankID-innlogging." | Retry login |
| BankID auth timeout | 408 | bankid_timeout |
"BankID-sesjonen utløp. Prøv igjen." | Retry login |
| State mismatch (CSRF) | 403 | state_mismatch |
"Sikkerhetssjekk feilet. Prøv igjen." | Restart flow |
| Token exchange failed | 502 | token_exchange_failed |
"Kunne ikke koble til BankID. Prøv igjen." | Retry later |
| JWKS verification failed | 502 | jwks_verification_failed |
"Teknisk feil. Prøv igjen senere." | Alert ops, retry |
| Invalid pid format | 422 | invalid_pid |
"Ugyldig identifikasjon fra BankID." | Contact support |
| User under 18 | 403 | underage |
"Du må være minst 18 år for å bruke Drop." | No recovery — legal requirement |
BANKID_CLIENT_ID missing |
500 | config_error |
"Teknisk feil. Prøv igjen senere." | Fix server config |
| Session revoked | 401 | session_revoked |
"Sesjonen din er utløpt. Logg inn på nytt." | Re-authenticate |
| JWT expired | 401 | token_expired |
"Sesjonen din er utløpt. Logg inn på nytt." | Refresh or re-authenticate |
| Rate limited | 429 | rate_limited |
"For mange forsøk. Vent litt og prøv igjen." | Wait and retry |
7.2 Error Flow
flowchart TD
A[BankID auth attempt] --> B{Auth successful?}
B -->|Yes| C[Exchange code for tokens]
B -->|No: cancelled| D[Return bankid_cancelled]
B -->|No: timeout| E[Return bankid_timeout]
C --> F{Token exchange OK?}
F -->|Yes| G[Verify ID token via JWKS]
F -->|No| H[Return token_exchange_failed<br/>Log error, alert ops]
G --> I{JWKS valid?}
I -->|Yes| J[Extract pid from claims]
I -->|No| K[Return jwks_verification_failed<br/>Check JWKS URL, cert rotation]
J --> L{Valid pid format?}
L -->|Yes| M{Age >= 18?}
L -->|No| N[Return invalid_pid]
M -->|Yes| O[findOrCreateUser]
M -->|No| P[Return underage<br/>403 Forbidden]
O --> Q[Create session + JWT]
Q --> R[Redirect to /dashboard]
8. Mock Mode (Development)
When BANKID_MOCK=true, the OIDC flow is simulated locally without contacting BankID:
| Mock Code Prefix | Mock User | Birthdate | Age Check |
|---|---|---|---|
underage* |
"Ung Testbruker" (pid: 01011012345) |
2010-01-01 | Fails (under 18) |
| (anything else) | "Test Bankersen" (pid: 01019012345) |
1990-01-01 | Passes |
Source: bankid.ts:188-195 (getMockUserInfo)
Mock flow:
initiateOIDC()generates a redirect URL (same structure, but not called)- Frontend skips BankID redirect, posts a mock code to callback
exchangeAndVerify()detectsisMock=trueand returns mock user infofindOrCreateUser()runs normally (creates real DB records)
9. Production Migration Checklist
| Step | Description | Status |
|---|---|---|
| 1 | Register BankID OIDC client at developer.bankid.no |
Not started |
| 2 | Obtain BANKID_CLIENT_ID and BANKID_CLIENT_SECRET |
Not started |
| 3 | Configure callback URLs (web + mobile deep link) | Not started |
| 4 | Set BANKID_MOCK=false (or remove env var) |
Pending |
| 5 | Test OIDC flow in BankID preprod environment | Not started |
| 6 | Configure JWKS caching (jose handles this, verify TTL) | Pending |
| 7 | Set secure JWT_SECRET (min 32 chars, from vault) |
Pending |
| 8 | Verify nonce validation in callback | Implemented in code |
| 9 | Test underage rejection with real BankID test user | Not started |
| 10 | Test session revocation and re-authentication | Implemented in code |
| 11 | Move to production BankID endpoints | Not started |
| 12 | Monitor JWKS key rotation (BankID rotates keys) | Pending |
10. Phase 2: Vipps Login (Planned)
Vipps Login uses the same OIDC protocol. Integration plan:
- Register Vipps Login client at
portal.vipps.no - Add Vipps OIDC endpoints alongside BankID
- Vipps also returns Norwegian pid (fødselsnummer)
- User deduplication via
national_id_hash— same hash regardless of whether user logs in with BankID or Vipps - UI: Login screen offers two buttons: "Logg inn med BankID" and "Logg inn med Vipps"
Optional: Use an OIDC aggregator like Idura to get a single integration point for BankID + Vipps + future providers.
11. Environment Variables
Required (Production)
| Variable | Description | Example |
|---|---|---|
BANKID_CLIENT_ID |
OIDC client ID from BankID | abc123-def456 |
BANKID_CLIENT_SECRET |
OIDC client secret | secret-from-bankid |
BANKID_CALLBACK_URL |
Web callback URL | https://getdrop.no/api/auth/bankid/callback |
BANKID_CALLBACK_URL_MOBILE |
Mobile deep link callback | drop://auth/callback |
JWT_SECRET |
Drop JWT signing secret (min 32 chars) | (from Vaultwarden) |
Optional
| Variable | Description | Default |
|---|---|---|
BANKID_AUTHORIZE_URL |
Override authorize endpoint | BankID prod URL |
BANKID_TOKEN_URL |
Override token endpoint | BankID prod URL |
BANKID_JWKS_URL |
Override JWKS endpoint | BankID prod URL |
BANKID_ISSUER |
Override expected issuer | BankID prod issuer |
BANKID_MOCK |
Enable mock mode (true/false) |
false |
JWT_ALGORITHM |
JWT signing algorithm | HS256 |
JWT_EXPIRY |
Token lifetime | 24h |
12. Cross-References
- Authentication System: ../../backend/AUTHENTICATION.md — Full auth system documentation
- Open Banking AISP/PISP: open-banking-aisp-pisp.md — ASPSP SCA (separate from BankID login SCA)
- Security Architecture: ../hld/security-architecture.md — JWT security, session management
- Login Flow (LLD): ../lld/flow-login-authentication.md — Step-by-step login UX
- Database Schema: ../../backend/DATABASE-SCHEMA.md —
users,sessionstables - Source:
src/drop-api/src/lib/bankid.ts— BankID OIDC implementation
Open Banking (AISP/PISP)
Open Banking Integration: AISP & PISP
Version: 1.0 Date: 2026-02-21 Author: Banking Architecture Team Status: Approved Applies to: Drop — PSD2 Pass-Through Model
1. Overview
Drop operates as a Third Party Provider (TPP) under PSD2, using two regulated services:
- AISP (Account Information Service Provider) — reads bank account balances and transaction history from the user's bank
- PISP (Payment Initiation Service Provider) — initiates payments directly from the user's bank account
Drop never holds customer funds. All money stays in the user's bank account. Drop initiates payments on behalf of the user via PISP and reads balances via AISP, both requiring explicit user consent and Strong Customer Authentication (SCA).
TPP Registration
Before operating as AISP/PISP, Drop must:
- Register with Finanstilsynet (Norwegian FSA) as a payment institution or authorized agent
- Obtain eIDAS certificates (QWAC for TLS, QSeal for signing) from a qualified TSP
- Register in the EBA TPP Register for EU-wide passporting
- Onboard with ASPSPs (banks) via their Open Banking developer portals or through an aggregator (e.g., Neonomics, Tink, Enable Banking)
| Registration Item | Authority | Status |
|---|---|---|
| Finanstilsynet PISP/AISP license | Finanstilsynet | Not applied (Phase 2 blocker) |
| eIDAS QWAC certificate | Qualified TSP (e.g., Buypass, Commfides) | Not obtained |
| eIDAS QSeal certificate | Qualified TSP | Not obtained |
| EBA TPP Register entry | EBA | Pending license |
| ASPSP onboarding (DNB, SpareBank 1, Nordea) | Per bank | Pending license |
Interim approach: Drop can operate as an agent under a licensed PSP's umbrella (1-3 months to set up) while preparing its own license application (6-12 months).
2. Berlin Group NextGenPSD2 API Standard
Drop targets the Berlin Group NextGenPSD2 specification (v1.3.12+), which is the dominant Open Banking standard in Scandinavia and the EEA. Norwegian banks (DNB, SpareBank 1, Nordea) expose APIs conforming to this standard, often through the BITS (Banking Industry's Technical Secretariat) coordination framework.
2.1 API Endpoint Mapping
| Berlin Group Endpoint | Method | Drop Usage | Drop Feature |
|---|---|---|---|
/v1/consents |
POST | Create AISP consent | Bank Account Linking (/accounts) |
/v1/consents/{consentId} |
GET | Check consent status | Consent lifecycle management |
/v1/consents/{consentId} |
DELETE | Revoke consent | Settings / Account unlinking |
/v1/consents/{consentId}/authorisations |
POST | Start SCA for consent | Bank linking SCA redirect |
/v1/consents/{consentId}/status |
GET | Poll consent status | Post-SCA consent verification |
/v1/accounts |
GET | List user's bank accounts | Dashboard account selector |
/v1/accounts/{accountId} |
GET | Get account details | Bank Account detail view |
/v1/accounts/{accountId}/balances |
GET | Read balance | Dashboard balance display (bank_accounts.balance) |
/v1/accounts/{accountId}/transactions |
GET | Read transaction history | Transaction History (future) |
/v1/payments/sepa-credit-transfers |
POST | Initiate SEPA payment | Remittance (EEA corridors) |
/v1/payments/cross-border-credit-transfers |
POST | Initiate cross-border payment | Remittance (non-EEA corridors) |
/v1/payments/domestic-credit-transfers |
POST | Initiate Norwegian payment | QR Payment to merchant |
/v1/payments/{paymentId} |
GET | Check payment status | Transaction status tracking |
/v1/payments/{paymentId}/authorisations |
POST | Start SCA for payment | Payment SCA redirect |
/v1/payments/{paymentId}/status |
GET | Poll payment status | Post-SCA payment verification |
2.2 Base URLs (Norwegian Banks)
| Bank (ASPSP) | Sandbox URL | Production URL |
|---|---|---|
| DNB | https://developer.dnb.no/sandbox/psd2/ |
https://api.dnb.no/psd2/ |
| SpareBank 1 | https://developer.sparebank1.no/sandbox/ |
https://api.sparebank1.no/open-banking/ |
| Nordea | https://developer.nordeaopenbanking.com/ |
https://api.nordeaopenbanking.com/ |
3. AISP — Account Information Service
3.1 Consent Flow
AISP access requires explicit user consent. The consent specifies which accounts and data types (balances, transactions) the TPP can access, and has a maximum validity of 90 days (per PSD2 RTS Art. 10).
sequenceDiagram
participant U as User (Browser/App)
participant D as Drop API
participant A as ASPSP (User's Bank)
U->>D: Link bank account
D->>A: POST /v1/consents<br/>{access: {balances: [iban], transactions: [iban]},<br/>recurringIndicator: true, validUntil: "2026-08-21",<br/>frequencyPerDay: 4, combinedServiceIndicator: false}
A-->>D: 201 {consentId, consentStatus: "received",<br/>_links: {scaRedirect: "https://bank.no/sca/..."}}
D-->>U: Redirect to bank SCA page
U->>A: Authenticate with BankID (SCA)
A-->>U: Redirect to Drop callback
U->>D: GET /api/accounts/callback?consentId=xxx
D->>A: GET /v1/consents/{consentId}/status
A-->>D: {consentStatus: "valid"}
D->>A: GET /v1/accounts?consentId={consentId}
A-->>D: {accounts: [{iban, currency, name, ...}]}
D->>A: GET /v1/accounts/{accountId}/balances
A-->>D: {balances: [{balanceType: "expected", balanceAmount: {currency: "NOK", amount: "45230.00"}}]}
D->>D: Store in bank_accounts table<br/>(balance cached, balance_synced_at = now)
D-->>U: Bank account linked successfully
3.2 Balance Retrieval
After consent is granted, Drop reads balances to display on the Dashboard. The bank_accounts.balance column stores the cached AISP-read value — it is never a Drop-held balance.
Refresh strategy:
| Trigger | Frequency | Method |
|---|---|---|
| User opens Dashboard | On demand | GET /v1/accounts/{id}/balances |
| Background sync | Every 6 hours (max 4/day per PSD2 RTS) | Scheduled job per linked account |
| Pre-payment check | Before PISP initiation | GET /v1/accounts/{id}/balances |
| User manual refresh | Pull-to-refresh gesture | GET /v1/accounts/{id}/balances |
PSD2 RTS constraint: A TPP may access the ASPSP's account data a maximum of 4 times per day without the PSU's active request (Art. 36(6) RTS). User-initiated requests are unlimited.
Drop API mapping:
GET /api/auth/mereturnstotalBalanceandbankAccounts[].balance— these are cached AISP reads from thebank_accountstablebank_accounts.balance_synced_attracks when the balance was last refreshed from the ASPSP
3.3 Data Storage
| AISP Data | Drop DB Column | Table | Notes |
|---|---|---|---|
| Account IBAN | bank_accounts.iban |
bank_accounts |
Stored on linking |
| Account name | bank_accounts.bank_name |
bank_accounts |
ASPSP name (e.g., "DNB") |
| Account number | bank_accounts.account_number |
bank_accounts |
Domestic format |
| Balance amount | bank_accounts.balance |
bank_accounts |
Cached AISP read (stored in oere) |
| Balance timestamp | bank_accounts.balance_synced_at |
bank_accounts |
Last refresh time |
| Consent ID | (new column needed) | bank_accounts or consents |
Links to ASPSP consent |
| Consent expiry | (new column needed) | consents |
Max 90 days from grant |
4. PISP — Payment Initiation Service
4.1 Payment Initiation Flow
PISP initiates payments from the user's bank account. Every PISP transaction requires SCA with dynamic linking — the authentication must be tied to the specific amount and payee (PSD2 RTS Art. 97(2)).
sequenceDiagram
participant U as User (Browser/App)
participant D as Drop API
participant A as ASPSP (User's Bank)
participant R as Recipient Bank
U->>D: POST /api/transactions/remittance<br/>{recipientId, amount, bankAccountId}
D->>D: Validate: KYC approved, balance sufficient,<br/>amount 100-50000 NOK, exchange rate lookup
D->>D: POST /api/transactions/disclosure<br/>Calculate fee (0.5%), FX rate, total
D-->>U: Show pre-payment disclosure<br/>(PSD2 Art. 45/46 compliance)
U->>D: Confirm payment
D->>A: POST /v1/payments/sepa-credit-transfers<br/>{debtorAccount: {iban},<br/>instructedAmount: {currency, amount},<br/>creditorAccount: {iban}, creditorName,<br/>remittanceInformationUnstructured}
A-->>D: 201 {paymentId, transactionStatus: "RCVD",<br/>_links: {scaRedirect: "https://bank.no/sca/pay/..."}}
D->>D: Create transaction record<br/>(status: "processing", idempotency_key set)
D-->>U: Redirect to bank SCA page
U->>A: Authenticate payment with BankID<br/>(dynamic linking: amount + payee shown)
A-->>U: Redirect to Drop callback
U->>D: GET /api/payments/callback?paymentId=xxx
D->>A: GET /v1/payments/{paymentId}/status
A-->>D: {transactionStatus: "ACCP"}
D->>D: Update transaction status to "completed"
D-->>U: Payment confirmed
Note over A,R: Bank executes SEPA/SWIFT transfer<br/>Settlement via interbank rails
4.2 Payment Types
| Drop Transaction Type | Berlin Group Payment Product | Use Case | Settlement Time |
|---|---|---|---|
| QR Payment (domestic) | domestic-credit-transfers |
Pay merchant in Norway | Instant (SEPA Inst) or T+1 |
| Remittance (EEA) | sepa-credit-transfers |
Send to EU/EEA countries | 1-2 business days |
| Remittance (non-EEA) | cross-border-credit-transfers |
Send to Serbia, Pakistan, Turkey, etc. | 2-4 business days |
4.3 Dynamic Linking (PSD2 RTS Art. 97(2))
For every PISP payment, the SCA procedure must incorporate dynamic linking:
- The payer must be made aware of the amount and payee during authentication
- The authentication code must be specific to that amount and payee
- Any change to amount or payee invalidates the authentication
Implementation: Drop passes instructedAmount and creditorName in the PISP request. The ASPSP displays these on the BankID SCA screen. The user confirms by authenticating, creating a cryptographic link between the consent and the specific transaction parameters.
4.4 Idempotency
Drop uses the idempotency_key column in the transactions table (with a unique index idx_tx_idempotency) to prevent duplicate payments:
- Generate
idempotency_keyfrom:{userId}:{recipientId}:{amount}:{timestamp_minute} - Include as
X-Request-IDheader in PISP API calls - If ASPSP returns a duplicate error, look up existing transaction by
idempotency_key - Return the existing transaction to the user (no double-charge)
5. Consent Lifecycle
5.1 State Diagram
stateDiagram-v2
[*] --> Requested: User initiates bank linking
Requested --> ScaPending: ASPSP returns scaRedirect
ScaPending --> Valid: User completes SCA
ScaPending --> Failed: SCA timeout / user cancels
ScaPending --> Failed: SCA rejected by bank
Valid --> Valid: Balance refresh (within 90 days)
Valid --> Expired: 90-day validity exceeded
Valid --> RevokedByUser: User unlinks bank account
Valid --> RevokedByASPSP: Bank revokes access
Valid --> RenewalPending: 90 days before expiry, prompt renewal
RenewalPending --> ScaPending: User re-authenticates
RenewalPending --> Expired: User ignores renewal
Expired --> Requested: User re-links account
Failed --> Requested: User retries
RevokedByUser --> [*]
RevokedByASPSP --> Requested: User re-links
Expired --> [*]
5.2 Consent Properties
| Property | Value | PSD2 Reference |
|---|---|---|
| Maximum validity | 90 days | RTS Art. 10(1) |
| Renewal SCA required | Yes, every 90 days | RTS Art. 10(2) |
| Access frequency (TPP-initiated) | Max 4x/day per account | RTS Art. 36(6) |
| Access frequency (PSU-initiated) | Unlimited | RTS Art. 36(6) |
| Revocation | User can revoke at any time | PSD2 Art. 94 |
| Scope | Per-account (balances + transactions) | Berlin Group consent model |
| Combined service | false (AISP separate from PISP) |
Berlin Group combinedServiceIndicator |
5.3 Consent Storage
Drop tracks consents in two places:
consentstable — GDPR consent records (consent_type:psd2_aisp,psd2_pisp)bank_accountstable — Links to ASPSP consent ID for each linked account
When a consent expires or is revoked:
bank_accounts.balanceis zeroed (stale data removed)bank_accounts.balance_synced_atis nulled- User is prompted to re-link via notification
6. SCA Requirements
6.1 When SCA Is Required
| Operation | SCA Required | SCA Type |
|---|---|---|
| AISP consent creation | Yes | Redirect to bank (BankID) |
| AISP consent renewal (90 days) | Yes | Redirect to bank (BankID) |
| AISP balance read (after initial consent) | No | Access token sufficient |
| PISP payment initiation | Yes, always | Redirect to bank with dynamic linking |
| PISP payment > threshold | Yes (no exemption) | Drop does not apply SCA exemptions |
6.2 SCA Methods
Drop does not perform SCA directly. SCA is delegated to the ASPSP (user's bank), which uses BankID as the authentication mechanism. The flow is:
- Drop sends AISP consent or PISP payment request to ASPSP
- ASPSP returns an
scaRedirectURL - Drop redirects the user to the bank's SCA page
- User authenticates with BankID (knowledge + possession factors)
- Bank redirects back to Drop with the result
6.3 SCA Exemptions
Drop does not apply SCA exemptions for PISP transactions. All payments require full SCA regardless of amount. This is a conservative approach that:
- Simplifies implementation
- Reduces fraud risk
- Avoids complex exemption logic (low value, trusted beneficiary, recurring)
- Aligns with Norwegian banks' SCA enforcement
7. Fallback Mechanisms
7.1 ASPSP API Unavailability
If the ASPSP's dedicated PSD2 API is unavailable:
| Scenario | Fallback | PSD2 Reference |
|---|---|---|
| API down (AISP) | Show last cached balance with timestamp | RTS Art. 33(4) |
| API down (PISP) | Display error, suggest retry later | No fallback — payment requires live API |
| API degraded (slow) | 30s timeout, retry once | Standard HTTP retry |
| API returns 5xx | Circuit breaker (3 failures → 60s cooldown) | Operational resilience |
| Consent expired | Prompt user to re-authenticate | Renewal flow |
7.2 Fallback Access (Screen Scraping)
Under PSD2 RTS Art. 33(4), if an ASPSP's dedicated API does not meet availability and performance standards, the TPP may fall back to the ASPSP's customer-facing online banking interface. Drop does not plan to implement screen scraping — instead relying on aggregators (Neonomics, Tink) who handle multi-bank connectivity and fallback.
7.3 Multi-Bank Strategy
Norwegian users may have accounts at multiple banks. Drop supports multiple linked accounts via the bank_accounts table (one marked is_primary). Each linked account has its own AISP consent with its own 90-day lifecycle.
8. Error Handling
8.1 ASPSP Error Codes (Berlin Group)
| HTTP Status | Berlin Group Code | Drop Handling | User Message |
|---|---|---|---|
| 400 | FORMAT_ERROR |
Log + show validation error | "Kunne ikke koble til banken. Sjekk kontonummeret." |
| 401 | CERTIFICATE_INVALID |
Alert ops, block requests | "Teknisk feil. Prøv igjen senere." |
| 403 | CONSENT_INVALID |
Delete consent, prompt re-link | "Tilgangen til banken din er utløpt. Koble til på nytt." |
| 403 | CONSENT_EXPIRED |
Delete consent, prompt re-link | "Tilgangen til banken din er utløpt. Koble til på nytt." |
| 404 | RESOURCE_UNKNOWN |
Log + remove stale data | "Kontoen ble ikke funnet i banken." |
| 429 | ACCESS_EXCEEDED |
Back off, use cached data | "For mange forespørsler. Viser sist kjente saldo." |
| 500+ | Server error | Circuit breaker, use cached data | "Banken svarer ikke. Prøv igjen senere." |
8.2 Payment-Specific Errors
| Scenario | ASPSP Response | Drop Handling |
|---|---|---|
| Insufficient funds | RJCT (rejected) |
Update transaction status to failed, notify user |
| Invalid IBAN | FORMAT_ERROR |
Reject before sending to ASPSP (validate locally) |
| SCA timeout | No callback within 5 min | Mark transaction as failed, release hold |
| SCA cancelled | User cancels at bank | Mark transaction as failed |
| Duplicate payment | DUPLICATE or HTTP 409 |
Look up by idempotency_key, return existing |
9. Aggregator Strategy
Rather than integrating directly with each ASPSP, Drop plans to use an Open Banking aggregator for production:
| Aggregator | Coverage | Strengths | Consideration |
|---|---|---|---|
| Neonomics (Norwegian) | Nordics + EEA | Strong Nordic bank coverage, Norwegian company | Primary candidate |
| Tink (Visa) | EU-wide (6000+ banks) | Largest coverage, Visa backing | Broader coverage |
| Enable Banking | Nordics | Direct PSD2, no screen scraping | Privacy-focused |
Benefits of aggregator approach:
- Single API integration (vs. per-bank integration)
- Aggregator handles eIDAS certificates, bank onboarding, fallback
- Faster time-to-market
- Aggregator maintains bank API compatibility as banks update
Trade-offs:
- Additional per-transaction cost
- Data passes through third party (GDPR implications)
- Dependency on aggregator uptime
10. Implementation Roadmap
| Phase | Milestone | Dependencies |
|---|---|---|
| Current (MVP) | Mock AISP/PISP (local DB balances, simulated payments) | None |
| Phase 2a | Aggregator sandbox integration (Neonomics or Tink) | Aggregator contract signed |
| Phase 2b | BankID OIDC for Drop auth + ASPSP SCA for consents | BankID client credentials |
| Phase 2c | AISP live (read real balances from DNB, SpareBank 1) | eIDAS cert + consent flow |
| Phase 2d | PISP live (initiate real payments) | Finanstilsynet license or agent arrangement |
| Phase 3 | Multi-bank support, consent renewal automation, SEPA Instant | Production scaling |
11. Cross-References
- BankID OIDC Integration: bankid-oidc-integration.md — Drop's authentication layer (separate from ASPSP SCA)
- Payment Processing: payment-processing.md — SEPA/SWIFT settlement, FX, fees
- Security Architecture: ../hld/security-architecture.md — Threat model, SCA controls
- Bank Account Linking Flow: ../lld/flow-bank-account-linking.md — Detailed AISP consent UX
- Remittance Flow: ../lld/flow-remittance.md — Detailed PISP payment UX
- Database Schema: ../../backend/DATABASE-SCHEMA.md —
bank_accounts,transactions,consentstables - API Reference: ../../backend/API-REFERENCE.md — Drop API endpoints
- Compliance Status: ../../security/COMPLIANCE.md — PSD2 readiness assessment
Payment Processing
Payment Processing Architecture
Version: 1.0 Date: 2026-02-21 Author: Banking Architecture Team Status: Approved Applies to: Drop — Payment Initiation & Settlement
1. Overview
Drop processes two types of payments, both initiated via PISP (Payment Initiation Service Provider) from the user's own bank account:
- Remittance — cross-border money transfers to 30+ countries (via SEPA SCT/SCTInst for EEA, SWIFT gpi for non-EEA)
- QR Payment — instant domestic payments to merchants in Norway
Drop never holds customer funds. All payments are initiated directly from the user's bank account via PSD2 Open Banking APIs. Drop earns revenue from transaction fees (0.5% remittance, 1% QR).
| Property | Remittance | QR Payment |
|---|---|---|
| API endpoint | POST /api/transactions/remittance |
POST /api/transactions/qr-payment |
| Fee | 0.5% of send amount | 1.0% of payment amount |
| Amount range | 100 - 50,000 NOK | 1 - 100,000 NOK |
| Settlement rail | SEPA SCT/SCTInst (EEA), SWIFT (non-EEA) | Domestic credit transfer (instant) |
| KYC required | Yes (kyc_status = 'approved') |
No (auth sufficient) |
| FX conversion | Yes (NOK to recipient currency) | No (NOK to NOK) |
| Status flow | processing -> completed / failed |
completed (instant) |
2. SEPA Payment Flow (EEA Remittance)
For remittances to EEA countries (EU + Norway, Iceland, Liechtenstein), Drop uses SEPA Credit Transfer (SCT) or SEPA Instant Credit Transfer (SCT Inst).
2.1 SEPA SCT Flow
sequenceDiagram
participant U as User
participant D as Drop API
participant DB as Drop DB
participant A as User's Bank (ASPSP)
participant CSM as SEPA CSM<br/>(EBA CLEARING / TARGET2)
participant RB as Recipient Bank
U->>D: POST /api/transactions/remittance<br/>{recipientId, amount: 2000, bankAccountId}
D->>DB: Verify: KYC approved, recipient exists,<br/>bank account exists
D->>DB: GET exchange_rates WHERE to_currency = 'EUR'
D->>D: Calculate fee: 2000 * 0.005 = 10 NOK<br/>Calculate receive: 2000 * 0.087 = 174 EUR<br/>Total debit: 2010 NOK
D->>D: POST /api/transactions/disclosure<br/>(PSD2 Art. 45/46 pre-payment info)
D-->>U: Disclosure: send 2000 NOK, fee 10 NOK,<br/>rate 0.087, receive 174 EUR,<br/>ETA 1-2 business days
U->>D: Confirm payment
D->>DB: Generate idempotency_key<br/>INSERT transaction (status: processing)
D->>A: POST /v1/payments/sepa-credit-transfers<br/>{debtorAccount: {iban: user_iban},<br/>instructedAmount: {currency: NOK, amount: 2010},<br/>creditorAccount: {iban: recipient_iban},<br/>creditorName: "Recipient Name"}
A-->>D: {paymentId, transactionStatus: RCVD,<br/>scaRedirect: "https://bank.no/sca/..."}
D-->>U: Redirect to bank SCA
U->>A: BankID authentication (dynamic linking:<br/>amount 2010 NOK to "Recipient Name")
A-->>U: Redirect to Drop callback
U->>D: Payment callback
D->>A: GET /v1/payments/{paymentId}/status
A-->>D: {transactionStatus: ACCP}
D->>DB: UPDATE transaction SET status = 'completed'
Note over A,CSM: Bank submits to SEPA CSM<br/>within cutoff time
A->>CSM: SEPA SCT message (pacs.008)
CSM->>RB: Route to recipient bank
RB->>RB: Credit recipient account
Note over CSM,RB: Settlement: T+1 business day<br/>(SCT Inst: < 10 seconds)
2.2 SEPA Specifications
| Property | SEPA SCT | SEPA SCT Inst |
|---|---|---|
| Standard | ISO 20022 pacs.008 | ISO 20022 pacs.008 |
| Max amount | 999,999,999.99 EUR | 100,000 EUR |
| Settlement time | T+1 business day | < 10 seconds (24/7/365) |
| Availability | Business days only | 24/7/365 |
| Coverage | 36 SEPA countries | Participating banks only |
| Drop usage | Default for EEA remittance | Preferred when available |
| Cut-off time | Bank-specific (typically 14:00-16:00 CET) | No cut-off |
3. Cross-Border Remittance with FX
For non-EEA corridors (Serbia, Pakistan, Turkey, etc.), Drop uses SWIFT gpi (Global Payments Innovation) or correspondent banking networks.
3.1 Cross-Border Flow with FX Conversion
sequenceDiagram
participant U as User
participant D as Drop API
participant FX as FX Rate Provider
participant A as User's Bank (ASPSP)
participant CB as Correspondent Bank
participant RB as Recipient Bank<br/>(e.g., Banca Intesa, Serbia)
U->>D: POST /api/transactions/remittance<br/>{recipientId: rec_1, amount: 2000}
D->>D: Lookup recipient: Marko Petrovic,<br/>Serbia, RSD, Banca Intesa
D->>FX: GET current NOK/RSD rate
FX-->>D: Rate: 10.17 (1 NOK = 10.17 RSD)
D->>D: Calculate:<br/>Send: 2000 NOK<br/>Fee: 2000 * 0.005 = 10 NOK<br/>Receive: 2000 * 10.17 = 20,340 RSD<br/>Total debit: 2010 NOK
D-->>U: Disclosure (PSD2 Art. 45):<br/>You send: 2,000 NOK<br/>Fee: 10 NOK (0.5%)<br/>Rate: 1 NOK = 10.17 RSD<br/>Recipient receives: 20,340 RSD<br/>Total cost: 2,010 NOK<br/>ETA: 2-4 business days
U->>D: Confirm payment
D->>D: Lock FX rate for 15 minutes<br/>(rate_locked_at = now, rate_expires_at = now + 15m)
D->>D: Generate idempotency_key<br/>INSERT transaction (status: processing)
D->>A: POST /v1/payments/cross-border-credit-transfers<br/>{debtorAccount: {iban}, instructedAmount: {NOK, 2010},<br/>creditorAccount: {bban: recipient_bank_account},<br/>creditorName: "Marko Petrovic",<br/>creditorAgent: {bic: DBDBRSBG}}
A-->>D: {paymentId, scaRedirect}
D-->>U: Redirect to bank SCA
U->>A: BankID authentication
A-->>U: Redirect to Drop callback
D->>A: GET /v1/payments/{paymentId}/status
A-->>D: {transactionStatus: ACCP}
D->>D: UPDATE transaction status = 'completed'
Note over A,CB: SWIFT gpi transfer<br/>UETR tracking ID assigned
A->>CB: MT103 / pacs.008 (NOK)
CB->>CB: FX conversion NOK to RSD<br/>(at correspondent bank rate)
CB->>RB: Credit in RSD
RB->>RB: Credit Marko's account<br/>20,340 RSD received
3.2 Supported Corridors
| Corridor | Currency | Exchange Rate (NOK to) | Rail | Estimated Delivery |
|---|---|---|---|---|
| Norway to Serbia | RSD | 10.17 | SWIFT gpi | 2-4 business days |
| Norway to Bosnia | BAM | 0.17 | SWIFT gpi | 2-4 business days |
| Norway to Poland | PLN | 0.374 | SEPA SCT (EEA) | 1-2 business days |
| Norway to Pakistan | PKR | 26.5 | SWIFT gpi | 2-4 business days |
| Norway to Turkey | TRY | 3.39 | SWIFT gpi | 2-4 business days |
| Norway to EU (EUR) | EUR | 0.087 | SEPA SCT/SCTInst | 1-2 days / instant |
Source: exchange_rates table, seeded in db.ts:234-237
4. FX Rate Management
4.1 Rate Sourcing
| Phase | Source | Refresh | Markup |
|---|---|---|---|
| MVP (current) | Static seed data in exchange_rates table |
Manual update | None (display rate = mid-market) |
| Phase 2 | ECB reference rates + commercial FX provider | Every 15 minutes | 0.1-0.3% spread |
| Phase 3 | Real-time feed from FX partner (e.g., Wise, CurrencyCloud) | Real-time (streaming) | Configurable per corridor |
4.2 Rate Lock Window
When a user initiates a remittance, the FX rate is locked for 15 minutes:
- User sees rate on the disclosure screen
- Rate is locked when user confirms (before SCA)
- If SCA completes within 15 minutes, the locked rate applies
- If SCA times out, the rate expires and must be re-quoted
This protects both the user (no surprise rate changes during authentication) and Drop (limited exposure to rate movement).
4.3 Rate Storage
| Column | Table | Description |
|---|---|---|
exchange_rates.rate |
exchange_rates |
Current mid-market rate (NOK to target) |
exchange_rates.updated_at |
exchange_rates |
Last rate update timestamp |
transactions.exchange_rate |
transactions |
Rate locked at transaction time |
transactions.send_amount |
transactions |
Amount in NOK (stored in oere) |
transactions.receive_amount |
transactions |
Amount in target currency (stored in subunits) |
5. Fee Calculation Model
5.1 Fee Structure
| Transaction Type | Fee Rate | Min Fee | Max Fee | Applied To |
|---|---|---|---|---|
| Remittance | 0.5% | 10 NOK | 500 NOK | Send amount (before FX) |
| QR Payment | 1.0% | 1 NOK | 1,000 NOK | Payment amount |
| AISP balance read | Free | - | - | No charge |
5.2 Fee Calculation
Remittance example (2,000 NOK to Serbia):
| Line Item | Calculation | Amount |
|---|---|---|
| Send amount | User input | 2,000.00 NOK |
| Fee (0.5%) | 2,000 * 0.005 | 10.00 NOK |
| Total debit | Send + Fee | 2,010.00 NOK |
| Exchange rate | From exchange_rates table |
10.17 RSD/NOK |
| Receive amount | 2,000 * 10.17 | 20,340.00 RSD |
QR Payment example (149 NOK at merchant):
| Line Item | Calculation | Amount |
|---|---|---|
| Payment amount | From QR scan | 149.00 NOK |
| Fee (1.0%) | 149 * 0.01 | 1.49 NOK |
| Total debit | Payment + Fee | 150.49 NOK |
| Merchant receives | Payment - merchant fee | 147.51 NOK |
5.3 Fee Code References
| Endpoint | Fee Logic | Source |
|---|---|---|
POST /api/transactions/remittance |
fee = amount * 0.005 |
transactions/remittance/route.ts |
POST /api/transactions/qr-payment |
fee = amount * 0.01 |
transactions/qr-payment/route.ts |
POST /api/transactions/disclosure |
Returns fee + FX pre-payment | transactions/disclosure/route.ts |
GET /api/rates/[currency] |
Returns fee: 0.005 (informational) |
rates/[currency]/route.ts |
5.4 Revenue Model Comparison
| Provider | Remittance Fee | QR/In-Store Fee |
|---|---|---|
| Drop | 0.5% | 1.0% |
| Western Union | 5-10% | N/A |
| Wise | 0.7-1.5% | N/A |
| Vipps | N/A | 1.75-2.75% |
6. Settlement & Reconciliation
6.1 Settlement Flow
Drop does not settle payments itself — the ASPSP (user's bank) handles settlement via interbank rails. Drop's role is to initiate and track payments.
| Step | Actor | Action |
|---|---|---|
| 1. Initiation | Drop | POST PISP request to ASPSP |
| 2. SCA | User + ASPSP | User authenticates at bank |
| 3. Acceptance | ASPSP | Bank accepts payment instruction |
| 4. Clearing | CSM (SEPA) / SWIFT | Message routed to recipient bank |
| 5. Settlement | Central bank / CSM | Funds transferred between banks |
| 6. Credit | Recipient bank | Recipient account credited |
| 7. Confirmation | Drop | Poll payment status, update transaction |
6.2 Reconciliation Process
flowchart TD
A[Scheduled reconciliation job<br/>runs every hour] --> B{Fetch transactions<br/>WHERE status = 'processing'<br/>AND created_at older than 1h}
B --> C[For each pending transaction]
C --> D[GET /v1/payments/paymentId/status<br/>from ASPSP]
D --> E{ASPSP status?}
E -->|ACSC / ACCP| F[UPDATE status = 'completed'<br/>SET completed_at = now]
E -->|RJCT| G[UPDATE status = 'failed'<br/>Log rejection reason]
E -->|PDNG / ACTC| H[Keep as 'processing'<br/>Check again next cycle]
E -->|API error| I[Log error, retry next cycle<br/>Circuit breaker if repeated]
F --> J[Create notification:<br/>'Overfoering fullfoert']
G --> K[Create notification:<br/>'Overfoering feilet'<br/>+ refund logic]
K --> L{Funds already debited?}
L -->|Yes| M[Initiate refund via ASPSP<br/>or manual intervention]
L -->|No| N[No action needed<br/>Payment was never executed]
6.3 ASPSP Transaction Statuses (Berlin Group)
| Status Code | Meaning | Drop Action |
|---|---|---|
RCVD |
Received (payment accepted for processing) | Transaction created, status = processing |
PDNG |
Pending (awaiting SCA or bank processing) | Keep as processing |
ACTC |
Accepted Technical (technical validation passed) | Keep as processing |
ACCP |
Accepted Customer Profile (customer checks passed) | Keep as processing |
ACSC |
Accepted Settlement Completed | Update to completed |
ACSP |
Accepted Settlement In Process | Keep as processing |
RJCT |
Rejected | Update to failed |
CANC |
Cancelled | Update to failed |
7. Idempotency & Retry Strategy
7.1 Idempotency
The transactions table has a unique index on idempotency_key (idx_tx_idempotency):
CREATE UNIQUE INDEX IF NOT EXISTS idx_tx_idempotency
ON transactions(idempotency_key)
WHERE idempotency_key IS NOT NULL;
Key generation: {userId}:{recipientId|merchantId}:{amount}:{timestamp_minute}
Flow:
- Before creating a transaction, check if
idempotency_keyalready exists - If exists, return the existing transaction (no duplicate)
- If not, insert new transaction with the key
- Pass the same key as
X-Request-IDto the ASPSP
7.2 Retry Strategy
| Failure Type | Retry? | Strategy |
|---|---|---|
| Network timeout to ASPSP | Yes | Exponential backoff: 1s, 2s, 4s (max 3 retries) |
| ASPSP returns 5xx | Yes | Exponential backoff with jitter, max 3 retries |
| ASPSP returns 4xx | No | Log error, fail immediately (client error) |
| SCA timeout | No | Mark as failed, user must restart |
| Duplicate detected | No | Return existing transaction |
| FX rate expired | No | Re-quote rate, user must re-confirm |
8. Pre-Payment Disclosure (PSD2 Art. 45/46)
Before initiating any payment, Drop must provide the user with clear information about costs and delivery. The POST /api/transactions/disclosure endpoint generates this.
8.1 Disclosure Content
| Information Item | PSD2 Article | Drop Implementation |
|---|---|---|
| Total amount debited | Art. 45(1)(a) | totalCost = amount + fee |
| Fee amount and percentage | Art. 45(1)(b) | fee, feePercentage |
| Exchange rate applied | Art. 45(1)(c) | exchangeRate |
| Amount received by recipient | Art. 45(1)(d) | receiveAmount in receiveCurrency |
| Estimated delivery time | Art. 45(1)(e) | estimatedDelivery |
| Currency of debit | Art. 45(1)(f) | Send currency (NOK) |
| Currency of credit | Art. 45(1)(g) | Receive currency |
8.2 Disclosure API Response
{
"amount": 2000,
"fee": 10,
"feePercentage": 0.5,
"exchangeRate": 10.17,
"receiveAmount": 20340,
"receiveCurrency": "RSD",
"estimatedDelivery": "2-4 business days",
"totalCost": 2010
}
8.3 Delivery Time Estimates
| Transaction Type | Corridor | Estimate |
|---|---|---|
| QR Payment | Domestic (Norway) | "Instant" |
| Remittance | EEA (SEPA) | "1-2 business days" |
| Remittance | Non-EEA (SWIFT) | "2-4 business days" |
9. Transaction Integrity
9.1 Atomic Operations
All financial operations use database transactions to ensure atomicity. The transaction() function in db.ts:123-179 wraps operations in BEGIN/COMMIT blocks with automatic ROLLBACK on error.
Key integrity checks:
WHERE balance >= ?prevents overdraw- PostgreSQL MVCC +
READ COMMITTEDisolation (default) prevents dirty reads; useSERIALIZABLEfor phantom read protection - Fee calculated and included in the single atomic debit
9.2 Consistency Guarantees
| Guarantee | Mechanism |
|---|---|
| No double-spend | WHERE balance >= ? in UPDATE + idempotency_key |
| No partial transactions | PostgreSQL BEGIN/COMMIT |
| No phantom reads | PostgreSQL MVCC snapshot isolation; use SERIALIZABLE isolation for full phantom read protection |
| No duplicate payments | Unique index on idempotency_key |
| No stale balances | balance_synced_at tracking + pre-payment AISP refresh |
10. Monitoring & Alerts
10.1 Key Metrics
| Metric | Threshold | Alert |
|---|---|---|
| Transaction success rate | < 95% over 1 hour | Critical alert |
| Average settlement time (SEPA) | > 48 hours | Warning |
| Average settlement time (SWIFT) | > 5 business days | Warning |
| Failed transaction rate | > 5% | Warning |
| Reconciliation mismatches | Any | Immediate alert |
| FX rate staleness | > 1 hour since last update | Warning |
10.2 Audit Trail
All payment operations are logged in the audit_log table:
| Action | Logged Data |
|---|---|
payment.initiated |
Transaction ID, amount, recipient, bank account |
payment.sca_completed |
Transaction ID, SCA method |
payment.completed |
Transaction ID, ASPSP status, settlement reference |
payment.failed |
Transaction ID, failure reason, ASPSP error |
payment.refund |
Original transaction ID, refund amount |
11. Cross-References
- Open Banking AISP/PISP: open-banking-aisp-pisp.md — Berlin Group API integration, consent lifecycle
- BankID OIDC: bankid-oidc-integration.md — Authentication (not payment SCA)
- Security Architecture: ../hld/security-architecture.md — Fraud detection, AML screening
- Remittance Flow (LLD): ../lld/flow-remittance.md — Step-by-step remittance UX
- Database Schema: ../../backend/DATABASE-SCHEMA.md —
transactions,exchange_rates,bank_accountstables - API Reference: ../../backend/API-REFERENCE.md — Transaction, disclosure, and rate endpoints
- Compliance: ../../security/COMPLIANCE.md — PSD2, AML readiness
Sentry Observability
Sentry Observability Integration
Error tracking, performance monitoring, alerting, and SLO/SLI definitions for the Drop fintech platform. Covers Sentry SDK integration, data scrubbing, Slack alerting, structured logging, and release tracking.
Observability Data Flow
flowchart LR
subgraph DropAPI["drop-api (Hono v4)"]
EH[Error Handler<br/>middleware/error-handler.ts]
Logger[Structured Logger<br/>lib/logger.ts]
SentryLib[Sentry SDK<br/>lib/sentry.ts]
AlertLib[Alert System<br/>lib/alerts.ts]
end
subgraph SentryCloud["Sentry Cloud"]
Issues[Issue Tracking]
Perf[Performance Monitoring<br/>Transaction traces]
Releases[Release Tracking<br/>Source maps + commits]
end
subgraph Alerting["Alert Channels"]
Slack[Slack Webhook<br/>SLACK_WEBHOOK_URL]
Console[stdout<br/>JSON structured logs]
end
subgraph Monitoring["Infrastructure"]
CloudWatch[AWS CloudWatch<br/>Container logs + metrics]
end
EH -->|Unhandled errors| SentryLib
EH -->|5xx errors| AlertLib
EH -->|All errors| Logger
SentryLib -->|captureException| Issues
SentryLib -->|Transaction traces| Perf
SentryLib -->|Release metadata| Releases
AlertLib -->|Error spikes| Slack
AlertLib -->|Startup/shutdown| Slack
Logger -->|JSON log entries| Console
Console -->|Container stdout| CloudWatch
SentryLib -.->|beforeSend| Scrubber[Data Scrubber<br/>Removes PII, auth tokens,<br/>fødselsnummer, passwords]
Sentry Configuration
SDK Setup
Source: src/drop-api/src/lib/sentry.ts
Package: @sentry/node
Initialization: Lazy — initialized on first captureError() or captureMessage() call.
Sentry.init({
dsn: process.env.SENTRY_DSN,
environment: process.env.NODE_ENV || "development",
release: `drop-api@${version}`, // from package.json
tracesSampleRate: parseFloat(process.env.SENTRY_TRACES_SAMPLE_RATE || "0.1"),
beforeSend(event, hint) {
// Scrub sensitive data before sending to Sentry
// See "Data Scrubbing" section below
},
});
Environment Variables
| Variable | Required | Default | Description |
|---|---|---|---|
SENTRY_DSN |
No | Not set (no-op mode) | Sentry project DSN. When absent, all Sentry calls are no-ops and errors are only logged to console. |
SENTRY_TRACES_SAMPLE_RATE |
No | 0.1 (10%) |
Percentage of transactions to trace for performance monitoring. |
NODE_ENV |
No | development |
Maps to Sentry environment tag. |
SLACK_WEBHOOK_URL |
No | Not set (skip alerts) | Slack incoming webhook for alert notifications. |
Sentry Project Structure
| Project | Environment | Purpose |
|---|---|---|
drop-api (staging) |
staging |
Pre-release error tracking, higher trace sample rate (0.5) |
drop-api (production) |
production |
Production error tracking, 10% trace sample rate |
drop-web (planned) |
production |
Client-side error tracking via @sentry/nextjs |
drop-mobile (planned) |
production |
React Native error tracking via @sentry/react-native |
Data Scrubbing
Source: sentry.ts:108-139
All events pass through a beforeSend hook that removes sensitive data before transmission to Sentry.
String Scrubbing Patterns
| Pattern | Replacement | Example |
|---|---|---|
password=... |
password=[REDACTED] |
Request bodies, exception messages |
pin=... |
pin=[REDACTED] |
Card PIN values |
cardNumber=... |
cardNumber=[REDACTED] |
Card numbers (PCI-DSS) |
cvv=... |
cvv=[REDACTED] |
Card verification values |
fødselsnummer=... |
fødselsnummer=[REDACTED] |
Norwegian national ID (PII) |
authorization:... |
authorization:[REDACTED] |
JWT tokens |
cookie:... |
cookie:[REDACTED] |
Session cookies |
Object Scrubbing
Sensitive keys are recursively scrubbed from event extra data, breadcrumbs, and request bodies:
- Keys containing:
password,pin,cardNumber,cvv,fødselsnummer,authorization,cookie - Values replaced with
[REDACTED]
Request Header Removal
The following headers are deleted from Sentry events entirely:
Sentry SDK Functions
Source: sentry.ts:141-244
| Function | Purpose | When Used |
|---|---|---|
captureError(error, context?) |
Capture exception with optional user ID, request ID, tags, and extra data. Returns Sentry event ID. | error-handler.ts on 5xx errors |
captureMessage(message, level?, context?) |
Capture informational message with severity level. | Manual diagnostic logging |
setUser(userId, metadata?) |
Associate current scope with user ID for error grouping. | After successful authentication |
clearUser() |
Remove user association from current scope. | On logout |
addBreadcrumb(message, category, level?, data?) |
Add navigation/action breadcrumb for error context. Data is scrubbed. | Key user actions |
setTag(key, value) |
Set tag on current scope for filtering. | Environment, feature flags |
flush(timeout?) |
Flush pending events on graceful shutdown. Default 2000ms. | Process exit handler |
isSentryEnabled() |
Check if SENTRY_DSN is set. |
Conditional logic |
Dual Output Pattern
All Sentry functions also log to console.error / console.log. This ensures errors are captured in CloudWatch logs even if Sentry is unavailable, and provides a local development experience without requiring a Sentry DSN.
Alert System
Source: src/drop-api/src/lib/alerts.ts
Slack Alerting
Alerts are sent to a Slack channel via incoming webhook (SLACK_WEBHOOK_URL).
| Alert Type | Severity | Trigger | Cooldown |
|---|---|---|---|
| Error spike | critical |
5+ errors within 60 seconds | 10 minutes per unique title |
| API startup | info |
Application startup | None |
| API shutdown | info |
Graceful shutdown | None |
Error Spike Detection
Source: alerts.ts:65-83
Window: 60 seconds (sliding)
Threshold: 5 errors
Cooldown: 10 minutes per alert title
The trackError() function is called from the global error handler (error-handler.ts:10) for every 5xx error. It maintains a sliding window of error timestamps and triggers a Slack alert when the spike threshold is breached.
Alert Message Format
CRITICAL *Error spike detected*
7 errors in last minute
_2026-02-21T12:00:00.000Z_
View in Sentry (link if available)
Structured Logging
Source: src/drop-api/src/lib/logger.ts
Log Entry Format
All logs are written to stdout as newline-delimited JSON (NDJSON):
{
"timestamp": "2026-02-21T12:00:00.000Z",
"level": "info",
"message": "BankID login successful",
"requestId": "550e8400-e29b-41d4-a716-446655440000",
"metadata": {
"userId": "usr_a1b2c3d4e5f6g7h8",
"method": "bankid"
}
}
Log Levels
| Level | Usage | Example |
|---|---|---|
debug |
Detailed diagnostic info | SQL query parameters |
info |
Normal operations | Login success, transaction created |
warn |
Recoverable issues | Token verification failed, rate limit approached |
error |
Errors requiring attention | BankID callback error, database connection failure |
Request Context
Each request gets a unique requestId (from x-request-id header or crypto.randomUUID()). This ID is:
- Set as a Hono context variable (
app.ts:34) - Returned in the
x-request-idresponse header (app.ts:36) - Included in all log entries for the request
- Attached as a Sentry tag for cross-referencing
Error Handling Flow
sequenceDiagram
participant Client
participant Route as Route Handler
participant ErrHandler as Global Error Handler
participant Logger as Structured Logger
participant Sentry as Sentry SDK
participant Alerts as Alert System
participant Slack as Slack Webhook
Route->>Route: Business logic throws error
alt HTTPException
Route-->>ErrHandler: HTTPException (known status)
ErrHandler->>Logger: Log error with request context
ErrHandler-->>Client: { error: "http_error", message: "..." }, status
else Unhandled Error
Route-->>ErrHandler: Error thrown
ErrHandler->>Logger: logger.error(message, stack, path, method)
ErrHandler->>Sentry: captureError(error)
Sentry->>Sentry: beforeSend → scrub PII
Sentry->>Sentry: Send to Sentry Cloud
ErrHandler->>Alerts: trackError(error)
Alerts->>Alerts: Add to sliding window
alt Error spike (5+ in 60s)
Alerts->>Slack: POST webhook (CRITICAL alert)
end
ErrHandler-->>Client: { error: "internal_error", message: "An unexpected error occurred" }, 500
end
SLO/SLI Definitions
Service Level Indicators (SLIs)
| SLI | Measurement | Source |
|---|---|---|
| Availability | Percentage of successful health check responses (200) | App Runner health checks, Cloudflare origin monitoring |
| Latency | p50, p95, p99 response time for API endpoints | Sentry performance monitoring, CloudWatch |
| Error Rate | Percentage of 5xx responses out of total requests | Sentry issue counts, structured logs |
| Database Latency | dbLatencyMs from /v1/health endpoint |
Health check response, CloudWatch custom metric |
| Auth Success Rate | Percentage of successful BankID authentications | Sentry custom metrics, audit_log table |
Service Level Objectives (SLOs)
| SLO | Target | Measurement Window | Error Budget |
|---|---|---|---|
| API Availability | 99.9% | 30-day rolling | 43 minutes/month downtime |
| API Latency (p95) | < 300ms | 30-day rolling | 5% of requests may exceed |
| API Latency (p99) | < 1000ms | 30-day rolling | 1% of requests may exceed |
| Error Rate (5xx) | < 0.1% | 30-day rolling | 1 in 1000 requests |
| Database Latency | < 50ms | 30-day rolling | 5% of queries may exceed |
| Auth Success Rate | > 99% | 30-day rolling | 1% of auth attempts may fail |
| Transaction Success Rate | > 99.5% | 30-day rolling | 0.5% of transactions may fail |
Performance Targets (from architecture document)
| Metric | Target | Source |
|---|---|---|
| First Contentful Paint (FCP) | < 1.5s | architecture-document.md section 8 |
| Largest Contentful Paint (LCP) | < 2.5s | architecture-document.md section 8 |
| Time to First Byte (TTFB) | < 200ms | architecture-document.md section 8 |
| API p95 response time | < 300ms | architecture-document.md section 8 |
| Lighthouse score | > 90 | architecture-document.md section 8 |
| Build time | < 60s | architecture-document.md section 8 |
Alert Rule Configuration
| Rule | Condition | Severity | Channel | Cooldown |
|---|---|---|---|---|
| Error spike | 5+ errors in 60s window | critical |
Slack webhook | 10 min |
| API startup | Application start event | info |
Slack webhook | None |
| API shutdown | Graceful shutdown event | info |
Slack webhook | None |
| Health check failure | /v1/health returns 503 |
critical |
App Runner auto-restart + CloudWatch alarm | N/A |
| High error rate | 5xx rate > 5% for 2 minutes | critical |
CloudWatch alarm → SNS → Slack | 5 min |
| High latency | p95 > 500ms for 3 minutes | warning |
CloudWatch alarm → SNS → Slack | 5 min |
| Database slow query | Query > 1000ms | warning |
Sentry performance alert | 10 min |
| Auth failure spike | 10+ auth failures in 5 minutes | warning |
Sentry alert rule | 15 min |
Release Tracking
Source Map Upload
# In CI/CD pipeline after build
sentry-cli releases new "drop-api@${VERSION}"
sentry-cli releases set-commits "drop-api@${VERSION}" --auto
sentry-cli sourcemaps upload --release "drop-api@${VERSION}" ./dist/
sentry-cli releases finalize "drop-api@${VERSION}"
Release Metadata
Each release in Sentry includes:
- Version:
drop-api@{package.json version}(e.g.,drop-api@0.1.0) - Environment:
development,staging, orproduction - Commits: Automatically linked via
--autoflag - Source maps: Uploaded for stack trace deobfuscation
Cross-References
- Error handler:
src/drop-api/src/middleware/error-handler.ts— Global error handler that triggers Sentry capture - Logger:
src/drop-api/src/lib/logger.ts— Structured JSON logging - Sentry SDK:
src/drop-api/src/lib/sentry.ts— Full Sentry integration with data scrubbing - Alert system:
src/drop-api/src/lib/alerts.ts— Slack webhook alerting with spike detection - Security architecture: SECURITY-ARCHITECTURE.md — Security monitoring context
- Deployment: deployment-architecture.md — CloudWatch and infrastructure monitoring
Sumsub KYC Integration
Sumsub KYC/AML Integration
Identity verification architecture for the Drop fintech platform using Sumsub as the KYC/AML provider. Covers webhook architecture, applicant lifecycle, document check types, risk level definitions, SDK integration for web and mobile, idempotency handling, and error recovery.
KYC Verification Flow
sequenceDiagram
participant User as User (Web/Mobile)
participant Client as Drop Client
participant API as drop-api (Hono)
participant DB as Database
participant Sumsub as Sumsub API
participant SumsubSDK as Sumsub SDK Widget
Note over User,SumsubSDK: Step 1: Initiate KYC
User->>Client: Navigate to KYC verification
Client->>API: POST /v1/user/kyc/initiate
API->>API: Verify auth (authMiddleware)
API->>DB: Check users.kyc_status
alt Already approved
DB-->>API: kyc_status = 'approved'
API-->>Client: { status: "approved" }
else Needs verification
API->>Sumsub: POST /resources/applicants<br/>{ externalUserId, email, levelName }
Sumsub-->>API: { id: applicantId }
API->>Sumsub: POST /resources/accessTokens<br/>{ userId, ttlInSecs: 3600 }
Sumsub-->>API: { token: accessToken }
API->>DB: UPDATE users SET kyc_status = 'pending'
API->>DB: INSERT INTO screening_results
API-->>Client: { status: "pending", redirectUrl, externalId }
end
Note over User,SumsubSDK: Step 2: Document Verification (in Sumsub SDK)
Client->>SumsubSDK: Initialize SDK with accessToken
User->>SumsubSDK: Upload ID document (passport/ID card)
User->>SumsubSDK: Take selfie (liveness check)
SumsubSDK->>Sumsub: Submit documents for review
Sumsub->>Sumsub: AI + human review
Note over User,SumsubSDK: Step 3: Webhook Callback
Sumsub->>API: POST /v1/webhooks/sumsub<br/>{ type, applicantId, reviewResult }
API->>API: Verify HMAC signature
API->>API: Check idempotency (screening_results)
API->>DB: UPDATE users SET kyc_status = 'approved'/'rejected'
API->>DB: INSERT INTO screening_results
API->>DB: INSERT INTO audit_log
API-->>Sumsub: 200 OK
Applicant State Machine
stateDiagram-v2
[*] --> init: POST /resources/applicants
init --> pending: Documents submitted
pending --> queued: Automated checks passed, queued for review
queued --> completed: Review finished
pending --> completed: Fast-track (low risk)
completed --> [*]
state completed {
[*] --> GREEN: All checks passed
[*] --> RED: Verification failed
[*] --> RETRY: Resubmission requested
}
note right of init
User created in Sumsub
SDK widget opened
No documents yet
end note
note right of pending
Documents uploaded
AI processing
end note
note right of queued
AI checks passed
Awaiting human review
(if required by level)
end note
note left of GREEN
kyc_status = 'approved'
Full access granted
end note
note left of RED
kyc_status = 'rejected'
Financial operations blocked
rejectLabels explains why
end note
note left of RETRY
kyc_status = 'pending'
User prompted to resubmit
end note
Status Mapping
| Sumsub Status | Sumsub Review Answer | Drop kyc_status |
User Impact |
|---|---|---|---|
init |
N/A | pending |
Cannot perform financial operations |
pending |
N/A | pending |
Cannot perform financial operations |
queued |
N/A | pending |
Cannot perform financial operations |
completed |
GREEN |
approved |
Full access to remittance and QR payments |
completed |
RED |
rejected |
Blocked from financial operations, can appeal |
completed |
RETRY |
pending |
Prompted to resubmit documents |
onHold |
N/A | pending |
Under manual review |
Webhook Architecture
Webhook Endpoint
URL: POST /v1/webhooks/sumsub
Authentication: HMAC-SHA256 signature verification
Webhook Events
| Event Type | Trigger | Drop Action |
|---|---|---|
applicantCreated |
Applicant record created in Sumsub | Log to audit_log, no status change |
applicantPending |
Documents submitted, verification in progress | Update kyc_status to pending if not already |
applicantReviewed |
Verification completed (approved or rejected) | Update kyc_status based on reviewResult.reviewAnswer |
applicantOnHold |
Manual review required | Log to audit_log, status remains pending |
applicantActionPending |
Additional action required from user | Notify user via notifications table |
applicantReset |
Application reset for resubmission | Reset kyc_status to pending |
Signature Verification
POST /v1/webhooks/sumsub
Headers:
X-Payload-Digest: HMAC-SHA256(request_body, SUMSUB_SECRET_KEY)
Content-Type: application/json
Verification logic:
- Read raw request body
- Compute
HMAC-SHA256(body, SUMSUB_SECRET_KEY) - Compare with
X-Payload-Digestheader (timing-safe comparison) - Reject with 401 if signature mismatch
Retry Policy
Sumsub retries webhook delivery on non-2xx responses:
| Attempt | Delay | Total Wait |
|---|---|---|
| 1 | Immediate | 0s |
| 2 | 30s | 30s |
| 3 | 2m | 2m 30s |
| 4 | 10m | 12m 30s |
| 5 | 30m | 42m 30s |
| 6 | 1h | 1h 42m 30s |
| 7 | 2h | 3h 42m 30s |
| 8 (final) | 4h | 7h 42m 30s |
After 8 failed attempts, the webhook is marked as failed in Sumsub dashboard.
Idempotency Handling
Webhooks may be delivered multiple times. Drop handles this via the screening_results table:
- On webhook receipt, check if
screening_resultsalready has an entry with matchinguser_id+screening_type+ sameresult - If duplicate: return 200 OK immediately (acknowledge but don't process)
- If new or changed result: process the update, insert new
screening_resultsrecord
Database Records Created Per Webhook
| Table | Record | Purpose |
|---|---|---|
users |
UPDATE kyc_status |
Primary KYC status |
screening_results |
INSERT | Detailed verification result with timestamp |
audit_log |
INSERT | Immutable audit trail |
notifications |
INSERT (if status changed) | User notification |
Document Check Types
Verification Level: basic-kyc-level
| Check | Type | Description | Required |
|---|---|---|---|
| Document authenticity | IDENTITY |
Verify ID document is genuine (not altered/forged) | Yes |
| Liveness check | SELFIE |
Verify real person (not photo/video) | Yes |
| Face match | SELFIE |
Match selfie to document photo | Yes |
| Data extraction | IDENTITY |
OCR extraction of name, DOB, document number | Yes |
| Sanctions screening | SCREENING |
Check against OFAC, UN, EU sanctions lists | Yes |
| PEP screening | SCREENING |
Politically Exposed Persons database check | Yes |
| Adverse media | SCREENING |
News and media screening for negative coverage | Optional |
Accepted Document Types
| Document Type | Sumsub Code | Countries |
|---|---|---|
| Passport | PASSPORT |
All |
| National ID card | ID_CARD |
EEA countries |
| Driver's license | DRIVERS |
Norway, Sweden, Denmark, Finland |
| Residence permit | RESIDENCE_PERMIT |
Norway (non-citizen residents) |
Risk Level Definitions
Risk Level Matrix
| Risk Level | Score Range | Criteria | Drop Action |
|---|---|---|---|
| Low | 0-30 | All checks passed, no PEP/sanctions match, standard country | kyc_status = 'approved', standard transaction limits |
| Medium | 31-60 | Minor issues (document quality, partial name match), non-sanctioned PEP | kyc_status = 'approved', enhanced monitoring via aml_alerts, lower initial limits |
| High | 61-80 | Potential PEP match, high-risk country, document concerns | kyc_status = 'pending', manual review required, flag in screening_results |
| Critical | 81-100 | Sanctions match, confirmed fraud indicators, age verification failure | kyc_status = 'rejected', immediate block, create aml_alerts record, file potential STR |
Risk Assessment Factors
| Factor | Weight | Source |
|---|---|---|
| Document authenticity score | 30% | Sumsub AI analysis |
| Liveness/face match score | 20% | Sumsub biometric analysis |
| Country risk (origin/destination) | 20% | Drop internal risk scoring |
| PEP/sanctions screening | 20% | Sumsub screening databases |
| Transaction pattern analysis | 10% | Drop aml_alerts historical data |
High-Risk Countries for Drop
Based on remittance corridor analysis and FATF grey/black lists:
| Category | Countries | Additional Controls |
|---|---|---|
| Standard | Norway, Sweden, Denmark, Finland, EU/EEA | Standard KYC |
| Enhanced due diligence | Turkey, Pakistan | Enhanced monitoring, lower limits |
| Restricted | FATF grey list countries | Manual review required |
| Blocked | OFAC/UN sanctioned countries | Automatic rejection |
SDK Integration
Web Integration (WebSDK)
// Initialize Sumsub WebSDK in drop-web
import snsWebSdk from '@sumsub/websdk';
function launchSumsubWidget(accessToken: string, applicantId: string) {
const snsWebSdkInstance = snsWebSdk.init(accessToken, () => {
// Token expiry handler — request new token from API
return fetch('/v1/user/kyc/refresh-token')
.then(res => res.json())
.then(data => data.token);
})
.withConf({
lang: 'nb', // Norwegian
theme: 'dark',
uiConf: {
customCssStr: ':root { --primary-color: #0B6E35; }', // Drop brand green
},
})
.withOptions({
addViewportTag: false,
adaptIframeHeight: true,
})
.on('onError', (error) => {
captureError(error, { tags: { component: 'sumsub-websdk' } });
})
.on('onApplicantStatusChanged', (payload) => {
if (payload.reviewResult?.reviewAnswer === 'GREEN') {
// Redirect to dashboard
window.location.href = '/dashboard';
}
})
.build();
snsWebSdkInstance.launch('#sumsub-container');
}
Mobile Integration (React Native SDK)
// Initialize Sumsub React Native SDK in drop-mobile
import SNSMobileSDK from '@sumsub/react-native-mobilesdk-plugin';
async function launchSumsubMobile(accessToken: string) {
const snsMobileSDK = SNSMobileSDK.init(accessToken, async () => {
// Token expiry handler
const response = await fetch(`${API_URL}/v1/user/kyc/refresh-token`, {
headers: { Authorization: `Bearer ${authToken}` },
});
const data = await response.json();
return data.token;
})
.withHandlers({
onStatusChanged: (event) => {
if (event.newStatus === 'Approved') {
navigation.navigate('Dashboard');
}
},
onError: (error) => {
Sentry.captureException(error);
},
})
.withLocale('nb')
.withTheme({
primaryColor: '#0B6E35',
})
.build();
const result = await snsMobileSDK.launch();
return result;
}
Error Recovery
Failure Scenarios and Recovery
| Failure | Detection | Recovery |
|---|---|---|
| Sumsub API timeout (applicant creation) | 30s timeout in kyc.ts:44 |
Return rejected with error message, user can retry |
| Sumsub API timeout (status check) | 30s timeout in kyc.ts:148 |
Return rejected, poll again on next request |
| Access token generation failure | Non-200 response from /resources/accessTokens |
Return pending with applicant ID (applicant created but no widget) |
| Webhook delivery failure | Sumsub retry (8 attempts over 7h) | Automatic retry by Sumsub |
| Webhook signature mismatch | 401 response to webhook | Alert ops team, check SUMSUB_SECRET_KEY rotation |
| Database write failure on webhook | Transaction rollback | Return 500 to trigger Sumsub retry |
| Duplicate webhook | Idempotency check via screening_results |
Acknowledge with 200, skip processing |
| SDK widget crash | onError callback |
Capture in Sentry, show user-friendly error with retry option |
Manual Recovery Procedures
If a user is stuck in pending status after Sumsub has completed review:
- Check
screening_resultstable for latest result - Query Sumsub API:
GET /resources/applicants/{externalUserId}/status - If Sumsub shows
GREENbut DB showspending: manually update via admin endpoint - If Sumsub shows
RED: communicate rejection reason to user
Environment Variables
| Variable | Required | Description |
|---|---|---|
SUMSUB_API_URL |
Production | Sumsub API base URL (e.g., https://api.sumsub.com) |
SUMSUB_APP_TOKEN |
Production | Application token from Sumsub dashboard |
SUMSUB_SECRET_KEY |
Production | Secret key for HMAC signature verification |
SUMSUB_WEBHOOK_SECRET |
Production | Separate secret for webhook payload verification |
NEXT_PUBLIC_SERVICE_MODE |
All | When mock: uses auto-approve mock, no Sumsub calls |
Demo Mode Behavior
Source: lib/services/kyc.ts:26-28
When NEXT_PUBLIC_SERVICE_MODE=mock (demo mode):
initiateKyc()returns{ status: "approved" }immediatelycheckKycStatus()returns{ status: "approved" }immediately- No Sumsub API calls are made
- Users created via BankID get
kyc_status = 'approved'automatically (bankid.ts:233)
The mock Sumsub service (mock-sumsub.ts) provides a full simulation for UI development with applicant creation, document submission, and asynchronous verification (3-second delay, 90% approval rate).
Cross-References
- KYC service:
src/drop-app/src/lib/services/kyc.ts— Production KYC initiation and status check - Mock Sumsub:
src/drop-app/src/lib/services/mock-sumsub.ts— Demo mode mock implementation - BankID integration: AUTHENTICATION.md — BankID auto-approves KYC
- Database schema: DATABASE-SCHEMA.md —
screening_results,aml_alerts,users.kyc_status - Compliance: COMPLIANCE.md — AML/KYC regulatory requirements
- Security: SECURITY-ARCHITECTURE.md — Role-based access, KYC status enforcement
AISP Registration Serbia — NBS Response (Tok)
AISP Registracija u Srbiji — NBS Odgovor
Datum: 2026-03-18 Izvor: Narodna Banka Srbije (platni.sistem@nbs.rs) Kontekst: Odgovor na naš upit od 03.03.2026 u ime ALAI Holding AS (org.nr. 932 516 136) Namjena: Osnivanje ALAI Tech d.o.o. u Srbiji za AISP uslugu na srpskom tržištu
Ključni nalazi
Zakonski okvir
- Član 82a Zakona o platnim uslugama (RS br. 139/2014, 44/2018, 64/2024) — posebna pravila za firme koje pružaju ISKLJUČIVO AISP uslugu
- Ako firma pruža i druge platne usluge, primjenjuje se član 82 (stroži zahtjevi)
Postupak registracije
- Osnovati d.o.o. u Srbiji (ALAI Tech d.o.o.)
- Pripremiti dokumentaciju po članu 82 stav 1 (tačke 1, 2, 4, 5, 8, 10, 12, 15, 19, 20, 21, 23)
- Zaključiti ugovor o osiguranju od odgovornosti sa društvom za osiguranje
- Podnijeti zahtjev za registraciju Narodnoj banci Srbije
Osiguranje (OBAVEZNO)
- Polisa mora pokrivati teritorije na kojima se pruža usluga
- Pokriva odgovornost za: neovlašćen pristup informacijama o platnom računu, pristup sa ciljem prevare, neovlašćeno korišćenje informacija
- Alternativa: drugo odgovarajuće sredstvo za pokriće odgovornosti
Kapitalni zahtjev
- Za čisti AISP (samo informacijske usluge): nema minimalnog kapitalnog zahtjeva (za razliku od PISP)
- Ovo je značajna prednost za ulazak na tržište
Zakonski link
Napomena
Email od NBS je označen kao "UNUTRAŠNJA UPOTREBA" — puni tekst (9200+ chars) trunciran u našem sistemu. Potrebno dohvatiti kompletnu verziju za sve detalje o potrebnim dokumentima.
Sljedeći koraci
- Dohvatiti puni tekst emaila sa svim detaljima dokumentacije
- Pripremiti listu potrebnih dokumenata po članu 82a
- Istražiti opcije osiguranja od odgovornosti u Srbiji
- Planirati osnivanje ALAI Tech d.o.o.
Database Architecture
Database design, migration strategy, lifecycle, audit, indexing
Database Design
Database Design
Version: 1.0 Date: 2026-02-21 Status: Approved Owner: Database Architect
Design Philosophy
Drop's database schema is designed around three principles:
- Simplicity over abstraction. 19 tables for a well-scoped fintech app. No generic "entities" table, no EAV patterns. Each table maps to a clear domain concept.
- Compliance by design. 7 of 19 tables exist solely for regulatory requirements (GDPR, AML, PSD2). They were added as a compliance infrastructure layer, not retrofitted.
- PostgreSQL-native. Schema is defined in Drizzle ORM (
src/shared/db/schema.ts) targeting PostgreSQL 16. PostgreSQL-native features (JSONB,FOR UPDATE,RETURNING, arrays) are available and used where beneficial. See ADR-014.
Why 19 Tables
The table count reflects the actual domain:
- 12 core tables cover the business logic: users, their bank accounts, recipients, merchants, transactions, exchange rates, cards, sessions, notifications, settings, spending limits, and rate limits.
- 7 compliance tables were added as a single compliance infrastructure layer: audit logging, AML alerts, STR reports, sanctions screening, consent tracking, data access requests, and complaints.
No table is redundant. No table combines unrelated concerns.
Complete Schema ERD
erDiagram
users ||--|| settings : "1:1 preferences"
users ||--o{ bank_accounts : "1:N linked accounts"
users ||--o{ cards : "1:N payment cards"
users ||--o{ recipients : "1:N saved recipients"
users ||--o{ transactions : "1:N financial ops"
users ||--o{ sessions : "1:N auth sessions"
users ||--o{ notifications : "1:N alerts"
users ||--o{ spending_limits : "1:N limits"
users ||--o{ merchants : "1:N merchant profiles"
users ||--o{ audit_log : "1:N audit entries"
users ||--o{ aml_alerts : "1:N AML flags"
users ||--o{ str_reports : "1:N STR filings"
users ||--o{ screening_results : "1:N screenings"
users ||--o{ consents : "1:N consents"
users ||--o{ data_access_requests : "1:N DSARs"
users ||--o{ complaints : "1:N complaints"
transactions }o--o| recipients : "remittance target"
transactions }o--o| merchants : "QR payment target"
transactions ||--o{ aml_alerts : "triggers alert"
aml_alerts ||--o{ str_reports : "escalates to STR"
cards ||--o{ spending_limits : "card-level limits"
users {
text id PK "usr_ + 16 hex"
text email UK "NOT NULL"
text password_hash "NOT NULL, default EIDONLY"
text auth_provider "default bankid"
text first_name "NOT NULL"
text last_name "NOT NULL"
text phone "nullable"
text date_of_birth "nullable"
text kyc_status "CHECK pending|approved|rejected"
text role "CHECK user|merchant"
text risk_level "CHECK low|medium|high"
text pep_status "CHECK not_checked|clear|match|pending_review"
integer sanctions_cleared "default 0"
text kyc_method "CHECK bankid|document|simplified"
text kyc_verified_at "nullable"
text national_id_hash "nullable, indexed WHERE NOT NULL"
text deleted_at "nullable, soft delete"
text created_at "default datetime now"
}
transactions {
text id PK
text user_id FK "NOT NULL"
text type "CHECK remittance|qr_payment"
text status "CHECK processing|completed|failed"
integer amount "NOT NULL, in minor units"
text currency "default NOK"
integer fee "default 0"
text recipient_id FK "nullable"
text merchant_id FK "nullable"
integer send_amount "nullable"
text send_currency "nullable"
integer receive_amount "nullable"
text receive_currency "nullable"
real exchange_rate "nullable"
text purpose_code "nullable"
text idempotency_key "UNIQUE WHERE NOT NULL"
text created_at "default datetime now"
text completed_at "nullable"
}
bank_accounts {
text id PK
text user_id FK "NOT NULL"
text bank_name "NOT NULL"
text account_number "NOT NULL"
text iban "nullable"
integer balance "default 0, cached AISP"
text balance_synced_at "nullable"
text currency "default NOK"
integer is_primary "default 0"
text connected_at "default datetime now"
}
merchants {
text id PK
text user_id FK "NOT NULL"
text business_name "NOT NULL"
text org_number "UNIQUE NOT NULL"
text address "nullable"
text bank_account "NOT NULL"
real fee_rate "default 0.01"
text status "default active"
text qr_hmac_key "NOT NULL, random 32 bytes"
text created_at "default datetime now"
}
recipients {
text id PK
text user_id FK "NOT NULL"
text name "NOT NULL"
text country "NOT NULL"
text currency "NOT NULL"
text bank_account "NOT NULL"
text bank_name "nullable"
text created_at "default datetime now"
}
Table-by-Table Design Rationale
Core Tables
users
Normalization: 3NF. All columns are functionally dependent on the primary key.
Design decisions:
idusesusr_prefix + 16 hex chars for readability and collision avoidance across distributed systems.password_hashdefaults to'EIDONLY'sentinel value -- BankID-only users have no password. This avoids nullable password fields that complicate auth logic.auth_providertracks how the user registered (bankid). Supports future Vipps Login without schema changes.national_id_hashstores SHA-256 of Norwegian fodselsnummer. Enables user deduplication across auth providers without storing the raw national ID.deleted_atenables soft delete for GDPR erasure while retaining records for AML legal obligations (5-year retention).risk_level,pep_status,sanctions_clearedare denormalized onto the user for fast access during transaction authorization -- these are checked on every financial operation.- KYC fields (
kyc_status,kyc_method,kyc_verified_at) are on the user table rather than a separate KYC table because there is a 1:1 relationship and the fields are accessed on every authenticated request.
transactions
Normalization: 3NF with intentional denormalization.
Design decisions:
amount,fee,send_amount,receive_amountare stored as integers in minor units (ore for NOK, para for RSD, etc.) to avoid floating-point precision issues.- Polymorphic reference:
recipient_idis set for remittances,merchant_idfor QR payments. Never both. This avoids a separate join table for a simple either/or relationship. exchange_rateis denormalized (snapshot at transaction time) because rates change. The rate at execution time must be preserved for audit and dispute resolution.idempotency_keywith a unique partial index (WHERE idempotency_key IS NOT NULL) prevents duplicate transaction submission without requiring every transaction to have a key.purpose_codesupports remittance regulatory requirements (some corridors require a transfer purpose).completed_atis separate fromcreated_atto track processing duration.
bank_accounts
Normalization: 3NF.
Design decisions:
balanceis a cached read-only value from AISP, not a Drop-held balance. This is the most important design detail in the entire schema.balance_synced_attracks when the balance was last refreshed from the bank via Open Banking.is_primaryflag determines which account is used for transactions by default (1 = primary, 0 = secondary).- No unique constraint on
account_numberbecause the same bank account could theoretically appear under different user records (shared accounts).
recipients
Normalization: 3NF.
Design decisions:
- Scoped to user (
user_idFK) -- recipients are private, not shared. countryandcurrencystored as free text validated at the API layer (not as FK to a countries table). This avoids over-engineering for 5-6 supported corridors.bank_accountstores the full foreign account number. Format varies by country (IBAN for EU, local format for others).
merchants
Normalization: 3NF.
Design decisions:
org_numberis UNIQUE -- one merchant registration per Norwegian organization number (9 digits).qr_hmac_keyis generated server-side (hex(randomblob(32))) for QR code integrity verification. Each merchant gets a unique key.fee_ratedefaults to0.01(1%). Stored per merchant to allow variable pricing in the future.user_idFK links the merchant to the user who registered it. A user's role is upgraded tomerchantupon registration.
exchange_rates
Normalization: 3NF.
Design decisions:
idusesINTEGER PRIMARY KEY AUTOINCREMENT-- the only auto-increment ID in the schema. Exchange rates are system-managed, not user-created, so prefixed IDs are unnecessary.- Only stores NOK-to-X rates (6 corridors). Inverse rates are calculated at runtime.
- No historical rate tracking in this table. Transaction records snapshot the rate at execution time.
sessions
Normalization: 3NF.
Design decisions:
token_hashstores SHA-256 of the JWT, not the JWT itself. This prevents session hijacking even if the database is compromised.revokedflag (0/1) enables server-side session invalidation without waiting for JWT expiry.- Multiple active sessions per user are allowed (different devices).
settings
Normalization: 3NF. 1:1 with users.
Design decisions:
user_idas PRIMARY KEY enforces the 1:1 relationship at the database level.- Created lazily on first
GET /api/settings(INSERT default if not exists). - Defaults:
currency='NOK',language='nb',push_enabled=1,email_enabled=1.
notifications
Normalization: 3NF.
Design decisions:
readflag (0/1) for marking notifications as read in batch.- No foreign key to the triggering entity (transaction, system event) --
typefield categorizes the notification source. - Designed for high volume with eventual cleanup (no retention policy yet).
cards (FUTURE)
Normalization: 3NF.
Design decisions:
- Feature-flagged. Table exists in schema but endpoints return 404 when disabled.
- Only stores
last_fourandtoken_ref-- never full card number or CVV (PCI-DSS compliance). pin_hashadded via runtime migration for backward compatibility.statussupports freeze/unfreeze without deletion (active->frozen->active).
spending_limits (FUTURE)
Normalization: 3NF.
Design decisions:
card_idis nullable -- supports user-level limits (no specific card) or card-level limits.limit_typevalues (daily,weekly,monthly,transaction) enforced at API level.- Limits are replaced, not accumulated (PUT semantics per limit type per card).
rate_limits
Normalization: 3NF (trivial -- 3 columns).
Design decisions:
keyis the IP address (TEXT PK). Simple key-value store.reset_atis a Unix timestamp. Expired entries are cleaned every 100 rate limit checks inmiddleware/rate-limit.ts.- Not a "real" domain table -- it is infrastructure. Could be replaced by Redis in production but works fine in SQLite/PostgreSQL.
Compliance Tables
audit_log
user_idis nullable because some audit events occur before authentication (e.g., failed login attempts).resource_type+resource_idenable generic resource tracking without polymorphic FKs.detailsis a TEXT field (JSON string) for flexible event-specific data.request_idfor correlating multiple audit entries from a single API request.- Two indexes:
user_idandactionfor the primary query patterns. (Note: notimestampindex exists in the implementation — onlyidx_audit_log_userandidx_audit_log_actionare created indb.ts.)
aml_alerts
severity(low/medium/high/critical) determines investigation priority and escalation timelines.statusworkflow:open->investigating->resolved|escalated|filed.reviewed_byandreviewed_attrack the compliance officer's review.transaction_idFK links to the specific transaction that triggered the alert.
str_reports
- Filed with Okokrim/EFE (Norwegian financial intelligence unit).
reference_numberstores the authority-assigned reference after submission.alert_idlinks back to the originating AML alert.- Immutable after
status = 'submitted'-- regulatory requirement.
screening_results
screening_type(pep/sanctions/adverse_media) supports multiple screening categories.providertracks which screening service was used (future: Sumsub, Refinitiv, etc.).match_detailsstores full match information as TEXT (JSON) for review.- Multiple results per user (periodic rescreening).
consents
consent_typevalues:terms,privacy,marketing,cookies_analytics,cookies_marketing.granted(0/1) with separategranted_at/withdrawn_attimestamps for full consent lifecycle.ip_addressstored as proof of consent action per GDPR requirements.
data_access_requests
request_typecovers GDPR data subject rights: export (Art. 15), erasure (Art. 17), rectification (Art. 16), restriction (Art. 18).download_urlfor data export files (temporary signed URLs).statusworkflow:pending->processing->completed|rejected.
complaints
- Required by Finansavtaleloven section 3-53 (15 business day response requirement).
category(transaction/service/fees/privacy/technical/other) for routing and reporting.resolutiontext field filled when complaint is resolved.resolved_attimestamp for SLA compliance tracking.
Constraint Inventory
| Table | Constraint Type | Column(s) | Value |
|---|---|---|---|
users |
PRIMARY KEY | id |
- |
users |
UNIQUE | email |
- |
users |
NOT NULL | email, password_hash, first_name, last_name |
- |
users |
CHECK | kyc_status |
IN ('pending','approved','rejected') |
users |
CHECK | role |
IN ('user','merchant') |
users |
CHECK | risk_level |
IN ('low','medium','high') |
users |
CHECK | pep_status |
IN ('not_checked','clear','match','pending_review') |
users |
CHECK | kyc_method |
IN ('bankid','document','simplified') |
transactions |
PRIMARY KEY | id |
- |
transactions |
NOT NULL | user_id, type, amount |
- |
transactions |
FK | user_id |
users(id) |
transactions |
FK | recipient_id |
recipients(id) |
transactions |
FK | merchant_id |
merchants(id) |
transactions |
CHECK | type |
IN ('remittance','qr_payment') |
transactions |
CHECK | status |
IN ('processing','completed','failed') |
transactions |
UNIQUE (partial) | idempotency_key |
WHERE idempotency_key IS NOT NULL |
merchants |
PRIMARY KEY | id |
- |
merchants |
UNIQUE | org_number |
- |
merchants |
NOT NULL | user_id, business_name, org_number, bank_account, qr_hmac_key |
- |
merchants |
FK | user_id |
users(id) |
bank_accounts |
PRIMARY KEY | id |
- |
bank_accounts |
NOT NULL | user_id, bank_name, account_number |
- |
bank_accounts |
FK | user_id |
users(id) |
recipients |
PRIMARY KEY | id |
- |
recipients |
NOT NULL | user_id, name, country, currency, bank_account |
- |
recipients |
FK | user_id |
users(id) |
sessions |
PRIMARY KEY | id |
- |
sessions |
NOT NULL | user_id, token_hash, expires_at |
- |
sessions |
FK | user_id |
users(id) |
cards |
PRIMARY KEY | id |
- |
cards |
NOT NULL | user_id, last_four, expiry |
- |
cards |
FK | user_id |
users(id) |
cards |
CHECK | type |
IN ('virtual','physical') |
cards |
CHECK | status |
IN ('active','frozen','cancelled') |
settings |
PRIMARY KEY | user_id |
1:1 with users |
settings |
FK | user_id |
users(id) |
exchange_rates |
PRIMARY KEY | id |
AUTOINCREMENT |
exchange_rates |
NOT NULL | to_currency, rate |
- |
notifications |
PRIMARY KEY | id |
- |
notifications |
NOT NULL | user_id, type, title, body |
- |
notifications |
FK | user_id |
users(id) |
spending_limits |
PRIMARY KEY | id |
- |
spending_limits |
NOT NULL | user_id, limit_type, amount |
- |
spending_limits |
FK | user_id, card_id |
users(id), cards(id) |
rate_limits |
PRIMARY KEY | key |
IP address |
audit_log |
PRIMARY KEY | id |
- |
audit_log |
NOT NULL | action |
- |
audit_log |
FK | user_id |
users(id) (nullable) |
aml_alerts |
PRIMARY KEY | id |
- |
aml_alerts |
NOT NULL | user_id, alert_type, severity |
- |
aml_alerts |
FK | user_id, transaction_id |
users(id), transactions(id) |
aml_alerts |
CHECK | severity |
IN ('low','medium','high','critical') |
aml_alerts |
CHECK | status |
IN ('open','investigating','resolved','escalated','filed') |
str_reports |
PRIMARY KEY | id |
- |
str_reports |
NOT NULL | user_id, report_type |
- |
str_reports |
FK | user_id, alert_id |
users(id), aml_alerts(id) |
str_reports |
CHECK | status |
IN ('draft','submitted','acknowledged') |
screening_results |
PRIMARY KEY | id |
- |
screening_results |
NOT NULL | user_id, screening_type, result |
- |
screening_results |
FK | user_id |
users(id) |
screening_results |
CHECK | screening_type |
IN ('pep','sanctions','adverse_media') |
screening_results |
CHECK | result |
IN ('clear','match','potential_match','error') |
consents |
PRIMARY KEY | id |
- |
consents |
NOT NULL | user_id, consent_type, granted |
- |
consents |
FK | user_id |
users(id) |
data_access_requests |
PRIMARY KEY | id |
- |
data_access_requests |
NOT NULL | user_id, request_type |
- |
data_access_requests |
FK | user_id |
users(id) |
data_access_requests |
CHECK | request_type |
IN ('export','erasure','rectification','restriction') |
data_access_requests |
CHECK | status |
IN ('pending','processing','completed','rejected') |
complaints |
PRIMARY KEY | id |
- |
complaints |
NOT NULL | user_id, category, subject, description |
- |
complaints |
FK | user_id |
users(id) |
complaints |
CHECK | status |
IN ('received','investigating','resolved','escalated') |
Naming Conventions
| Convention | Rule | Examples |
|---|---|---|
| Table names | Lowercase, plural, snake_case | users, bank_accounts, aml_alerts |
| Column names | Lowercase, snake_case | user_id, first_name, created_at |
| Primary keys | id (or entity-name for 1:1 tables like settings.user_id) |
users.id, settings.user_id |
| Foreign keys | {referenced_table_singular}_id |
user_id, recipient_id, card_id |
| Timestamps | {action}_at suffix |
created_at, completed_at, withdrawn_at |
| Boolean flags | Descriptive name, INTEGER 0/1 | revoked, read, is_primary, granted |
| Status columns | status with CHECK constraint |
status CHECK(... IN (...)) |
| Indexes | idx_{table}_{column} (exception: idx_tx_idempotency uses abbreviation) |
idx_transactions_user, idx_sessions_token, idx_tx_idempotency |
| ID prefixes | 3-letter prefix + underscore + 16 hex chars | usr_, tx_, ba_, mer_, rec_, ses_, con_, cmp_ |
Normalization Analysis
All tables are in Third Normal Form (3NF) with documented exceptions:
| Table | NF Level | Deviation | Justification |
|---|---|---|---|
users |
3NF | risk_level, pep_status, sanctions_cleared could be in a separate risk_profile table |
Accessed on every transaction check. Separate table would add a JOIN to the critical path. 1:1 relationship makes a separate table pointless. |
transactions |
3NF | exchange_rate, send_amount, receive_amount denormalized from exchange_rates |
Rate at execution time must be preserved immutably. The exchange_rates table changes; the transaction record must not. |
transactions |
3NF | Polymorphic FK (recipient_id OR merchant_id) |
Simple either/or. A join table or STI would add complexity for no benefit at this scale. |
bank_accounts |
3NF | balance denormalized from external bank (AISP) |
This is a cache, not authoritative data. Drop cannot modify the real balance. |
audit_log |
3NF | details is unstructured TEXT (JSON) |
Audit events have variable structure. A normalized schema would require dozens of event-specific tables. |
No table violates 2NF (no partial key dependencies) because all tables use single-column primary keys.
Cross-References
- Full schema: DATABASE-SCHEMA.md
- Data architecture overview: data-architecture.md
- Migration strategy: migration-strategy.md
- Dual-driver implementation:
src/drop-api/src/lib/db.ts
Migration Strategy
Migration Strategy: SQLite to PostgreSQL
STATUS: COMPLETED (2026-03-03) This document describes the completed migration from the old dual-driver architecture to PostgreSQL-only. The migration is done. Current architecture: PostgreSQL 16 (all environments), Drizzle ORM. See ADR-014 for the authoritative current state.
Version: 1.0 Date: 2026-02-21 Status: Completed — migration done per ADR-014 Owner: Database Architect
Overview
HISTORICAL NOTE: The dual-driver architecture and
better-sqlite3dependency described in this document have been removed. The codebase now uses Drizzle ORM with PostgreSQL 16 exclusively.db.tsandUSE_PGno longer exist. See ADR-014.
This document captures the migration plan that was executed when transitioning from SQLite (development) + PostgreSQL (production) to PostgreSQL 16 in all environments. It is preserved as a historical record.
Migration Execution Flow
flowchart TD
A[Phase 1: Prepare] --> B[Phase 2: Schema Migration]
B --> C[Phase 3: Data Migration]
C --> D[Phase 4: Validation]
D --> E{All checks pass?}
E -->|Yes| F[Phase 5: Cutover]
E -->|No| G[Fix issues]
G --> D
F --> H[Phase 6: Post-migration]
subgraph "Phase 1: Prepare"
A1[Provision PostgreSQL instance]
A2[Configure DATABASE_URL]
A3[Create shadow database for testing]
A4[Backup SQLite database file]
end
subgraph "Phase 2: Schema"
B1[Run PostgreSQL schema DDL]
B2[Create indexes]
B3[Verify constraints]
end
subgraph "Phase 3: Data"
C1[Export SQLite data as INSERT statements]
C2[Transform data types]
C3[Load into PostgreSQL]
C4[Reset sequences]
end
subgraph "Phase 4: Validate"
D1[Row count comparison]
D2[Checksum validation]
D3[Application smoke tests]
D4[Run full test suite against PG]
end
subgraph "Phase 5: Cutover"
F1[Set DATABASE_URL in production]
F2[Deploy application]
F3[Verify health endpoint]
end
subgraph "Phase 6: Post-migration"
H1[Monitor error rates]
H2[Monitor query performance]
H3[Archive SQLite file]
end
Data Type Mapping
The dual-driver layer already handles SQL syntax differences. The schema migration must map SQLite types to PostgreSQL equivalents:
| SQLite Type | PostgreSQL Type | Tables Using It | Notes |
|---|---|---|---|
TEXT |
TEXT |
All tables | Direct mapping, no change |
TEXT PRIMARY KEY |
TEXT PRIMARY KEY |
All except exchange_rates |
Same behavior |
INTEGER (boolean) |
BOOLEAN or INTEGER |
sessions.revoked, notifications.read, settings.push_enabled, settings.email_enabled, bank_accounts.is_primary, consents.granted, users.sanctions_cleared |
Keep as INTEGER for dual-driver compat, or convert to BOOLEAN in PG-only mode |
INTEGER (currency) |
BIGINT |
transactions.amount, transactions.fee, transactions.send_amount, transactions.receive_amount, bank_accounts.balance, spending_limits.amount |
Use BIGINT for amounts in minor units to prevent overflow |
INTEGER PRIMARY KEY AUTOINCREMENT |
SERIAL PRIMARY KEY |
exchange_rates.id |
Only auto-increment in schema |
INTEGER (unix timestamp) |
INTEGER |
rate_limits.reset_at |
Unix epoch, no conversion needed |
REAL |
DOUBLE PRECISION |
transactions.exchange_rate, merchants.fee_rate |
Direct mapping |
TEXT DEFAULT (datetime('now')) |
TEXT DEFAULT CURRENT_TIMESTAMP |
All created_at, updated_at columns |
Handled by adaptSqlForPg() in db.ts |
SQLite-specific SQL Adaptations (already implemented in db.ts:46-52)
| SQLite SQL | PostgreSQL Equivalent | Handler |
|---|---|---|
INSERT OR IGNORE INTO |
INSERT INTO ... ON CONFLICT DO NOTHING |
runIgnore() function |
INSERT OR REPLACE INTO |
INSERT INTO ... ON CONFLICT (col) DO UPDATE SET |
runUpsert() function |
datetime('now') |
CURRENT_TIMESTAMP |
adaptSqlForPg() regex |
? placeholders |
$1, $2, ... positional |
convertPlaceholders() |
randomblob(32) |
gen_random_bytes(32) |
Schema-level change (merchants.qr_hmac_key default) |
hex() |
encode(..., 'hex') |
Schema-level change |
PostgreSQL Schema DDL
The PostgreSQL schema must be created separately from the SQLite schema since CREATE TABLE IF NOT EXISTS syntax is shared but defaults and functions differ:
-- PostgreSQL schema for Drop
-- Run once when provisioning production database
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
email TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL DEFAULT 'EIDONLY',
auth_provider TEXT DEFAULT 'bankid',
first_name TEXT NOT NULL,
last_name TEXT NOT NULL,
phone TEXT,
date_of_birth TEXT,
kyc_status TEXT DEFAULT 'pending' CHECK(kyc_status IN ('pending','approved','rejected')),
role TEXT DEFAULT 'user' CHECK(role IN ('user','merchant')),
risk_level TEXT DEFAULT 'low' CHECK(risk_level IN ('low','medium','high')),
pep_status TEXT DEFAULT 'not_checked' CHECK(pep_status IN ('not_checked','clear','match','pending_review')),
sanctions_cleared INTEGER DEFAULT 0,
kyc_method TEXT CHECK(kyc_method IN ('bankid','document','simplified')),
kyc_verified_at TEXT,
national_id_hash TEXT,
deleted_at TEXT,
created_at TEXT DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_users_national_id ON users(national_id_hash) WHERE national_id_hash IS NOT NULL;
CREATE TABLE IF NOT EXISTS recipients (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id),
name TEXT NOT NULL,
country TEXT NOT NULL,
currency TEXT NOT NULL,
bank_account TEXT NOT NULL,
bank_name TEXT,
created_at TEXT DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_recipients_user ON recipients(user_id);
CREATE TABLE IF NOT EXISTS merchants (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id),
business_name TEXT NOT NULL,
org_number TEXT UNIQUE NOT NULL,
address TEXT,
bank_account TEXT NOT NULL,
fee_rate DOUBLE PRECISION DEFAULT 0.01,
status TEXT DEFAULT 'active',
qr_hmac_key TEXT NOT NULL DEFAULT encode(gen_random_bytes(32), 'hex'),
created_at TEXT DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS transactions (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id),
type TEXT NOT NULL CHECK(type IN ('remittance','qr_payment')),
status TEXT DEFAULT 'processing' CHECK(status IN ('processing','completed','failed')),
amount BIGINT NOT NULL,
currency TEXT DEFAULT 'NOK',
fee BIGINT DEFAULT 0,
recipient_id TEXT REFERENCES recipients(id),
merchant_id TEXT REFERENCES merchants(id),
send_amount BIGINT,
send_currency TEXT,
receive_amount BIGINT,
receive_currency TEXT,
exchange_rate DOUBLE PRECISION,
purpose_code TEXT,
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
completed_at TEXT,
idempotency_key TEXT
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_tx_idempotency ON transactions(idempotency_key) WHERE idempotency_key IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_transactions_user ON transactions(user_id);
CREATE TABLE IF NOT EXISTS exchange_rates (
id SERIAL PRIMARY KEY,
from_currency TEXT DEFAULT 'NOK',
to_currency TEXT NOT NULL,
rate DOUBLE PRECISION NOT NULL,
updated_at TEXT DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS bank_accounts (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id),
bank_name TEXT NOT NULL,
account_number TEXT NOT NULL,
iban TEXT,
balance BIGINT DEFAULT 0,
balance_synced_at TEXT,
currency TEXT DEFAULT 'NOK',
is_primary INTEGER DEFAULT 0,
connected_at TEXT DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_bank_accounts_user ON bank_accounts(user_id);
-- Remaining tables follow the same pattern...
-- See full DDL in migration script
Sequence/Auto-increment Migration
Only one table uses auto-increment: exchange_rates.
| Aspect | SQLite | PostgreSQL | Migration Step |
|---|---|---|---|
| Type | INTEGER PRIMARY KEY AUTOINCREMENT |
SERIAL PRIMARY KEY |
Schema DDL change |
| Sequence reset | N/A (built into rowid) | SELECT setval('exchange_rates_id_seq', (SELECT MAX(id) FROM exchange_rates)) |
After data load |
| Gap behavior | Gaps allowed | Gaps allowed | No difference |
JSON Handling
| Aspect | SQLite | PostgreSQL | Impact |
|---|---|---|---|
| Storage type | TEXT (plain string) | TEXT (could use JSONB) | No change needed for compatibility |
| JSON columns | audit_log.details, aml_alerts.details, str_reports.details, screening_results.match_details |
Same columns, stored as TEXT | Keep as TEXT for dual-driver compatibility |
| Querying JSON | Not used (JSON is stored, not queried in SQL) | Could use ->>/@> operators |
Future optimization: add JSONB indexes for audit log queries |
| Validation | None (application layer) | Could add CHECK with jsonb cast |
Future enhancement |
Decision: Keep JSON columns as TEXT for now. Converting to JSONB is a future optimization that would break dual-driver compatibility.
Date/Time Handling
| Aspect | SQLite | PostgreSQL | Migration |
|---|---|---|---|
| Default value | datetime('now') |
CURRENT_TIMESTAMP |
Handled by adaptSqlForPg() |
| Storage format | ISO 8601 TEXT | ISO 8601 TEXT (not TIMESTAMP type) | No conversion needed |
| Timezone | UTC (application convention) | UTC (application convention) | Consistent |
| Date arithmetic | datetime('now', '-3 days') |
CURRENT_TIMESTAMP - INTERVAL '3 days' |
Only used in seed data, not production queries |
Note: All timestamps are stored as TEXT in ISO 8601 format (YYYY-MM-DDTHH:MM:SS) in both databases. This is intentional for dual-driver compatibility. A future PostgreSQL-only optimization could convert to TIMESTAMPTZ.
Migration Checklist
Pre-Migration
- Provision PostgreSQL instance (AWS RDS or equivalent)
- Configure connection pooling (built-in
pg.Pool, max connections TBD) - Set
DATABASE_URLenvironment variable - Create shadow database for testing
- Backup current SQLite file:
cp data/drop.db data/drop.db.backup.$(date +%s) - Run full test suite against SQLite (baseline)
- Review all raw SQL queries for SQLite-specific syntax (should be none -- all go through
db.ts)
Schema Migration
- Run PostgreSQL DDL script to create all 19 tables
- Create all indexes (11 indexes defined in
db.tsschema) - Verify all CHECK constraints are active
- Verify all foreign key constraints are active
- Test
initDb()function withDATABASE_URLset
Data Migration
- Export SQLite data using
sqlite3 drop.db .dumpor custom export script - Transform
INTEGER PRIMARY KEY AUTOINCREMENTtoSERIAL - Transform
randomblob()defaults togen_random_bytes() - Load data into PostgreSQL
- Reset
exchange_rates_id_seqsequence - Verify row counts match per table
Validation
- Row count comparison (all 19 tables)
- Spot-check 10 records per table for data integrity
- Run application smoke tests:
- Login (BankID flow)
- View dashboard (bank accounts, balance)
- List transactions
- Create remittance
- Create QR payment
- View notifications
- Update settings
- Run full test suite with
DATABASE_URLset - Verify
GET /api/healthreturnsdb: "connected"with acceptable latency
Cutover
- Set
DATABASE_URLin production environment - Deploy application
- Verify health endpoint
- Monitor error rates for 1 hour
- Monitor query latency for 1 hour
Post-Migration
- Archive SQLite file
- Update documentation to reflect PostgreSQL as primary
- Consider PostgreSQL-specific optimizations (JSONB, TIMESTAMPTZ, partial indexes)
- Configure automated backups (pg_dump cron or RDS snapshots)
Rollback Procedure
flowchart TD
A[Issue detected in PostgreSQL] --> B{Is it data corruption?}
B -->|Yes| C[Stop application immediately]
B -->|No| D{Is it a query/performance issue?}
D -->|Yes| E[Fix query and redeploy]
D -->|No| F[Investigate further]
C --> G[Remove DATABASE_URL env var]
G --> H[Redeploy application]
H --> I[Application falls back to SQLite]
I --> J[Investigate PostgreSQL issue offline]
J --> K[Fix and retry migration]
Rollback is simple: Remove or unset the DATABASE_URL environment variable. The application immediately falls back to SQLite. This is the primary advantage of the dual-driver architecture.
| Rollback Scenario | Action | Downtime |
|---|---|---|
| Schema issue in PostgreSQL | Unset DATABASE_URL, redeploy |
~2 minutes (deploy time) |
| Data integrity issue | Unset DATABASE_URL, redeploy with SQLite backup |
~2 minutes |
| Performance regression | Unset DATABASE_URL, optimize PG offline |
~2 minutes |
| Partial migration failure | Drop PostgreSQL schema, fix script, retry | No production impact (still on SQLite) |
Key constraint: Rollback only works if no new data has been written to PostgreSQL that does not exist in SQLite. In practice, this means the migration window should be short and the SQLite database should be read-only during cutover.
Zero-Downtime Migration Using Dual-Driver
The dual-driver architecture enables a phased migration with zero downtime:
sequenceDiagram
participant App as Application
participant SQLite as SQLite (current)
participant PG as PostgreSQL (new)
Note over App,SQLite: Phase A: Normal operation (SQLite)
App->>SQLite: All reads/writes
Note over App,PG: Phase B: Provision and schema
App->>SQLite: All reads/writes (unchanged)
Note right of PG: Create schema, indexes
Note over App,PG: Phase C: Data migration
App->>SQLite: All reads/writes (unchanged)
Note right of PG: Bulk load from SQLite export
Note over App,PG: Phase D: Final sync + cutover
App->>SQLite: Brief read-only mode
Note right of PG: Delta sync (new records since bulk load)
Note over App: Set DATABASE_URL
App->>PG: All reads/writes
Note over App,PG: Phase E: Production on PostgreSQL
App->>PG: All reads/writes
Note left of SQLite: Archived as backup
Total downtime: Only during Phase D final sync + deploy, estimated at 2-5 minutes for the current data volume.
Testing Approach
Shadow Database Testing
Before production migration, run the full application against a shadow PostgreSQL database:
- Provision shadow PG: Same version and configuration as production target
- Run schema creation: Execute PostgreSQL DDL
- Load production-like data: Export SQLite demo data, transform, load
- Run test suite:
DATABASE_URL=<shadow> npm test - Run integration tests: Full API flow tests against shadow
- Load testing: Verify query performance under expected load
Data Integrity Checks
-- Row count comparison (run against both databases)
SELECT 'users' as tbl, COUNT(*) as cnt FROM users
UNION ALL SELECT 'transactions', COUNT(*) FROM transactions
UNION ALL SELECT 'bank_accounts', COUNT(*) FROM bank_accounts
UNION ALL SELECT 'recipients', COUNT(*) FROM recipients
UNION ALL SELECT 'merchants', COUNT(*) FROM merchants
UNION ALL SELECT 'sessions', COUNT(*) FROM sessions
UNION ALL SELECT 'notifications', COUNT(*) FROM notifications
UNION ALL SELECT 'settings', COUNT(*) FROM settings
UNION ALL SELECT 'exchange_rates', COUNT(*) FROM exchange_rates
UNION ALL SELECT 'cards', COUNT(*) FROM cards
UNION ALL SELECT 'spending_limits', COUNT(*) FROM spending_limits
UNION ALL SELECT 'rate_limits', COUNT(*) FROM rate_limits
UNION ALL SELECT 'audit_log', COUNT(*) FROM audit_log
UNION ALL SELECT 'aml_alerts', COUNT(*) FROM aml_alerts
UNION ALL SELECT 'str_reports', COUNT(*) FROM str_reports
UNION ALL SELECT 'screening_results', COUNT(*) FROM screening_results
UNION ALL SELECT 'consents', COUNT(*) FROM consents
UNION ALL SELECT 'data_access_requests', COUNT(*) FROM data_access_requests
UNION ALL SELECT 'complaints', COUNT(*) FROM complaints;
Cross-References
- Dual-driver implementation:
src/drop-api/src/lib/db.ts - Database schema: DATABASE-SCHEMA.md
- Database design: database-design.md
- Data architecture: data-architecture.md
- Deployment architecture: deployment-architecture.md
- Roadmap Phase 2: ROADMAP.md (PostgreSQL migration is Phase 2)
Data Lifecycle
Data Lifecycle Management
Version: 1.0 Date: 2026-02-21 Status: Approved Owner: Database Architect
Overview
Drop processes personal and financial data subject to multiple overlapping regulatory frameworks. This document defines retention periods, archival strategies, deletion cascades, and GDPR data subject request handling for all 19 tables.
Applicable regulations:
- GDPR (Personopplysningsloven, LOV-2018-06-15-38) -- data minimization, right to erasure, right to access
- AML/KYC (Hvitvaskingsloven, LOV-2018-06-01-23) -- 5-year retention post-relationship
- Norwegian Bookkeeping Act (Bokforingsloven) -- 5-year retention for financial records
- PSD2 (Betalingstjenesteloven) -- audit trail requirements
- Finansavtaleloven -- complaint handling records
Key tension: GDPR right to erasure (Art. 17) vs. AML legal retention obligations. AML wins -- data required for anti-money laundering must be retained for 5 years regardless of erasure requests.
Retention Periods
Per-Table Retention Schedule
| Table | Retention Period | Legal Basis | Archival After | Purge After |
|---|---|---|---|---|
users |
5 years post-relationship end | Hvitvaskingsloven section 30 | Account deletion + 1 year | 5 years post-deletion |
bank_accounts |
5 years post-relationship end | Hvitvaskingsloven section 30 | Account deletion | 5 years post-deletion |
transactions |
5 years from transaction date | Bokforingsloven section 13, Hvitvaskingsloven section 30 | 1 year after transaction | 5 years after transaction |
recipients |
5 years post-relationship end | Hvitvaskingsloven section 30 (counterparty records) | Account deletion | 5 years post-deletion |
merchants |
5 years post-relationship end | Bokforingsloven, Hvitvaskingsloven | Account deletion | 5 years post-deletion |
sessions |
90 days after expiry | Legitimate interest (security) | After expiry | 90 days after expiry |
notifications |
1 year from creation | Legitimate interest (UX) | 6 months | 1 year |
settings |
Duration of relationship | Contract performance | Account deletion | Immediate on deletion |
exchange_rates |
Indefinite (reference data) | Legitimate interest | Never | Never |
cards |
5 years post-cancellation | PCI-DSS, Bokforingsloven | Card cancellation | 5 years post-cancellation |
spending_limits |
Duration of card lifecycle | Contract performance | Card cancellation | With card record |
rate_limits |
Until window expires | Legitimate interest (security) | Auto-cleaned per request | Immediate on expiry |
audit_log |
5 years from event | PSD2 Art. 94, Hvitvaskingsloven | 1 year after event | 5 years after event |
aml_alerts |
5 years post-resolution | Hvitvaskingsloven section 30 | After resolution | 5 years post-resolution |
str_reports |
5 years after filing | Hvitvaskingsloven section 30 | Never (active reference) | 5 years after filing |
screening_results |
5 years post-relationship end | Hvitvaskingsloven section 30 | Account deletion | 5 years post-deletion |
consents |
Duration of consent + 5 years | GDPR Art. 7(1) (proof of consent) | After withdrawal + 1 year | 5 years after withdrawal |
data_access_requests |
5 years from completion | GDPR accountability (Art. 5(2)) | After completion | 5 years after completion |
complaints |
5 years from resolution | Finansavtaleloven, Bokforingsloven | After resolution | 5 years after resolution |
Per-Column Retention (Sensitive Fields)
| Table.Column | Contains | Retention | Anonymization Method |
|---|---|---|---|
users.email |
PII (email address) | Until erasure (then anonymized) | Replace with deleted_usr_{hash}@anonymized.local |
users.first_name |
PII | Until erasure | Replace with [REDACTED] |
users.last_name |
PII | Until erasure | Replace with [REDACTED] |
users.phone |
PII | Until erasure | Replace with NULL |
users.date_of_birth |
PII | Until erasure | Replace with NULL |
users.national_id_hash |
PII (hashed) | 5 years (AML) | Already hashed; set to NULL after retention |
users.password_hash |
Auth credential | Until erasure | Replace with DELETED |
bank_accounts.account_number |
Financial PII | 5 years (AML) | Replace with ****{last4} |
bank_accounts.iban |
Financial PII | 5 years (AML) | Replace with ****{last4} |
recipients.bank_account |
Financial PII | 5 years (AML) | Replace with ****{last4} |
recipients.name |
PII | 5 years (AML, counterparty) | Replace with [REDACTED] |
cards.last_four |
Financial (partial) | 5 years | Already truncated |
cards.pin_hash |
Auth credential | Until card cancellation | Set to NULL |
audit_log.ip_address |
PII (IP address) | 5 years (PSD2) | Replace with 0.0.0.0 after retention |
audit_log.user_agent |
Quasi-PII | 5 years | Replace with [REDACTED] after retention |
consents.ip_address |
PII | 5 years (proof of consent) | Replace with 0.0.0.0 after retention |
Archival Strategy
Active vs. Archived Data
flowchart LR
A[Active Data<br/>Primary Database] -->|After retention trigger| B[Cold Archive<br/>Read-only Storage]
B -->|After full retention period| C[Purge<br/>Permanent Deletion]
subgraph "Active (PostgreSQL)"
A1[Recent transactions]
A2[Active users]
A3[Current sessions]
end
subgraph "Cold Archive (S3/Glacier)"
B1[Old transactions > 1 year]
B2[Deleted user records]
B3[Resolved AML alerts]
B4[Filed STR reports]
end
subgraph "Purge"
C1[Records past 5-year retention]
C2[Anonymized analytics retained]
end
Archival Tiers
| Tier | Storage | Access Time | Data Types | Cost |
|---|---|---|---|---|
| Hot (Active DB) | PostgreSQL | Milliseconds | All current data, active users, recent transactions | Primary DB cost |
| Warm (Archive DB) | PostgreSQL read replica or separate schema | Seconds | Transactions > 1 year, deleted users pending retention | Reduced compute |
| Cold (Object storage) | AWS S3 / Glacier | Minutes to hours | Compliance exports, old audit logs, filed STR reports | Minimal |
Archival Process
- Daily job: Identify records eligible for archival (past active retention period)
- Export: Write eligible records to archive storage (S3 with server-side encryption)
- Verify: Confirm archive integrity (checksum comparison)
- Remove from active: Delete from primary database
- Log: Record archival action in
audit_log
Deletion Cascades: User Account Deletion
When a user requests account deletion (GDPR Art. 17 right to erasure), the following cascade executes:
flowchart TD
A[DELETE /api/user/account] --> B{Active transactions?}
B -->|Yes, processing| C[Reject: Wait for completion]
B -->|No| D[Begin deletion cascade]
D --> E[Revoke all sessions]
E --> F[Soft-delete user record]
F --> G[Anonymize PII fields]
G --> H[Create data_access_request<br/>type=erasure, status=completed]
subgraph "Immediate Actions"
E
F
G
end
subgraph "Retained for AML (5 years)"
I[transactions — amounts, dates, types]
J[audit_log — anonymized entries]
K[aml_alerts — if any]
L[str_reports — if any]
M[screening_results — if any]
end
subgraph "Deleted Immediately"
N[settings — preferences]
O[notifications — all]
P[rate_limits — if any for user IP]
end
subgraph "Anonymized + Retained"
Q[bank_accounts — account numbers masked]
R[recipients — names redacted]
S[consents — IP anonymized]
end
H --> I
H --> J
H --> K
H --> N
H --> Q
Deletion Cascade Detail
| Step | Table | Action | SQL |
|---|---|---|---|
| 1 | sessions |
Revoke all | UPDATE sessions SET revoked = 1 WHERE user_id = ? |
| 2 | users |
Soft delete + anonymize | UPDATE users SET deleted_at = CURRENT_TIMESTAMP, email = 'deleted_' || id || '@anonymized.local', first_name = '[REDACTED]', last_name = '[REDACTED]', phone = NULL, date_of_birth = NULL, password_hash = 'DELETED' WHERE id = ? |
| 3 | settings |
Delete | DELETE FROM settings WHERE user_id = ? |
| 4 | notifications |
Delete | DELETE FROM notifications WHERE user_id = ? |
| 5 | bank_accounts |
Anonymize | UPDATE bank_accounts SET account_number = '****' || RIGHT(account_number, 4), iban = CASE WHEN iban IS NOT NULL THEN '****' || RIGHT(iban, 4) END WHERE user_id = ? |
| 6 | recipients |
Anonymize | UPDATE recipients SET name = '[REDACTED]', bank_account = '****' || RIGHT(bank_account, 4) WHERE user_id = ? |
| 7 | consents |
Anonymize IP | UPDATE consents SET ip_address = '0.0.0.0' WHERE user_id = ? |
| 8 | cards |
Anonymize | UPDATE cards SET pin_hash = NULL WHERE user_id = ? |
| 9 | spending_limits |
Delete | DELETE FROM spending_limits WHERE user_id = ? |
| 10 | data_access_requests |
Create record | INSERT INTO data_access_requests (id, user_id, request_type, status, completed_at) VALUES (?, ?, 'erasure', 'completed', CURRENT_TIMESTAMP) |
| 11 | audit_log |
Log deletion | INSERT INTO audit_log (id, user_id, action, details) VALUES (?, ?, 'user.deleted', '{"reason":"gdpr_erasure"}') |
NOT deleted (AML retention): transactions, audit_log (existing entries), aml_alerts, str_reports, screening_results, merchants. These are retained for 5 years per hvitvaskingsloven section 30, with PII fields anonymized.
Data Subject Access Request (DSAR) Implementation
DSAR Types
| Request Type | GDPR Article | SLA | Implementation |
|---|---|---|---|
| Export (right to access) | Art. 15 | 30 days | GET /api/user/data-export -- returns JSON with all user data |
| Erasure (right to be forgotten) | Art. 17 | 30 days | DELETE /api/user/account -- soft delete + anonymization cascade |
| Rectification (right to correct) | Art. 16 | 30 days | POST /v1/user/rectification -- updates specified fields, creates data_access_request record |
| Restriction (right to restrict) | Art. 18 | 30 days | POST /v1/user/restriction -- flags account as restricted, creates data_access_request record |
Export Flow
sequenceDiagram
participant U as User
participant API as API
participant DB as Database
U->>API: GET /api/user/data-export
API->>DB: SELECT * FROM users WHERE id = ?
API->>DB: SELECT * FROM transactions WHERE user_id = ?
API->>DB: SELECT * FROM recipients WHERE user_id = ?
API->>DB: SELECT * FROM bank_accounts WHERE user_id = ?
API->>DB: SELECT * FROM settings WHERE user_id = ?
API->>DB: SELECT * FROM consents WHERE user_id = ?
API->>DB: INSERT INTO data_access_requests<br/>(type='export', status='completed')
API-->>U: 200 JSON { user, transactions, recipients, bankAccounts, settings, consents }
The current implementation (/api/user/data-export) returns data inline as JSON. For production, large exports should be written to a temporary signed S3 URL and the download_url field in data_access_requests populated.
DSAR Tracking
All DSARs are tracked in the data_access_requests table:
| Field | Purpose |
|---|---|
request_type |
export, erasure, rectification, restriction |
status |
pending -> processing -> completed/rejected |
requested_at |
When the user submitted the request |
completed_at |
When the request was fulfilled |
download_url |
Temporary URL for data export files |
notes |
Internal processing documentation |
Anonymization Techniques
For Analytics Retention
After the active retention period, data can be anonymized for analytics rather than deleted:
| Data Type | Anonymization Technique | Reversible? | Analytics Value |
|---|---|---|---|
| User identity | Replace name/email with opaque ID | No | User-level metrics without PII |
| Transaction amounts | Retain exact values (not PII) | N/A | Revenue and volume analytics |
| Geographic data | Retain country codes only | N/A | Corridor analysis |
| Timestamps | Retain date, remove time | Partially | Trend analysis |
| IP addresses | Replace with 0.0.0.0 |
No | None (removed for privacy) |
| Bank account numbers | Replace with ****{last4} |
No | None |
| Phone numbers | Remove entirely | No | None |
Anonymization SQL Pattern
-- Anonymize a deleted user's data for analytics retention
UPDATE users SET
email = 'anon_' || id || '@analytics.internal',
first_name = '[ANON]',
last_name = '[ANON]',
phone = NULL,
date_of_birth = NULL,
national_id_hash = NULL,
password_hash = 'ANONYMIZED'
WHERE id = ? AND deleted_at IS NOT NULL;
-- Transaction data is retained as-is (amounts are not PII)
-- Recipient names are redacted
UPDATE recipients SET
name = 'Recipient_' || id,
bank_account = '****' || SUBSTR(bank_account, -4)
WHERE user_id = ?;
Legal Basis Reference
| Retention Obligation | Law | Section | Requirement |
|---|---|---|---|
| KYC/AML records | Hvitvaskingsloven | Section 30 | Retain customer identity and transaction records for 5 years after relationship ends |
| Transaction records | Bokforingsloven | Section 13 | Retain accounting records for 5 years (3.5 years primary, 1.5 years secondary) |
| Audit trail | PSD2 / Betalingstjenesteloven | Art. 94 impl. | Maintain records of payment transactions for at least 5 years |
| Consent proof | GDPR | Art. 7(1) | Demonstrate that consent was given (retain proof) |
| Complaint records | Finansavtaleloven | Section 3-53 | Maintain complaint records (15 business day response SLA) |
| Right to erasure exceptions | GDPR | Art. 17(3)(b) | Erasure does not apply when processing is necessary for compliance with legal obligation |
| Data minimization | GDPR | Art. 5(1)(c) | Do not retain data longer than necessary for stated purpose |
| STR records | Hvitvaskingsloven | Section 30 | STR reports and supporting documentation retained 5 years after filing |
Conflict resolution: When GDPR right to erasure conflicts with AML retention requirements, AML wins per GDPR Art. 17(3)(b). The user is informed that "data [is] retained for 5 years per AML requirements" in the deletion response.
Automated Lifecycle Jobs
| Job | Frequency | Action |
|---|---|---|
| Session cleanup | Daily | Delete expired sessions older than 90 days |
| Rate limit cleanup | Every 100 rate limit checks | Delete expired rate limit entries (implemented in middleware/rate-limit.ts) |
| Notification cleanup | Weekly | Archive notifications older than 6 months, delete older than 1 year |
| Audit log archival | Monthly | Move audit entries older than 1 year to cold storage |
| AML alert archival | Monthly | Archive resolved alerts older than 1 year |
| User data purge | Monthly | Permanently delete anonymized user data past 5-year retention |
| Consent proof archival | Monthly | Archive withdrawn consents older than 1 year |
Retention Cron Endpoint
The retention enforcement is implemented as GET /v1/cron/retention (see cron.ts). When triggered, it:
- User anonymization (5+ years post-deletion): Anonymizes PII fields (
email,first_name,last_name,phone,date_of_birth,national_id_hash,password_hash) for users deleted more than 5 years ago - Session cleanup: Deletes expired sessions older than 90 days
- OTP cleanup: Removes expired OTP codes (legacy table, wrapped in try/catch)
This endpoint should be called periodically (e.g., daily via external scheduler or cron job). It is not automatically scheduled within the application.
Cross-References
- Database schema: DATABASE-SCHEMA.md
- Database design: database-design.md
- Audit architecture: audit-architecture.md
- Compliance status: COMPLIANCE.md
- Security architecture: SECURITY-ARCHITECTURE.md
- GDPR API endpoints: API-REFERENCE.md (GDPR & Compliance section)
- Account deletion:
DELETE /api/user/accountin API-REFERENCE.md - Data export:
GET /api/user/data-exportin API-REFERENCE.md
Audit Architecture
Audit Architecture
Version: 1.0 Date: 2026-02-21 Status: Approved Owner: Database Architect
Overview
Drop's audit system records all significant user actions for compliance (PSD2, GDPR, AML) and security monitoring. The audit_log table is the central audit store, designed for append-only writes with indexed queries for investigation and reporting.
Regulatory drivers:
- PSD2 (Betalingstjenesteloven): Art. 94 requires payment service providers to maintain records of payment transactions for at least 5 years
- GDPR (Personopplysningsloven): Art. 5(2) accountability principle -- demonstrate compliance
- AML (Hvitvaskingsloven): Section 30 requires retention of all customer due diligence and transaction records
- Finansavtaleloven: Section 3-53 requires complaint handling audit trail
Audit Log Table Design
Schema
CREATE TABLE audit_log (
id TEXT PRIMARY KEY, -- Prefixed ID (e.g., 'aud_a1b2c3...')
timestamp TEXT DEFAULT CURRENT_TIMESTAMP,
user_id TEXT REFERENCES users(id), -- NULL for unauthenticated events
action TEXT NOT NULL, -- Event type (e.g., 'auth.login')
resource_type TEXT, -- Entity type (e.g., 'transaction')
resource_id TEXT, -- Entity ID (e.g., 'tx_abc123')
details TEXT, -- JSON string with event-specific data
ip_address TEXT, -- Client IP (from X-Forwarded-For)
user_agent TEXT, -- Browser/app user agent
request_id TEXT -- Correlation ID for multi-event requests
);
CREATE INDEX idx_audit_log_user ON audit_log(user_id);
CREATE INDEX idx_audit_log_action ON audit_log(action);
-- Note: idx_audit_log_timestamp is planned but not yet implemented in db.ts
Design Rationale
| Column | Design Decision |
|---|---|
id |
TEXT with prefix for consistency with all other Drop tables |
timestamp |
TIMESTAMPTZ (PostgreSQL 16 — ADR-014; all environments) |
user_id |
Nullable FK -- some events (failed login, rate limit hit) occur before authentication |
action |
Dot-notation namespace (e.g., auth.login, transaction.create) for hierarchical filtering |
resource_type + resource_id |
Generic resource reference avoids polymorphic FKs while enabling resource-specific queries |
details |
JSON TEXT for flexible, event-specific metadata without schema changes per event type |
request_id |
Correlates multiple audit entries from a single API request (e.g., transaction creation generates audit + notification) |
Audit Event Flow
flowchart TD
A[User Action] --> B[API Route Handler]
B --> C{Action Type}
C -->|Authentication| D[Auth Events]
C -->|Transaction| E[Financial Events]
C -->|Settings/Profile| F[Account Events]
C -->|Admin/Compliance| G[Admin Events]
D --> H[Write audit_log]
E --> H
F --> H
G --> H
H --> I[Primary Indexes]
I --> J[idx_audit_log_user<br/>User investigation]
I --> K[idx_audit_log_timestamp<br/>Time-range queries]
I --> L[idx_audit_log_action<br/>Event type filtering]
H --> M{Severity Check}
M -->|Critical| N[AML Alert Pipeline]
M -->|Normal| O[Stored for review]
N --> P[aml_alerts table]
sequenceDiagram
participant User
participant API as API Handler
participant Audit as Audit Logger
participant DB as Database
participant AML as AML Monitor
User->>API: POST /transactions/remittance
API->>API: Validate request
API->>DB: BEGIN transaction
API->>DB: UPDATE bank_accounts (debit)
API->>DB: INSERT transactions
API->>Audit: Log 'transaction.create'
Audit->>DB: INSERT audit_log
API->>DB: COMMIT
Audit->>AML: Check transaction patterns
AML->>DB: Query recent transactions for user
alt Suspicious pattern detected
AML->>DB: INSERT aml_alerts
end
Audit Event Types
| Action | Category | Trigger | Logged Details |
|---|---|---|---|
auth.login |
Authentication | Successful BankID login | {method: "bankid", provider: "bankid"} |
auth.login.failed |
Authentication | Failed login attempt | {reason: "invalid_credentials", email: "..."} |
auth.logout |
Authentication | User logout | {sessions_revoked: N} |
auth.session.created |
Authentication | New session created | {session_id: "ses_..."} |
auth.session.revoked |
Authentication | Session revoked | {session_id: "ses_..."} |
auth.token.refreshed |
Authentication | JWT token refreshed | {new_session_id: "ses_..."} |
transaction.create |
Financial | Remittance or QR payment created | {type, amount, currency, fee, recipient_id/merchant_id} |
transaction.complete |
Financial | Transaction marked completed | {transaction_id: "tx_..."} |
transaction.fail |
Financial | Transaction marked failed | {transaction_id, reason} |
qr_payment.create |
Financial | QR payment executed | {amount, merchant_id, fee} |
bank_account.link |
Account | Bank account linked via AISP | {bank_name, last4_account} |
bank_account.balance_sync |
Account | Balance refreshed from AISP | {bank_account_id, balance} |
recipient.create |
Account | New recipient added | {country, currency} |
recipient.delete |
Account | Recipient removed | {recipient_id} |
settings.update |
Account | User settings changed | {changed_fields: ["currency","language"]} |
merchant.register |
Account | Merchant profile created | {business_name, org_number} |
kyc.status_change |
Compliance | KYC status updated | {old_status, new_status, method} |
consent.granted |
Compliance | GDPR consent given | {consent_type, ip_address} |
consent.withdrawn |
Compliance | GDPR consent withdrawn | {consent_type, ip_address} |
dsar.export |
Compliance | Data export request completed | {request_id} |
dsar.erasure |
Compliance | Account deletion requested | {request_id} |
complaint.created |
Compliance | Customer complaint filed | {category, complaint_id} |
complaint.resolved |
Compliance | Complaint resolved | {complaint_id, resolution_days} |
aml.alert_created |
AML | Suspicious activity detected | {alert_type, severity, transaction_id} |
aml.alert_resolved |
AML | AML alert investigated and resolved | {alert_id, resolution} |
str.filed |
AML | STR submitted to authorities | {str_id, reference_number} |
screening.completed |
AML | PEP/sanctions screening done | {screening_type, result} |
user.deleted |
Account | User account deleted (GDPR) | {reason: "gdpr_erasure"} |
rate_limit.exceeded |
Security | Rate limit hit | {endpoint, ip_address, limit} |
card.created |
Account | Card created (FUTURE) | {type, last_four} |
card.frozen |
Account | Card frozen (FUTURE) | {card_id} |
card.cancelled |
Account | Card cancelled (FUTURE) | {card_id} |
Tamper Detection
Hash Chain Mechanism
To ensure audit log integrity (detect unauthorized modifications or deletions), the audit system should implement a hash chain:
flowchart LR
E1[Entry 1<br/>hash = SHA256<br/>data + genesis] --> E2[Entry 2<br/>hash = SHA256<br/>data + E1.hash]
E2 --> E3[Entry 3<br/>hash = SHA256<br/>data + E2.hash]
E3 --> E4[Entry N<br/>hash = SHA256<br/>data + E(N-1).hash]
Proposed implementation:
Add a chain_hash column to audit_log:
ALTER TABLE audit_log ADD COLUMN chain_hash TEXT;
Each entry's hash is computed as:
chain_hash = SHA256(
timestamp || user_id || action || resource_type ||
resource_id || details || previous_chain_hash
)
Verification: Walk the chain from the first entry, recomputing each hash. A mismatch indicates tampering. This can be run as a periodic integrity check job.
Current status: Not yet implemented. The audit table stores events without tamper detection. Hash chain is a Phase 3 enhancement (pre-production launch).
Alternative: Append-Only with Write-Once Storage
For production, audit logs should be replicated to write-once storage (AWS S3 Object Lock / Glacier Vault Lock) within minutes of creation. This provides:
- Immutability guarantee independent of database access
- External verification point
- Compliance with regulatory requirements for tamper-evident audit trails
Compliance Requirements
PSD2 Audit Trail
| Requirement | Implementation | Status |
|---|---|---|
| Record all payment transactions | transaction.create event with full details |
Implemented |
| Record authentication events | auth.login, auth.logout events |
Implemented |
| Record consent actions | consent.granted, consent.withdrawn events |
Implemented |
| 5-year retention | Retention policy defined (see data-lifecycle.md) | Policy defined |
| Tamper-evident | Hash chain proposed, not yet implemented | Planned |
GDPR Audit Trail
| Requirement | Implementation | Status |
|---|---|---|
| Record data access requests | dsar.export, dsar.erasure events |
Implemented |
| Record consent changes | consent.granted, consent.withdrawn events |
Implemented |
| Record data deletion | user.deleted event |
Implemented |
| Demonstrate accountability | Full audit trail queryable by user_id | Implemented |
AML Audit Trail
| Requirement | Implementation | Status |
|---|---|---|
| Record suspicious activity alerts | aml.alert_created event |
Implemented |
| Record STR filings | str.filed event |
Implemented |
| Record screening results | screening.completed event |
Implemented |
| Record KYC status changes | kyc.status_change event |
Implemented |
Log Retention and Searchability
Query Patterns
| Query | Use Case | Index Used | Example SQL |
|---|---|---|---|
| All events for a user | Investigation, DSAR | idx_audit_log_user |
SELECT * FROM audit_log WHERE user_id = ? ORDER BY timestamp DESC |
| Events in time range | Compliance reporting | idx_audit_log_timestamp |
SELECT * FROM audit_log WHERE timestamp BETWEEN ? AND ? |
| Events by type | Pattern analysis | idx_audit_log_action |
SELECT * FROM audit_log WHERE action = 'transaction.create' |
| Events for a resource | Transaction audit trail | Sequential scan (consider composite index) | SELECT * FROM audit_log WHERE resource_type = 'transaction' AND resource_id = ? |
| Recent events globally | Dashboard, monitoring | idx_audit_log_timestamp |
SELECT * FROM audit_log ORDER BY timestamp DESC LIMIT 50 |
Retention Tiers
| Tier | Age | Storage | Access |
|---|---|---|---|
| Hot | 0-3 months | Primary database, fully indexed | Real-time queries |
| Warm | 3-12 months | Primary database, indexed | Standard queries |
| Cold | 1-5 years | Archive storage (S3) | Restored on demand |
| Purge | 5+ years | Deleted | Not available |
Cross-References
- Audit log schema: DATABASE-SCHEMA.md (audit_log section)
- Data lifecycle: data-lifecycle.md (retention periods)
- Indexing strategy: indexing-strategy.md (audit log indexes)
- Compliance status: COMPLIANCE.md
- Security architecture: SECURITY-ARCHITECTURE.md
Indexing Strategy
Indexing Strategy
Version: 1.0 Date: 2026-02-21 Status: Approved Owner: Database Architect
Overview
Drop's indexing strategy is designed around actual user flow query patterns. Every index exists because a specific query needs it. No speculative indexes.
Current index count: 16 indexes across 19 tables (defined in db.ts schema).
Query Patterns by User Flow
Login Flow (BankID OIDC)
sequenceDiagram
participant U as User
participant API as API
participant DB as Database
U->>API: BankID callback (code, state)
API->>DB: Q1: Find user by national_id_hash
DB-->>API: User or NULL
alt New user
API->>DB: Q2: INSERT users
API->>DB: Q3: INSERT settings (defaults)
end
API->>DB: Q4: INSERT sessions
API-->>U: JWT cookie + redirect
| Query ID | SQL Pattern | Index Required | Current Coverage |
|---|---|---|---|
| Q1 | SELECT * FROM users WHERE national_id_hash = ? |
idx_users_national_id (partial: WHERE NOT NULL) |
Covered |
| Q2 | INSERT INTO users (...) |
None (PK insert) | N/A |
| Q3 | INSERT INTO settings (...) |
None (PK insert) | N/A |
| Q4 | INSERT INTO sessions (...) |
None (PK insert) | N/A |
Authentication Middleware (every authenticated request)
| Query ID | SQL Pattern | Index Required | Current Coverage |
|---|---|---|---|
| Q5 | SELECT * FROM sessions WHERE token_hash = ? AND revoked = 0 AND expires_at > ? |
idx_sessions_token |
Covered |
| Q6 | SELECT * FROM users WHERE id = ? |
PRIMARY KEY | Covered |
Dashboard View
sequenceDiagram
participant U as User
participant API as API
participant DB as Database
U->>API: GET /auth/me
API->>DB: Q5: Verify session (token_hash)
API->>DB: Q6: Get user by PK
API->>DB: Q7: Get bank accounts for user
DB-->>API: Bank accounts with cached balances
API-->>U: User profile + total balance
| Query ID | SQL Pattern | Index Required | Current Coverage |
|---|---|---|---|
| Q7 | SELECT * FROM bank_accounts WHERE user_id = ? |
idx_bank_accounts_user |
Covered |
Transaction History
| Query ID | SQL Pattern | Index Required | Current Coverage |
|---|---|---|---|
| Q8 | SELECT * FROM transactions WHERE user_id = ? [AND type = ?] [AND status = ?] ORDER BY created_at DESC LIMIT ? OFFSET ? |
idx_transactions_user |
Covered (user_id) |
| Q9 | SELECT COUNT(*) FROM transactions WHERE user_id = ? |
idx_transactions_user |
Covered |
Note: The type and status filters are applied after the user_id index lookup. At current scale (< 10K transactions per user), this is efficient. A composite index (user_id, created_at DESC) would optimize the ORDER BY for users with many transactions.
Create Remittance
| Query ID | SQL Pattern | Index Required | Current Coverage |
|---|---|---|---|
| Q10 | SELECT * FROM recipients WHERE id = ? AND user_id = ? |
idx_recipients_user + PK |
Covered |
| Q11 | SELECT * FROM exchange_rates WHERE to_currency = ? |
Sequential scan (6 rows) | Acceptable (tiny table) |
| Q12 | SELECT * FROM bank_accounts WHERE user_id = ? AND is_primary = 1 |
idx_bank_accounts_user |
Covered |
| Q13 | UPDATE bank_accounts SET balance = balance - ? WHERE id = ? AND balance >= ? |
PK | Covered |
| Q14 | INSERT INTO transactions (...) |
None (PK insert) | N/A |
Create QR Payment
| Query ID | SQL Pattern | Index Required | Current Coverage |
|---|---|---|---|
| Q15 | SELECT * FROM merchants WHERE id = ? |
PK | Covered |
| Q12 | (Same as remittance -- primary bank account) | idx_bank_accounts_user |
Covered |
| Q13 | (Same as remittance -- balance debit) | PK | Covered |
| Q14 | (Same as remittance -- insert transaction) | N/A | N/A |
Notifications List
| Query ID | SQL Pattern | Index Required | Current Coverage |
|---|---|---|---|
| Q16 | SELECT * FROM notifications WHERE user_id = ? ORDER BY created_at DESC |
idx_notifications_user |
Covered |
| Q17 | UPDATE notifications SET read = 1 WHERE id IN (?, ?, ...) AND user_id = ? |
PK + idx_notifications_user |
Covered |
Settings View/Update
| Query ID | SQL Pattern | Index Required | Current Coverage |
|---|---|---|---|
| Q18 | SELECT * FROM settings WHERE user_id = ? |
PK (user_id IS the PK) | Covered |
| Q19 | UPDATE settings SET ... WHERE user_id = ? |
PK | Covered |
Merchant Dashboard
| Query ID | SQL Pattern | Index Required | Current Coverage |
|---|---|---|---|
| Q20 | SELECT * FROM merchants WHERE user_id = ? |
Sequential scan (1 merchant per user) | Acceptable |
| Q21 | SELECT * FROM transactions WHERE merchant_id = ? AND created_at >= ? |
idx_transactions_merchant |
Partially covered (no composite with created_at) |
Recipient Management
| Query ID | SQL Pattern | Index Required | Current Coverage |
|---|---|---|---|
| Q22 | SELECT * FROM recipients WHERE user_id = ? LIMIT ? OFFSET ? |
idx_recipients_user |
Covered |
| Q23 | SELECT * FROM recipients WHERE id = ? AND user_id = ? |
PK + idx_recipients_user |
Covered |
| Q24 | DELETE FROM recipients WHERE id = ? AND user_id = ? |
PK | Covered |
Compliance Queries (Admin/Internal)
| Query ID | SQL Pattern | Index Required | Current Coverage |
|---|---|---|---|
| Q25 | SELECT * FROM audit_log WHERE user_id = ? ORDER BY timestamp DESC |
idx_audit_log_user |
Covered |
| Q26 | SELECT * FROM audit_log WHERE action = ? AND timestamp BETWEEN ? AND ? |
idx_audit_log_action + idx_audit_log_timestamp |
Partially (separate indexes, no composite) |
| Q27 | SELECT * FROM aml_alerts WHERE user_id = ? AND status IN ('open','investigating') |
idx_aml_alerts_user |
Covered |
| Q28 | SELECT * FROM aml_alerts WHERE status = 'open' ORDER BY created_at |
idx_aml_alerts_status (proposed) |
Not covered (needs new index) |
| Q29 | SELECT * FROM complaints WHERE user_id = ? ORDER BY created_at DESC |
idx_complaints_user |
Covered |
| Q30 | SELECT * FROM complaints WHERE status IN ('received','investigating') |
idx_complaints_status (proposed) |
Not covered (needs new index) |
Index Inventory
Current Indexes (defined in db.ts)
| Index Name | Table | Column(s) | Type | Rationale |
|---|---|---|---|---|
idx_users_national_id |
users |
national_id_hash |
B-tree, partial (WHERE NOT NULL) | BankID login deduplication -- find user by hashed national ID |
idx_recipients_user |
recipients |
user_id |
B-tree | List recipients per user, verify ownership |
idx_transactions_user |
transactions |
user_id |
B-tree | Transaction history per user (most frequent query) |
idx_transactions_merchant |
transactions |
merchant_id |
B-tree | Merchant dashboard -- transactions for merchant (documented in DATABASE-SCHEMA.md) |
idx_tx_idempotency |
transactions |
idempotency_key |
B-tree, unique, partial (WHERE NOT NULL) | Prevent duplicate transaction submission |
idx_bank_accounts_user |
bank_accounts |
user_id |
B-tree | Dashboard balance lookup, transaction source |
idx_sessions_user |
sessions |
user_id |
B-tree | Revoke all sessions on logout |
idx_sessions_token |
sessions |
token_hash |
B-tree | Auth middleware -- validate session on every request |
idx_notifications_user |
notifications |
user_id |
B-tree | Notifications list per user (documented in DATABASE-SCHEMA.md) |
idx_audit_log_user |
audit_log |
user_id |
B-tree | User investigation, DSAR compliance |
idx_audit_log_action |
audit_log |
action |
B-tree | Event type filtering for monitoring |
idx_audit_log_timestamp |
audit_log |
timestamp |
B-tree | Time-range queries for compliance reporting (documented in DATABASE-SCHEMA.md) |
idx_aml_alerts_user |
aml_alerts |
user_id |
B-tree | Per-user AML alert lookup |
idx_aml_alerts_status |
aml_alerts |
status |
B-tree | Open alerts dashboard (documented in DATABASE-SCHEMA.md) |
idx_complaints_user |
complaints |
user_id |
B-tree | Per-user complaint history |
idx_screening_user |
screening_results |
user_id |
B-tree | Per-user screening history |
Indexes from DATABASE-SCHEMA.md (not in db.ts code)
The DATABASE-SCHEMA.md documentation lists additional indexes that may not be in the current db.ts SQLITE_SCHEMA string:
| Index Name | Table | Column(s) | Status |
|---|---|---|---|
idx_merchants_org |
merchants |
org_number |
Documented but covered by UNIQUE constraint |
idx_cards_user |
cards |
user_id |
Documented, may not be in db.ts |
idx_spending_limits_user |
spending_limits |
user_id |
Documented, may not be in db.ts |
idx_spending_limits_card |
spending_limits |
card_id |
Documented, may not be in db.ts |
idx_consents_user |
consents |
user_id |
Documented, may not be in db.ts |
idx_data_requests_user |
data_access_requests |
user_id |
Documented, may not be in db.ts |
idx_complaints_status |
complaints |
status |
Documented, may not be in db.ts |
Recommendation: Reconcile DATABASE-SCHEMA.md with actual db.ts code. Add missing indexes to the schema if the queries justify them.
Recommended Additional Indexes
Based on query pattern analysis, the following indexes should be added:
| Proposed Index | Table | Column(s) | Justification |
|---|---|---|---|
idx_transactions_user_created |
transactions |
(user_id, created_at DESC) |
Optimizes paginated transaction history (Q8) -- avoids sort after index lookup |
idx_complaints_status |
complaints |
status |
Admin dashboard query for open complaints (Q30) |
idx_consents_user |
consents |
user_id |
DSAR export needs all consents for user |
idx_data_requests_user |
data_access_requests |
user_id |
DSAR tracking per user |
idx_audit_log_resource |
audit_log |
(resource_type, resource_id) |
Resource-specific audit trail lookup |
Partial Index Opportunities (PostgreSQL)
These are PostgreSQL-specific optimizations to add after migration:
| Proposed Index | Table | Column(s) | Condition | Justification |
|---|---|---|---|---|
idx_sessions_active |
sessions |
user_id |
WHERE revoked = 0 |
Auth middleware only queries active sessions |
idx_aml_alerts_open |
aml_alerts |
created_at |
WHERE status IN ('open','investigating') |
Dashboard shows only open alerts |
idx_notifications_unread |
notifications |
user_id |
WHERE read = 0 |
Badge count for unread notifications |
idx_users_active |
users |
email |
WHERE deleted_at IS NULL |
Login only checks non-deleted users |
Connection Pooling Configuration
Note (ADR-014, 2026-03-03): Drop uses PostgreSQL 16 in ALL environments. SQLite and the dual-driver layer have been removed. The section below reflects the current PostgreSQL-only configuration.
PostgreSQL 16 (All Environments)
PostgreSQL uses Drizzle ORM with connection pooling:
| Setting | Value | Source | Notes |
|---|---|---|---|
| Pool library | pg.Pool |
db.ts:16-21 |
Node-postgres built-in pool |
| Connection string | DATABASE_URL env var |
db.ts:18 |
Standard PostgreSQL URL format |
| Max connections | Default (10) | pg.Pool default | Adjust based on App Runner instance count |
| Idle timeout | 10,000ms | pg.Pool default | Close idle connections after 10s |
| Connection timeout | 0 (no timeout) | pg.Pool default | Wait indefinitely for connection |
Recommended Production Pool Configuration
// Recommended pg.Pool configuration for production
const pool = new pg.Pool({
connectionString: process.env.DATABASE_URL,
max: 20, // Max connections per instance
idleTimeoutMillis: 30000, // Close idle after 30s
connectionTimeoutMillis: 5000, // Fail if no connection in 5s
ssl: { rejectUnauthorized: true } // Require SSL for RDS
});
| Parameter | Recommended Value | Rationale |
|---|---|---|
max |
20 | Balance between connection availability and RDS connection limits. With 2-3 App Runner instances, total connections = 40-60 (well under RDS default 100). |
idleTimeoutMillis |
30,000 | Close idle connections to free RDS slots, but keep them long enough to avoid reconnection overhead for bursty traffic. |
connectionTimeoutMillis |
5,000 | Fail fast on connection issues rather than hanging. API should return 503 to client. |
ssl |
{ rejectUnauthorized: true } |
Encrypt connections to RDS. Required for compliance. |
PgBouncer Consideration
At current projected scale (3,000 users, ~100 concurrent connections), direct pg.Pool is sufficient. PgBouncer should be evaluated when:
- Connection count exceeds RDS limits
- Multiple services need to share the same database
- Transaction-mode pooling would reduce connection overhead
EXPLAIN ANALYZE Examples
Transaction History Query (most common)
-- PostgreSQL 16 (all environments — ADR-014)
EXPLAIN ANALYZE
SELECT * FROM transactions
WHERE user_id = 'usr_demo1'
ORDER BY created_at DESC
LIMIT 20 OFFSET 0;
-- Expected: Index Scan using idx_transactions_user on transactions
-- Sort Key: created_at DESC
-- Rows Removed by Index: 0 (all rows match user_id)
Session Validation (every request)
-- PostgreSQL 16
EXPLAIN ANALYZE
SELECT * FROM sessions
WHERE token_hash = 'abc123...'
AND revoked = FALSE
AND expires_at > NOW();
-- Expected: Index Scan using idx_sessions_token on sessions (token_hash=?)
-- PostgreSQL
EXPLAIN ANALYZE
SELECT * FROM sessions
WHERE token_hash = 'abc123...'
AND revoked = 0
AND expires_at > '2026-02-21T00:00:00';
-- Expected: Index Scan using idx_sessions_token on sessions (cost=0.28..8.30)
-- Filter: (revoked = 0 AND expires_at > ...)
Audit Log by User (investigation)
-- PostgreSQL
EXPLAIN ANALYZE
SELECT * FROM audit_log
WHERE user_id = 'usr_demo1'
ORDER BY timestamp DESC
LIMIT 100;
-- Expected: Index Scan Backward using idx_audit_log_user
Performance Monitoring
Key Metrics to Track
| Metric | Target | Alert Threshold | Query |
|---|---|---|---|
| Session validation latency | < 5ms | > 20ms | SELECT * FROM sessions WHERE token_hash = ? |
| Transaction list latency | < 50ms | > 200ms | SELECT * FROM transactions WHERE user_id = ? ORDER BY created_at DESC LIMIT 20 |
| Audit log write latency | < 10ms | > 50ms | INSERT INTO audit_log (...) |
| Index bloat | < 20% | > 50% | pg_stat_user_indexes |
| Sequential scans on large tables | 0 | Any | pg_stat_user_tables.seq_scan for transactions, audit_log |
Periodic Index Maintenance (PostgreSQL)
-- Check index usage
SELECT schemaname, tablename, indexname, idx_scan, idx_tup_read
FROM pg_stat_user_indexes
ORDER BY idx_scan ASC;
-- Check for bloated indexes
SELECT pg_size_pretty(pg_relation_size(indexrelid)) as size, indexrelid::regclass
FROM pg_stat_user_indexes
ORDER BY pg_relation_size(indexrelid) DESC;
-- Reindex if bloated
REINDEX INDEX CONCURRENTLY idx_transactions_user;
Cross-References
- Database schema: DATABASE-SCHEMA.md
- Database design: database-design.md
- Audit architecture: audit-architecture.md
- Data architecture: data-architecture.md
- Migration strategy: migration-strategy.md (PostgreSQL-specific optimizations)
- Drizzle ORM schema:
src/shared/db/schema.ts
High-Level Design Document
High-Level Design Document
Project: Drop Version: 1.0 Date: 2026-02-23 Author: Petter Graff, Senior Enterprise Architect Status: Approved Reviewers: Alem Bašić (CEO), John (AI Director)
Document History
| Version | Date | Author | Changes |
|---|---|---|---|
| 0.1 | 2026-02-21 | Standards Architect | Initial draft from source code analysis |
| 1.0 | 2026-02-23 | Petter Graff | Filled from real architecture docs |
1. Executive Summary
Purpose: Drop is a PSD2 pass-through payment application that enables Norwegian residents (18+) to send money internationally (remittance) and pay merchants via QR code — without Drop ever holding customer funds.
Business Context: Sending money from Norway is expensive and complex. Diaspora communities and internationally connected residents pay high fees through traditional remittance services. Drop removes that friction by operating as a licensed AISP (Account Information Service Provider) and PISP (Payment Initiation Service Provider) under PSD2 / Betalingstjenesteloven — reading balances directly from users' banks and initiating payments on their behalf. ALAI Holding AS builds Drop as a product for the Norwegian market, targeting all residents of Norway and Scandinavia, not just diaspora communities.
Key Outcomes:
- Users send money to 30+ countries at lower fees (0.5% vs. industry 2-5%)
- Merchants accept QR payments without POS hardware — mobile-first
- Drop avoids EMI licensing complexity (350K EUR capital requirement) by adopting the PISP/AISP pass-through model (20-50K EUR capital requirement)
- Strong regulatory compliance: BankID SCA, Sumsub KYC/AML, GDPR, AML (hvitvaskingsloven)
Scope: This document covers the Drop platform — web app (drop-web), API server (drop-api), and mobile app (drop-mobile) — and their integrations with BankID, Open Banking (AISP/PISP), Sumsub KYC, and payment rails. It excludes the Drop landing/marketing site, the Cards feature (feature-flagged, future), and future Vipps Login integration.
2. System Context (C4 Level 1)
graph TB
subgraph actors["External Actors"]
sender["Sender<br/>(Norwegian Resident, 18+)<br/>Sends money abroad via PISP"]
receiver["Receiver<br/>(30+ countries)<br/>Receives remittance"]
merchant["Merchant<br/>(Norwegian Business)<br/>Accepts QR payments"]
end
subgraph drop_system["Drop Payment System (ALAI Holding AS)"]
drop["Drop<br/>Next.js 15 + Hono v4 + Expo SDK 54<br/>PSD2 Pass-through App<br/>(AISP + PISP)"]
end
subgraph banking["Banking & Open Banking"]
bankid["BankID Norway<br/>OIDC Identity Provider<br/>Strong Customer Authentication"]
nordic_banks["Nordic Banks<br/>(DNB, SpareBank1, Nordea)<br/>Berlin Group NextGenPSD2 APIs<br/>AISP: Read balance<br/>PISP: Initiate payment"]
payment_rails["Payment Rails<br/>SEPA (EEA) / SWIFT (non-EEA)<br/>30+ remittance corridors"]
end
subgraph compliance["Compliance & KYC"]
sumsub["Sumsub<br/>KYC/AML Provider<br/>Document verification<br/>PEP/sanctions screening"]
finanstilsynet["Finanstilsynet<br/>Norwegian FSA<br/>PISP/AISP registration<br/>Regulatory oversight"]
okokrim["Okokrim / EFE<br/>Financial Intelligence Unit<br/>STR/SAR filing"]
end
subgraph infrastructure["Infrastructure"]
aws["AWS App Runner<br/>eu-north-1 (Stockholm)<br/>Container hosting + auto-scaling"]
cloudflare["Cloudflare<br/>CDN, WAF, DDoS protection<br/>DNS, TLS termination<br/>getdrop.no"]
sentry["Sentry<br/>Error tracking<br/>Performance monitoring"]
end
sender -->|"BankID login, view balance (AISP), send money (PISP), QR payments"| drop
receiver -.->|"Receives funds via bank transfer"| payment_rails
merchant -->|"Register business, view dashboard, generate QR code"| drop
drop -->|"OIDC authorize, ID token verification, age/identity check"| bankid
drop -->|"AISP: GET /accounts /balances; PISP: POST /payments"| nordic_banks
drop -->|"PISP payment routing — SEPA for EEA, SWIFT for non-EEA"| payment_rails
drop -->|"Applicant creation, document upload, webhook results"| sumsub
drop -.->|"License registration, regulatory reporting"| finanstilsynet
drop -.->|"STR filing (hvitvaskingsloven)"| okokrim
drop -->|"Deploy containers, auto-scale"| aws
drop -->|"DNS routing, TLS, WAF, DDoS protection"| cloudflare
drop -->|"Error events, performance traces"| sentry
nordic_banks -->|"Execute transfers"| payment_rails
3. Container Diagram (C4 Level 2)
C4Container
title Drop — Container Diagram (C4 Level 2)
Person(user, "End User", "Norwegian resident 18+, authenticated via BankID")
Person(merchant, "Merchant", "Business owner receiving QR payments")
System_Boundary(drop, "Drop Platform") {
Container(web, "drop-web", "Next.js 15, React 19, Tailwind v4", "SSR web app. 10 screens: Login, Onboarding, Dashboard, SendMoney, BankAccounts, TransactionHistory, ScanQR, Profile, Notifications, MerchantDashboard. BankID auth via httpOnly cookie.")
Container(api, "drop-api", "Hono v4, Node.js 22 Alpine", "REST API server. 26+ endpoints under /v1/. BankID OIDC callback, transaction processing, recipient management, merchant registration, GDPR compliance, admin operations.")
Container(mobile, "drop-mobile", "Expo SDK 54, React Native", "Native iOS/Android app. BankID auth via expo-web-browser deep linking (drop://auth/callback). AsyncStorage for token. 4 tabs: Hjem, Send, QR, Profil.")
ContainerDb(db, "Database", "SQLite (dev) / PostgreSQL 16 (prod)", "19 tables: users, sessions, transactions, bank_accounts, recipients, merchants, notifications, settings, cards, spending_limits, exchange_rates, audit_log, aml_alerts, str_reports, screening_results, consents, data_access_requests, complaints, rate_limits.")
}
System_Ext(bankid, "BankID OIDC", "Norwegian eID provider. OIDC authorize/token/JWKS endpoints. auth.bankid.no (prod).")
System_Ext(sumsub, "Sumsub", "KYC/AML identity verification. WebSDK (web), React Native SDK (mobile), webhooks for status updates.")
System_Ext(openbanking, "Open Banking APIs", "Berlin Group NextGenPSD2. AISP (balance reads) and PISP (payment initiation) via Neonomics aggregator (planned).")
System_Ext(sepa, "SEPA/SWIFT Networks", "International payment rails for remittance settlement to 30+ countries.")
Rel(user, web, "HTTPS", "Browser — getdrop.no")
Rel(user, mobile, "HTTPS", "iOS/Android app")
Rel(merchant, web, "HTTPS", "Merchant dashboard")
Rel(web, api, "HTTPS REST", "/api/* and /v1/* endpoints, JSON, httpOnly cookie")
Rel(mobile, api, "HTTPS REST", "/v1/* endpoints, JSON, Bearer token")
Rel(api, db, "SQL", "Parameterized queries via db.ts dual-driver abstraction")
Rel(api, bankid, "OIDC", "Authorization code flow, JWKS token verification")
Rel(api, sumsub, "REST + Webhooks", "Applicant creation, document checks, HMAC-verified webhooks")
Rel(api, openbanking, "Berlin Group NextGenPSD2", "AISP balance reads, PISP payment initiation with SCA")
Rel(api, sepa, "ISO 20022 (via banking partner)", "Remittance settlement to 30+ countries")
4. Component Overview
| Component | Responsibility | Technology | Owner Team |
|---|---|---|---|
| drop-web | SSR web application, user onboarding, dashboard, send money, QR scan, merchant dashboard | Next.js 15, React 19, Tailwind v4, shadcn/ui | ALAI — Frontend |
| drop-api | REST API, BankID OIDC, JWT sessions, payment processing, KYC/GDPR/AML compliance | Hono v4, Node.js 22 | ALAI — Backend |
| drop-mobile | Native iOS/Android, BankID auth, send money, QR scan, transaction history | Expo SDK 54, React Native | ALAI — Mobile |
| Database | Persistent storage, 19 tables, dual-driver (SQLite/PostgreSQL) | SQLite 3 (dev) / PostgreSQL 16 (prod) | ALAI — Backend |
| BankID OIDC | Strong Customer Authentication (SCA), Norwegian identity provider | OIDC 1.0 | BankID Norge |
| Sumsub KYC | Document verification, PEP/sanctions screening, AML risk scoring | Sumsub API + SDK | Sumsub |
| Open Banking (AISP/PISP) | Balance reads from user's bank, payment initiation from user's bank | Berlin Group NextGenPSD2, Neonomics aggregator | ALAI + Neonomics |
Component Descriptions
drop-web
Responsibility: Server-side rendered web application serving all 10 core screens. Handles the BankID OIDC redirect initiation, authentication callback (sets httpOnly cookie), and renders the full UI from Login to MerchantDashboard. Acts as BFF (Backend For Frontend) for the Next.js API routes at /api/auth/*.
Key Interfaces: HTTP GET/POST to Next.js API routes (/api/auth/bankid/*); Hono API REST calls (/v1/*) via fetch with cookie credentials.
Rationale: Separate from the API to allow independent scaling, enable SSR for SEO on the landing/marketing site, and encapsulate web-specific auth session management (httpOnly cookie).
drop-api
Responsibility: Central REST API serving both web and mobile clients. Owns all business logic: BankID OIDC code exchange, JWT issuance, transaction processing (remittance, QR payment), KYC initiation, GDPR endpoints, merchant management, admin operations, and AML compliance. Applies a 7-step middleware chain on every request.
Key Interfaces: 26+ endpoints under /v1/. External calls to BankID token endpoint, Sumsub API, and Open Banking PISP/AISP.
Rationale: Single source of business logic truth, consumed by both web (cookie auth) and mobile (Bearer token auth). Hono v4 chosen for performance on Node.js 22 (see ADR-008).
drop-mobile
Responsibility: Native iOS and Android application. Provides the core payment features: BankID login, dashboard with balance, send money, QR scanner, transaction history, and profile management.
Key Interfaces: Same Hono API /v1/* endpoints as web, using Bearer token (Authorization: Bearer <jwt>) instead of cookies. BankID auth via expo-web-browser + deep link drop://auth/callback.
Rationale: Separate from drop-web to allow platform-native UX, native push notifications (future), and biometric auth (future).
5. Technology Stack
| Layer | Technology | Version | Rationale |
|---|---|---|---|
| Frontend Framework | Next.js | 15 (App Router) | SSR + RSC for performance; BFF capability for auth cookie management |
| UI Framework | React | 19 | Concurrent features, server components |
| Styling | Tailwind CSS | v4 | Utility-first, design token support |
| UI Components | shadcn/ui (Radix UI) | Latest | Accessible primitives, keyboard nav, unstyled baseline |
| Mobile Framework | Expo (React Native) | SDK 54 | Cross-platform iOS/Android, managed workflow, OTA updates |
| Backend Language | TypeScript / Node.js | Node 22 LTS | Type safety end-to-end, team expertise, shared types with frontend |
| Backend Framework | Hono | v4 | Ultrafast edge-compatible framework; better performance than Express; native middleware chaining |
| Primary Database (prod) | PostgreSQL | 16 | ACID compliance, row-level security, rich indexing, AWS RDS managed |
| Development Database | SQLite (better-sqlite3) | 3.x | Zero-config local dev, WAL mode, dual-driver abstraction switches transparently |
| Authentication | BankID OIDC + jose | 2.0 | Norwegian legal requirement for SCA; jose for JWKS verification |
| KYC/AML | Sumsub | API v1 | Document verification, PEP/sanctions, Norwegian compliance coverage |
| Open Banking | Berlin Group NextGenPSD2 via Neonomics (planned) | v1.3.12+ | PSD2 AISP/PISP; Neonomics aggregator for Nordic bank coverage |
| Error Tracking | Sentry | SDK v8 | Full-stack error capture, session replay, performance tracing |
| Container Runtime | Docker | 24+ | Multi-stage build (4 stages), non-root user, Node 22 Alpine |
| Orchestration | AWS App Runner | - | Auto-scaling, managed TLS, no Kubernetes operational overhead |
| Edge / CDN | Cloudflare | - | WAF, DDoS protection, CDN for static assets, geo-blocking |
| Secrets | AWS Secrets Manager | - | JWT_SECRET, BANKID_CLIENT_SECRET, DATABASE_URL, SENTRY_DSN |
| CI/CD | GitHub Actions (planned) | - | Automated: tsc → lint → vitest → Docker build → ECR push → App Runner deploy |
6. Data Flow Overview
6.1 Remittance Payment Flow (Write)
flowchart LR
A([User — Web/Mobile]) -->|"POST /v1/transactions/remittance"| B[Hono API]
B -->|"1. Verify JWT + session"| C[(PostgreSQL)]
B -->|"2. Validate: KYC approved, recipient exists, amount 100-50000 NOK"| B
B -->|"3. Lookup exchange rate"| C
B -->|"4. Begin atomic transaction"| C
C -->|"INSERT transactions status=processing"| C
C -->|"INSERT audit_log"| C
C -->|"INSERT notifications"| C
B -->|"5. Initiate PISP payment"| D[Open Banking API]
D -->|"SCA redirect URL"| B
B -->|"6. Return 201 + redirect"| A
A -->|"7. User completes BankID SCA at bank"| D
D -->|"8. Webhook: payment confirmed"| B
B -->|"9. UPDATE transactions status=completed"| C
6.2 Balance Read Flow (Read — AISP)
flowchart LR
A([User]) -->|"GET /api/auth/me"| B[Next.js BFF / Hono API]
B -->|"Verify JWT cookie"| C[(PostgreSQL)]
C -->|"bank_accounts.balance (cached)"| B
B -->|"If stale: GET /v1/accounts/{id}/balances"| D[Open Banking AISP]
D -->|"Live balance"| B
B -->|"UPDATE bank_accounts SET balance, balance_synced_at"| C
B -->|"Return {totalBalance, accounts}"| A
7. Integration Points
7.1 External Integrations
| System | Direction | Protocol | Auth | Data Exchanged | SLA/Criticality |
|---|---|---|---|---|---|
| BankID OIDC | Outbound | OIDC 1.0 / HTTPS | Client ID + Client Secret (code flow) | ID token (pid, name, birthdate), access token | 99.9% / Critical — all auth blocked if down |
| Sumsub KYC | Outbound + Inbound webhooks | REST HTTPS + Webhooks | API token + HMAC-SHA256 | Applicant data, documents, verification results, risk scores | 99.5% / High — new registrations blocked |
| Open Banking (Neonomics/ASPSP) | Outbound | Berlin Group NextGenPSD2 / HTTPS | eIDAS QWAC cert + OAuth2 | Account lists, balances (AISP); payment initiations, payment status (PISP) | 99.5% / Critical — payments blocked if PISP down; AISP degrades to cached balance |
| SEPA/SWIFT | Outbound via banking partner | ISO 20022 | Banking partner credentials | Remittance transfers (amounts, IBANs, reference) | Best-effort / High — delays expected on bank outages |
| Cloudflare | Inbound (proxied) | DNS + HTTPS | Cloudflare API key | HTTP traffic, TLS, WAF rules | 99.99% / Critical — all traffic routed via Cloudflare |
| AWS Secrets Manager | Outbound | HTTPS | IAM role | JWT_SECRET, BANKID_CLIENT_SECRET, DATABASE_URL, SENTRY_DSN | 99.99% / Critical — startup fails if unavailable |
| Sentry | Outbound | HTTPS (SDK) | DSN token | Error events, stack traces, performance traces | Best-effort / Low — observability only |
7.2 Internal Service Integrations
| Service | Integration Type | Protocol | Notes |
|---|---|---|---|
| drop-web → drop-api | Synchronous | REST HTTPS | Web auth via httpOnly cookie (drop_token); API calls to /v1/* |
| drop-mobile → drop-api | Synchronous | REST HTTPS | Bearer token in Authorization header; same /v1/* API endpoints |
| drop-api → PostgreSQL | Synchronous | TCP (SQL) | db.ts dual-driver abstraction; parameterized queries only |
8. Deployment Overview
flowchart TB
subgraph Internet
Users[End Users — Browser + Mobile]
end
subgraph Cloudflare["Cloudflare Edge (getdrop.no)"]
DNS[DNS]
CDN[CDN — Static Assets /_next/static/*]
WAF[WAF — OWASP CRS + custom rules]
DDoS[DDoS Protection L3/L4/L7]
end
subgraph AWS["AWS eu-north-1 (Stockholm)"]
subgraph AppRunner["AWS App Runner (PLANNED)"]
WebApp[drop-web<br/>Next.js 15 standalone<br/>Node.js 22 Alpine<br/>Port 3000<br/>1-5 instances]
API[drop-api<br/>Hono v4<br/>Node.js 22 Alpine<br/>Port 3001<br/>1-10 instances]
end
subgraph DataTier["Data Tier"]
RDS[(RDS PostgreSQL 16<br/>db.t3.medium → db.r6g.large<br/>Multi-AZ — prod<br/>100GB gp3, auto-scale to 500GB<br/>30-day backup retention)]
end
subgraph Supporting["Supporting"]
ECR[ECR — Container Registry<br/>Image scanning enabled]
SM[Secrets Manager<br/>JWT_SECRET / BANKID_CLIENT_SECRET<br/>DATABASE_URL / SENTRY_DSN]
CW[CloudWatch<br/>Logs + Metrics + Alarms]
end
end
Users --> DNS
DNS --> CDN
CDN --> WAF
WAF --> DDoS
DDoS --> WebApp
DDoS --> API
WebApp --> RDS
API --> RDS
AppRunner --> ECR
AppRunner --> SM
AppRunner --> CW
Environments
| Environment | URL | Purpose | Database | BankID | Scale |
|---|---|---|---|---|---|
| Development | http://localhost:3000 + :3001 | Local dev via docker compose up |
SQLite (./data/drop.db) |
Mock (BANKID_MOCK=true) |
Single instance |
| Staging | https://staging.getdrop.no | Pre-release validation, QA, E2E | RDS PostgreSQL (separate) | BankID test environment | 1 replica |
| Production | https://getdrop.no | Live traffic | RDS PostgreSQL Multi-AZ | BankID production | Auto-scaled (1-5 web, 1-10 API) |
9. Cross-Cutting Concerns
9.1 Authentication & Authorization
- Strategy: BankID OIDC (Authorization Code Flow) — email/password removed, returns 410 Gone
- Identity Provider: BankID Norge (
auth.bankid.no) — OIDC 1.0, JWKS-verified ID tokens - Authorization Model: Role-based —
user(default) vsmerchant(gated). MiddlewareauthMiddleware+merchantMiddlewareenforce per-endpoint. - Token Lifetime: Web: 24h httpOnly cookie (
drop_token); Mobile: 7d Bearer JWT in AsyncStorage - MFA: Yes — BankID provides strong two-factor SCA (possession + knowledge/inherence) on every login and payment
9.2 Logging
- Framework: Hono native + console.log, captured by Sentry
- Format: JSON structured logs where available; request ID propagated via
x-request-idheader - Levels: ERROR/WARN sent to Sentry; INFO to CloudWatch
- Correlation IDs:
x-request-idgenerated per request (UUID), echoed in response header - Retention: 90 days in CloudWatch
- PII Handling: National IDs stored as SHA-256 hash only; raw PII never logged
9.3 Error Handling
- API Errors: JSON envelope
{ error: "code", message: "...", details: [...] } - Retry Strategy: External API calls: exponential backoff [1s, 2s, 4s], max 3 retries
- Circuit Breaker: Open Banking API: 3 failures in 60s → 60s cooldown
- Global Error Handler:
middleware/error-handler.ts— catches all unhandled errors, logs to Sentry, returns 500
9.4 Rate Limiting
- Implementation: Redis-less; DB-backed
rate_limitstable with SQLite/PostgreSQL dual support - Default Limits: Auth endpoints: 10 req/60s per IP; Transactions: 10 req/60s per IP + 3 per-user; Exchange rates: 120 req/60s
- Cloudflare WAF:
/v1/auth/*→ challenge at 20 req/10s;/v1/transactions/*→ block at 30 req/10s - Response: HTTP 429 (no Retry-After header currently; planned)
9.5 Secrets Management
- Tool: AWS Secrets Manager (production); environment variables (development)
- Rotation: Manual rotation policy — planned automation via Secrets Manager rotation Lambda
- Principle: No secrets in code;
JWT_SECREThas a dev-only fallback string that triggers a warning
9.6 Feature Flags
- Tool: Environment variables read at startup (in-memory, process lifetime)
- Flags:
CARDS_ENABLED(default false),ADVANCED_ANALYTICS(default false),WITHDRAW_ENABLED(default false) - Toggle: Restart required for flag changes; no runtime toggle UI
10. Quality Attributes & Architectural Trade-offs
| Quality Attribute | Target | Approach | Trade-off |
|---|---|---|---|
| Availability | 99.5% uptime | AWS App Runner multi-instance, Cloudflare 99.99% edge, RDS Multi-AZ | Higher AWS cost vs single-AZ |
| Performance (p99 latency) | < 200ms API responses | No external cache (SQLite WAL / PostgreSQL handles load), Cloudflare CDN for static assets | No Redis cache — acceptable at current scale |
| Scalability | 1,000 concurrent users (MVP) | App Runner auto-scale: 1-10 API instances, 1-5 web instances; stateless API (JWT) | All-or-nothing scaling (monolith) |
| Security | OWASP Top 10 compliant | Cloudflare WAF, parameterized SQL, httpOnly cookies, BankID SCA, HMAC webhooks | BankID adds auth flow complexity |
| Regulatory Compliance | PSD2, GDPR, AML (hvitvaskingsloven) | BankID SCA for payments, Sumsub KYC, 19-table compliance schema, STR filing | Compliance overhead slows feature delivery |
| Maintainability | Weekly deploys | Monolith-first (ADR-005), vitest test suite, TypeScript strict mode | Module boundary erosion risk without process isolation |
| Data Consistency | Strong (per transaction) | Atomic DB transactions for all financial operations, idempotency keys on payments | No eventual consistency — simpler but single-DB dependency |
11. Key Architectural Decisions
| ADR | Decision | Status | Date |
|---|---|---|---|
| ADR-001 | Consolidate to single Hono backend (remove dual middleware) | Accepted | 2026-02-12 |
| ADR-003 | Adopt PSD2 pass-through model — no wallet, no held funds | Accepted | 2026-02-12 |
| ADR-004 | JWT in httpOnly cookies (web) + Bearer tokens (mobile) | Accepted | 2026-02-12 |
| ADR-005 | Monolith-first architecture — extract microservices when team/scale demands | Accepted | 2026-02-21 |
| ADR-006 | Dual-driver DB abstraction: SQLite (dev) / PostgreSQL (prod) | Accepted | 2026-02-21 |
| ADR-007 | BankID as sole identity provider (email/password removed) | Accepted | 2026-02-21 |
| ADR-008 | Hono v4 as the API framework | Accepted | 2026-02-21 |
| ADR-012 | AWS App Runner for container hosting | Accepted | 2026-02-21 |
12. Constraints & Assumptions
12.1 Constraints
| # | Constraint | Category | Impact |
|---|---|---|---|
| C1 | Users must be Norwegian residents (18+) with Norwegian BankID and +47 phone number | Regulatory | Limits market to Norway; no international expansion without separate licensing |
| C2 | Drop must never hold customer funds (PSD2 pass-through model) | Regulatory | PISP/AISP architecture mandatory; wallet model legally excluded |
| C3 | BankID SCA required for every financial operation (PISP payment) | Regulatory / PSD2 RTS | Each payment requires bank SCA redirect — adds UX friction |
| C4 | 5-year AML data retention (hvitvaskingsloven) | Regulatory | Compliance tables cannot be purged; storage costs grow over time |
| C5 | GDPR Art. 17 right to erasure — soft delete + 5yr AML retention override | Regulatory | Cannot hard-delete user data if AML records exist |
| C6 | Finanstilsynet PISP/AISP license not yet obtained (Phase 2 blocker) | Regulatory | Live Open Banking API calls not permitted until license or agent arrangement secured |
| C7 | Monolith-first — all containers deploy together | Technical | No independent scaling per module; full deploy required for any change |
| C8 | Budget: AWS Secrets Manager, App Runner, RDS — cost scales with usage | Business | Architecture chosen for low base cost; scales to higher tiers on growth |
12.2 Assumptions
| # | Assumption | Validation Method | Risk if Wrong |
|---|---|---|---|
| A1 | Neonomics or Tink will provide Open Banking aggregator service for Phase 2 Nordic bank connectivity | Contract negotiation in Phase 2 | Direct per-bank ASPSP integration required (significantly higher effort) |
| A2 | BankID Norge will approve Drop's OIDC client registration | BankID developer portal application | Must use Vipps Login or alternative OIDC provider |
| A3 | PostgreSQL on RDS handles expected transaction volume without read replicas at MVP | Load testing before production launch | Must add read replicas or implement caching layer |
| A4 | App Runner rolling updates are sufficient (no true blue/green needed at MVP scale) | Monitor during first production deploy | Must implement custom blue/green via ALB traffic shifting |
13. Risks & Mitigations
| Risk | Likelihood | Impact | Score | Mitigation | Contingency |
|---|---|---|---|---|---|
| Finanstilsynet license delayed (>12 months) | 3 | 5 | 15 | Use licensed PSP agent arrangement (1-3 months setup) while applying | Demo/mock mode continues; partner with licensed PSP |
| BankID integration blocked (client not approved) | 2 | 5 | 10 | Apply early; prepare Vipps Login as alternative OIDC (same pid claim) | Vipps Login fallback (same architecture, different OIDC endpoints) |
| Open Banking ASPSP API unavailability (AISP) | 4 | 2 | 8 | Show cached balance with staleness indicator | Degrade gracefully: display last-known balance |
| Open Banking ASPSP API unavailability (PISP) | 3 | 5 | 15 | Circuit breaker; notify user to retry | Payment cannot proceed — user notified with ETA |
| Single database bottleneck (PostgreSQL) | 2 | 4 | 8 | Connection pooling, read replicas when needed, App Runner horizontal scale | Add read replicas, implement CQRS for transaction reads |
| Data breach via SQL injection | 1 | 5 | 5 | Parameterized queries (db.ts enforces), WAF, no raw SQL strings in routes | GDPR breach notification within 72h, incident response plan |
| Sumsub KYC outage (new user registrations blocked) | 2 | 3 | 6 | Retry queue; existing approved users unaffected | Queue new registrations; manual KYC review for priority users |
Approval
| Role | Name | Date | Signature |
|---|---|---|---|
| Author | Petter Graff | 2026-02-23 | |
| Technical Lead | John (AI Director) | ||
| Security Review | |||
| Approver (CEO) | Alem Bašić |
Low-Level Design (LLD)
Low-Level Design Document
Project: {{PROJECT_NAME}} Module/Component: {{MODULE_NAME}} Version: {{VERSION}} Date: {{DATE}} Author: {{AUTHOR}} Status: Draft | In Review | Approved Reviewers: {{REVIEWERS}} Related HLD: HLD Document
Document History
| Version | Date | Author | Changes |
|---|---|---|---|
| 0.1 | {{DATE}} | {{AUTHOR}} | Initial draft |
1. Module Overview
Module: {{MODULE_NAME}}
Service/Repo: {{REPO_NAME}}
Team Owner: {{TEAM_NAME}}
Single Responsibility: {{ONE_SENTENCE_WHAT_THIS_MODULE_DOES}}
Boundaries:
- Owns: {{WHAT_THIS_MODULE_OWNS}}
- Does NOT own: {{WHAT_THIS_MODULE_DOES_NOT_OWN}}
- Delegates to: {{WHAT_IT_DELEGATES}}
Key Business Rules:
- {{BUSINESS_RULE_1}}
- {{BUSINESS_RULE_2}}
- {{BUSINESS_RULE_3}}
2. Class / Module Diagram
classDiagram
class {{ServiceClassName}} {
-repository: {{RepositoryInterface}}
-eventBus: EventBus
-logger: Logger
+create(dto: Create{{Entity}}Dto): Promise~{{Entity}}~
+findById(id: string): Promise~{{Entity}} | null~
+findAll(filter: {{Filter}}Dto): Promise~PaginatedResult~{{Entity}}~~
+update(id: string, dto: Update{{Entity}}Dto): Promise~{{Entity}}~
+delete(id: string): Promise~void~
-validate(dto: unknown): void
-publishEvent(event: {{DomainEvent}}): Promise~void~
}
class {{RepositoryInterface}} {
<<interface>>
+findById(id: string): Promise~{{Entity}} | null~
+findMany(filter: {{Filter}}): Promise~{{Entity}}[]~
+create(data: Partial~{{Entity}}~): Promise~{{Entity}}~
+update(id: string, data: Partial~{{Entity}}~): Promise~{{Entity}}~
+delete(id: string): Promise~void~
+count(filter: {{Filter}}): Promise~number~
}
class {{Entity}} {
+id: string
+createdAt: Date
+updatedAt: Date
+deletedAt: Date | null
{{FIELD_1}}: {{TYPE_1}}
{{FIELD_2}}: {{TYPE_2}}
+isDeleted(): boolean
+toJSON(): {{EntityJSON}}
}
class Create{{Entity}}Dto {
{{FIELD_1}}: {{TYPE_1}}
{{FIELD_2}}: {{TYPE_2}}
}
class {{Controller}} {
-service: {{ServiceClassName}}
+POST /{{resource}}(body: Create{{Entity}}Dto): Response
+GET /{{resource}}/:id(): Response
+GET /{{resource}}(query: {{Filter}}Dto): Response
+PUT /{{resource}}/:id(body: Update{{Entity}}Dto): Response
+DELETE /{{resource}}/:id(): Response
}
{{ServiceClassName}} --> {{RepositoryInterface}}
{{ServiceClassName}} --> {{Entity}}
{{Controller}} --> {{ServiceClassName}}
{{RepositoryInterface}} ..> {{Entity}}
3. Database Schema
3.1 Tables
{{TABLE_NAME_1}}
Purpose: {{TABLE_PURPOSE}}
| Column | Type | Nullable | Default | Constraints | Description |
|---|---|---|---|---|---|
id |
UUID |
NO | gen_random_uuid() |
PK | Primary key |
created_at |
TIMESTAMPTZ |
NO | NOW() |
Record creation timestamp | |
updated_at |
TIMESTAMPTZ |
NO | NOW() |
Last update timestamp | |
deleted_at |
TIMESTAMPTZ |
YES | NULL |
Soft delete timestamp | |
{{COLUMN_1}} |
{{TYPE}} |
{{YES/NO}} | {{DEFAULT}} |
{{CONSTRAINTS}} | {{DESCRIPTION}} |
{{COLUMN_2}} |
{{TYPE}} |
{{YES/NO}} | {{DEFAULT}} |
{{CONSTRAINTS}} | {{DESCRIPTION}} |
{{FK_COLUMN}} |
UUID |
NO | FK → {{OTHER_TABLE}}(id) |
Reference to {{OTHER_TABLE}} |
Indexes:
| Index Name | Columns | Type | Rationale |
|---|---|---|---|
{{TABLE_NAME_1}}_pkey |
id |
B-tree (PK) | Primary key lookup |
idx_{{TABLE_NAME_1}}_{{COLUMN}} |
{{COLUMN}} |
B-tree | {{QUERY_RATIONALE}} |
idx_{{TABLE_NAME_1}}_deleted_at |
deleted_at |
Partial (WHERE deleted_at IS NULL) | Soft-delete filter performance |
{{TABLE_NAME_2}}
Purpose: {{TABLE_PURPOSE}}
| Column | Type | Nullable | Default | Constraints | Description |
|---|---|---|---|---|---|
id |
UUID |
NO | gen_random_uuid() |
PK | Primary key |
{{COLUMN_1}} |
{{TYPE}} |
{{YES/NO}} | {{DEFAULT}} |
{{CONSTRAINTS}} | {{DESCRIPTION}} |
3.2 Enums
CREATE TYPE {{ENUM_NAME}} AS ENUM (
'{{VALUE_1}}',
'{{VALUE_2}}',
'{{VALUE_3}}'
);
3.3 Migration Notes
- Migration file:
{{MIGRATION_FILE_NAME}}.sql - Zero-downtime: {{YES_NO}} — {{NOTES}}
- Backfill required: {{YES_NO}} — {{BACKFILL_STRATEGY}}
- Estimated migration time: {{ESTIMATE}}
4. API Contract
Base Path: /api/v{{VERSION}}/{{RESOURCE}}
POST /{{resource}}
Summary: Create a new {{entity}}
Authentication: Bearer JWT required
Request Body:
{
"{{field1}}": "{{type_and_example}}",
"{{field2}}": "{{type_and_example}}"
}
Success Response — 201 Created:
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"{{field1}}": "{{value}}",
"createdAt": "2024-01-01T00:00:00.000Z"
}
Error Responses:
| Status | Code | Description |
|---|---|---|
400 |
VALIDATION_ERROR |
Request body fails validation |
401 |
UNAUTHORIZED |
Missing or invalid JWT |
403 |
FORBIDDEN |
Insufficient permissions |
409 |
CONFLICT |
{{DUPLICATE_FIELD}} already exists |
422 |
BUSINESS_RULE_VIOLATION |
{{BUSINESS_RULE}} not met |
500 |
INTERNAL_ERROR |
Unexpected server error |
GET /{{resource}}/:id
Summary: Retrieve a {{entity}} by ID
Path Parameters:
| Parameter | Type | Description |
|---|---|---|
id |
UUID |
The {{entity}} identifier |
Success Response — 200 OK:
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"{{field1}}": "{{value}}",
"createdAt": "2024-01-01T00:00:00.000Z"
}
Error Responses:
| Status | Code | Description |
|---|---|---|
401 |
UNAUTHORIZED |
Missing or invalid JWT |
404 |
NOT_FOUND |
{{entity}} not found |
GET /{{resource}}
Summary: List {{entities}} with pagination and filtering
Query Parameters:
| Parameter | Type | Default | Description |
|---|---|---|---|
page |
integer |
1 |
Page number (1-based) |
limit |
integer |
20 |
Items per page (max: 100) |
sort |
string |
createdAt:desc |
Sort field and direction |
{{filter1}} |
{{type}} |
— | Filter by {{filter1}} |
Success Response — 200 OK:
{
"data": [],
"pagination": {
"page": 1,
"limit": 20,
"total": 0,
"totalPages": 0
}
}
5. Algorithm Specifications
5.1 {{ALGORITHM_NAME}}
Purpose: {{WHAT_IT_DOES}} Complexity: Time O({{TIME_COMPLEXITY}}) | Space O({{SPACE_COMPLEXITY}})
function {{algorithmName}}(input: {{InputType}}): {{OutputType}}
// Step 1: Validate input
if input is null or invalid then
throw ValidationError("{{VALIDATION_MESSAGE}}")
end if
// Step 2: {{STEP_2_DESCRIPTION}}
result = initialize()
for each item in input.items do
if item.satisfies({{CONDITION}}) then
result.add(transform(item))
end if
end for
// Step 3: {{STEP_3_DESCRIPTION}}
sorted = result.sortBy({{SORT_CRITERIA}})
// Step 4: Apply business rules
for each rule in {{BUSINESS_RULES}} do
sorted = rule.apply(sorted)
end for
return sorted
end function
Edge Cases:
- Empty input: Return empty result, do not throw
- Single item: {{SINGLE_ITEM_BEHAVIOR}}
- Maximum items: Limit to {{MAX_ITEMS}}, log warning if exceeded
6. Sequence Diagrams
6.1 Create {{Entity}} Flow
sequenceDiagram
autonumber
actor Client
participant GW as API Gateway
participant Auth as Auth Middleware
participant SVC as {{ServiceClassName}}
participant DB as Database
participant MQ as Message Queue
participant Email as Email Service
Client->>GW: POST /{{resource}} {body}
GW->>Auth: Validate JWT
Auth-->>GW: User context {userId, roles}
GW->>SVC: create(dto, userContext)
SVC->>SVC: validate(dto)
alt Validation fails
SVC-->>Client: 400 ValidationError
end
SVC->>DB: BEGIN TRANSACTION
SVC->>DB: INSERT INTO {{TABLE_NAME_1}}
DB-->>SVC: {{entity}} record
SVC->>DB: COMMIT
SVC->>MQ: publish("{{ENTITY_CREATED_EVENT}}", event)
MQ->>Email: Trigger confirmation email
Email-->>Client: Email delivered async
SVC-->>GW: {{entity}} dto
GW-->>Client: 201 Created
6.2 {{SECONDARY_FLOW_NAME}} Flow
sequenceDiagram
autonumber
actor Client
participant SVC as {{ServiceClassName}}
participant Cache as Redis
participant DB as Database
Client->>SVC: GET /{{resource}}/:id
SVC->>Cache: GET {{resource}}:{{id}}
alt Cache hit
Cache-->>SVC: Cached {{entity}}
SVC-->>Client: 200 OK (from cache)
else Cache miss
Cache-->>SVC: null
SVC->>DB: SELECT * FROM {{TABLE_NAME_1}} WHERE id = $1
alt Not found
DB-->>SVC: null
SVC-->>Client: 404 Not Found
end
DB-->>SVC: {{entity}} record
SVC->>Cache: SET {{resource}}:{{id}} TTL={{CACHE_TTL}}
SVC-->>Client: 200 OK
end
7. State Diagrams
stateDiagram-v2
[*] --> {{STATE_DRAFT}} : Created
{{STATE_DRAFT}} --> {{STATE_PENDING}} : submit()
{{STATE_DRAFT}} --> [*] : delete()
{{STATE_PENDING}} --> {{STATE_ACTIVE}} : approve()
{{STATE_PENDING}} --> {{STATE_REJECTED}} : reject(reason)
{{STATE_PENDING}} --> {{STATE_DRAFT}} : recall()
{{STATE_ACTIVE}} --> {{STATE_SUSPENDED}} : suspend(reason)
{{STATE_ACTIVE}} --> {{STATE_COMPLETED}} : complete()
{{STATE_ACTIVE}} --> {{STATE_CANCELLED}} : cancel(reason)
{{STATE_SUSPENDED}} --> {{STATE_ACTIVE}} : reactivate()
{{STATE_SUSPENDED}} --> {{STATE_CANCELLED}} : cancel(reason)
{{STATE_REJECTED}} --> {{STATE_DRAFT}} : revise()
{{STATE_COMPLETED}} --> [*]
{{STATE_CANCELLED}} --> [*]
State Transition Rules:
| From | To | Trigger | Guard Condition | Side Effect |
|---|---|---|---|---|
| DRAFT | PENDING | submit() |
All required fields populated | Notify reviewers |
| PENDING | ACTIVE | approve() |
Approver has {{PERMISSION}} role |
Send welcome email |
| ACTIVE | SUSPENDED | suspend(reason) |
Admin only | Log audit event |
8. Error Handling Strategy
8.1 Error Classification
| Error Type | HTTP Status | Retry? | Log Level | Alert? |
|---|---|---|---|---|
| ValidationError | 400 | No | INFO | No |
| UnauthorizedError | 401 | No | WARN | No |
| ForbiddenError | 403 | No | WARN | Suspicious patterns only |
| NotFoundError | 404 | No | INFO | No |
| ConflictError | 409 | No | WARN | No |
| ExternalServiceError | 502 | Yes (3x) | ERROR | Yes (if sustained) |
| DatabaseError | 500 | Yes (1x) | ERROR | Yes |
| UnexpectedError | 500 | No | ERROR | Yes |
8.2 Error Response Format (RFC 7807)
{
"type": "https://api.{{DOMAIN}}/errors/{{ERROR_CODE}}",
"title": "{{Human-readable error title}}",
"status": 400,
"detail": "{{Specific error message}}",
"instance": "/{{resource}}/{{id}}",
"traceId": "{{TRACE_ID}}"
}
8.3 Retry & Fallback Strategy
External API call failure:
→ Retry with exponential backoff: [1s, 2s, 4s]
→ Max retries: 3
→ Circuit breaker: Open after 5 failures in 60s window
→ Fallback: {{FALLBACK_BEHAVIOR}}
→ Alert: PagerDuty if circuit remains open > 5 minutes
9. Concurrency & Thread Safety
| Concern | Scenario | Mitigation |
|---|---|---|
| Duplicate creation | Two requests create same {{entity}} simultaneously | Unique constraint on {{UNIQUE_FIELD}} + catch constraint error → 409 |
| Optimistic locking | Two updates to same record | version column + check-and-increment |
| Race condition in {{FLOW}} | {{RACE_CONDITION_DESCRIPTION}} | {{MITIGATION}} (e.g., DB-level lock, Redis SETNX) |
10. Performance Considerations
| Operation | Target (p99) | Current Baseline | Optimization |
|---|---|---|---|
GET /{{resource}}/:id |
< 50ms | {{BASELINE}} | Redis cache (TTL={{TTL}}) |
GET /{{resource}} (list) |
< 200ms | {{BASELINE}} | Indexed query + cursor pagination |
POST /{{resource}} |
< 300ms | {{BASELINE}} | Async event publishing |
| Bulk import (1000 items) | < 5s | {{BASELINE}} | Batch insert in chunks of 100 |
Known bottlenecks:
- {{BOTTLENECK_1}}: {{MITIGATION}}
- {{BOTTLENECK_2}}: {{MITIGATION}}
11. Dependencies
Internal Dependencies
| Dependency | Type | Purpose | Fallback if unavailable |
|---|---|---|---|
{{INTERNAL_SERVICE_1}} |
Synchronous | {{PURPOSE}} | {{FALLBACK}} |
{{SHARED_LIB}} |
Library | {{PURPOSE}} | N/A (required) |
External Dependencies
| Dependency | Version | Purpose | Fallback if unavailable |
|---|---|---|---|
| PostgreSQL | {{VERSION}} |
Primary data store | None — module unavailable |
| Redis | {{VERSION}} |
Caching + session | Degrade gracefully (skip cache) |
{{EXTERNAL_API}} |
v{{VERSION}} |
{{PURPOSE}} | {{FALLBACK}} |
12. Configuration Parameters
| Variable | Type | Default | Required | Description |
|---|---|---|---|---|
{{MODULE_NAME}}_MAX_PAGE_SIZE |
integer |
100 |
No | Maximum items per page |
{{MODULE_NAME}}_CACHE_TTL_SECONDS |
integer |
300 |
No | Cache TTL in seconds |
{{MODULE_NAME}}_RETRY_ATTEMPTS |
integer |
3 |
No | External API retry count |
{{EXTERNAL_API_KEY_VAR}} |
string |
— | Yes | API key for {{EXTERNAL_SERVICE}} |
{{DB_CONNECTION_VAR}} |
string |
— | Yes | Database connection string |
13. Testing Approach
| Test Type | Tool | Coverage Target | Location |
|---|---|---|---|
| Unit tests | Jest / Vitest | > {{UNIT_COVERAGE}}% | src/{{module}}/__tests__/unit/ |
| Integration tests | Supertest + TestContainers | Key flows | src/{{module}}/__tests__/integration/ |
| Contract tests | Pact | All public APIs | src/{{module}}/__tests__/contract/ |
Key test scenarios:
- Create {{entity}} — success path
- Create {{entity}} — validation error (missing required field)
- Create {{entity}} — conflict (duplicate {{UNIQUE_FIELD}})
- Get {{entity}} by ID — found
- Get {{entity}} by ID — not found (404)
- List {{entities}} — with filters, pagination, sorting
- Update {{entity}} — success
- Update {{entity}} — not found
- Delete {{entity}} — success (soft delete)
- State transition — valid transition
- State transition — invalid transition (guard fails)
- External service failure — circuit breaker triggers
- Concurrent creation — duplicate constraint handled
Approval
| Role | Name | Date | Signature |
|---|---|---|---|
| Author | |||
| Module Owner | |||
| Security Review | |||
| Tech Lead |
Architecture Decision Record (ADR)
Architecture Decision Record — ADR-{{NUMBER}}
Project: {{PROJECT_NAME}} ADR Number: ADR-{{NUMBER}} Title: {{SHORT_DECISION_TITLE}} Version: 1.0 Date: {{DATE}} Author: {{AUTHOR}} Status: Proposed | Accepted | Deprecated | Superseded by ADR-{{NEW_NUMBER}} Reviewers: {{REVIEWERS}}
Document History
| Version | Date | Author | Changes |
|---|---|---|---|
| 0.1 | {{DATE}} | {{AUTHOR}} | Initial draft |
| 1.0 | {{DATE}} | {{AUTHOR}} | Accepted after review |
ADR Numbering Scheme
Convention: ADR-{NNN}-{short-slug}.md — e.g., ADR-001-use-postgresql.md
1. Context
1.1 Situation
{{DESCRIBE_THE_SITUATION_REQUIRING_A_DECISION}}
1.2 Forces & Constraints
Technical forces:
- {{TECHNICAL_FORCE_1}} — e.g., "We need horizontal scalability for 100K+ concurrent users"
- {{TECHNICAL_FORCE_2}} — e.g., "The team has strong expertise in TypeScript but limited Go experience"
- {{TECHNICAL_FORCE_3}}
Business forces:
- {{BUSINESS_FORCE_1}} — e.g., "Time-to-market is critical; MVP must launch in 3 months"
- {{BUSINESS_FORCE_2}} — e.g., "Budget limits managed services to under $2K/month"
Compliance & regulatory:
- {{COMPLIANCE_FORCE}} — e.g., "GDPR requires data residency in EEA"
Existing decisions that constrain this:
- ADR-{{PARENT_NUMBER}}: {{PARENT_DECISION}} — constrains us to {{CONSTRAINT}}
- Existing infrastructure: {{EXISTING_SYSTEM}}
1.3 Problem Statement
We need to decide: {{CLEAR_ONE_SENTENCE_PROBLEM_STATEMENT}}
2. Decision
We will: {{CLEAR_DECISION_STATEMENT}}
Rationale (summary): {{ONE_PARAGRAPH_WHY}}
3. Alternatives Considered
Option A: {{OPTION_A_NAME}} ← Selected
Description: {{OPTION_A_DESCRIPTION}}
Pros:
- {{PRO_1}}: {{EXPLANATION}}
- {{PRO_2}}: {{EXPLANATION}}
- {{PRO_3}}: {{EXPLANATION}}
Cons:
- {{CON_1}}: {{EXPLANATION}}
- {{CON_2}}: {{EXPLANATION}}
Cost/Effort: {{ESTIMATE}} — {{NOTES}}
Risk: {{RISK_LEVEL}} — {{RISK_DESCRIPTION}}
Option B: {{OPTION_B_NAME}}
Description: {{OPTION_B_DESCRIPTION}}
Pros:
- {{PRO_1}}: {{EXPLANATION}}
- {{PRO_2}}: {{EXPLANATION}}
Cons:
- {{CON_1}}: {{EXPLANATION}}
- {{CON_2}}: {{EXPLANATION}}
- {{CON_3}}: {{EXPLANATION}}
Cost/Effort: {{ESTIMATE}} — {{NOTES}}
Risk: {{RISK_LEVEL}} — {{RISK_DESCRIPTION}}
Why not chosen: {{SPECIFIC_REASON_REJECTED}}
Option C: {{OPTION_C_NAME}}
Description: {{OPTION_C_DESCRIPTION}}
Pros:
- {{PRO_1}}: {{EXPLANATION}}
Cons:
- {{CON_1}}: {{EXPLANATION}}
- {{CON_2}}: {{EXPLANATION}}
Cost/Effort: {{ESTIMATE}}
Why not chosen: {{SPECIFIC_REASON_REJECTED}}
Comparison Matrix
| Criterion | Weight | Option A (Selected) | Option B | Option C |
|---|---|---|---|---|
| {{CRITERION_1}} | {{1-5}} | {{SCORE}} | {{SCORE}} | {{SCORE}} |
| {{CRITERION_2}} | {{1-5}} | {{SCORE}} | {{SCORE}} | {{SCORE}} |
| {{CRITERION_3}} | {{1-5}} | {{SCORE}} | {{SCORE}} | {{SCORE}} |
| Team expertise | 4 | {{SCORE}} | {{SCORE}} | {{SCORE}} |
| Operational complexity | 3 | {{SCORE}} | {{SCORE}} | {{SCORE}} |
| Cost (3-year TCO) | 4 | {{SCORE}} | {{SCORE}} | {{SCORE}} |
| Community/support | 2 | {{SCORE}} | {{SCORE}} | {{SCORE}} |
| Weighted Total | {{TOTAL_A}} | {{TOTAL_B}} | {{TOTAL_C}} |
Score: 1 (poor) to 5 (excellent)
4. Consequences
4.1 Positive Consequences
- {{POSITIVE_1}}: {{DETAIL}}
- {{POSITIVE_2}}: {{DETAIL}}
- {{POSITIVE_3}}: {{DETAIL}}
4.2 Negative Consequences
- {{NEGATIVE_1}}: {{DETAIL}} — Mitigation: {{MITIGATION}}
- {{NEGATIVE_2}}: {{DETAIL}} — Mitigation: {{MITIGATION}}
4.3 Neutral / Secondary Effects
- {{NEUTRAL_1}}: {{DETAIL}}
- {{NEUTRAL_2}}: {{DETAIL}}
4.4 Technical Debt Created
- {{DEBT_ITEM}}: {{PLAN_TO_ADDRESS}}
- Acceptable because: {{REASON}}
5. Compliance Impact
| Regulation | Impact | Notes |
|---|---|---|
| GDPR | {{NONE/LOW/MEDIUM/HIGH}} | {{DETAILS}} |
| PCI-DSS | {{NONE/LOW/MEDIUM/HIGH}} | {{DETAILS}} |
| HIPAA | {{NONE/LOW/MEDIUM/HIGH}} | {{DETAILS}} |
| SOC2 | {{NONE/LOW/MEDIUM/HIGH}} | {{DETAILS}} |
Data residency implications: {{NONE / DATA_MUST_STAY_IN_REGION}}
6. Performance Impact
| Metric | Before | After (Expected) | Source |
|---|---|---|---|
| Latency (p99) | {{BEFORE}} | {{AFTER}} | {{BENCHMARK_SOURCE}} |
| Throughput | {{BEFORE}} | {{AFTER}} | {{BENCHMARK_SOURCE}} |
| Memory footprint | {{BEFORE}} | {{AFTER}} | {{BENCHMARK_SOURCE}} |
| Cost (monthly) | {{BEFORE}} | {{AFTER}} | {{ESTIMATE_SOURCE}} |
Performance testing plan: {{HOW_WILL_WE_VALIDATE}}
7. Migration / Implementation Notes
7.1 Migration Plan
Phase 1 ({{DURATION}}): {{PHASE_1_DESCRIPTION}}
- [ ] {{TASK_1}}
- [ ] {{TASK_2}}
Phase 2 ({{DURATION}}): {{PHASE_2_DESCRIPTION}}
- [ ] {{TASK_3}}
- [ ] {{TASK_4}}
Phase 3 ({{DURATION}}): {{PHASE_3_DESCRIPTION}} (if needed)
- [ ] {{TASK_5}}
7.2 Rollback Strategy
Can we roll back? {{YES/NO/PARTIAL}} — {{EXPLANATION}}
If yes: {{ROLLBACK_STEPS}}
7.3 Feature Flags
| Flag | Purpose | Default |
|---|---|---|
{{FLAG_NAME}} |
{{PURPOSE}} | {{DEFAULT_VALUE}} |
8. Related ADRs
| ADR | Relationship | Notes |
|---|---|---|
| ADR-{{N}} | Prerequisite | {{HOW_IT_RELATES}} |
| ADR-{{N}} | Supersedes | This decision replaces {{OLD_DECISION}} |
| ADR-{{N}} | Affected | This decision impacts {{HOW}} |
9. Review Date
Next review: {{REVIEW_DATE}} or when {{TRIGGER_CONDITION}}
Review trigger conditions:
- {{TRIGGER_1}} — e.g., "If load exceeds 500K req/day, revisit caching strategy"
- {{TRIGGER_2}} — e.g., "If vendor pricing changes by more than 50%"
- {{TRIGGER_3}} — e.g., "After 6 months in production"
Superseded by: — (fill in if this ADR is later superseded)
Approval
| Role | Name | Date | Signature |
|---|---|---|---|
| Author | |||
| Tech Lead | |||
| Security (if compliance impact) | |||
| CTO / Architect |
Module Design Document
Module Design Document
Project: Drop Module: Payments Module (
transactions+recipients+exchange_rates) Service: drop-api —src/drop-api/src/routes/Version: 1.0 Date: 2026-02-23 Author: Petter Graff, Senior Enterprise Architect Status: Approved Reviewers: Alem Bašić (CEO), John (AI Director)
Document History
| Version | Date | Author | Changes |
|---|---|---|---|
| 0.1 | 2026-02-23 | Petter Graff | Initial draft from source code analysis |
1. Module Overview & Responsibility
Module: payments
Layer: Application (routes) + Domain (business logic) + Infrastructure (DB access)
Repository: src/drop-api/src/routes/transactions.ts, src/drop-api/src/routes/recipients.ts, src/drop-api/src/routes/rates.ts
Team Owner: ALAI — Backend
Single Responsibility Statement:
The Payments module orchestrates all financial operations — remittance (international money transfer) and QR merchant payments — using the PSD2 pass-through model, meaning Drop never holds funds but initiates payments from the user's bank via PISP.
This module owns:
- Transaction lifecycle (create, status update, list, fetch)
- Remittance business rules (fee calculation, FX rate application, PSD2 disclosure)
- QR payment business rules (merchant fee calculation, HMAC QR validation)
- Recipient management (CRUD for saved remittance contacts)
- Exchange rate lookup and caching (6 corridors)
- Idempotency enforcement for payment operations
- Pre-payment disclosure (PSD2 Art. 45/46)
This module does NOT own:
- User authentication and session management — owned by
authmodule (routes/auth.ts,lib/bankid.ts) - KYC/AML status — owned by
compliancemodule (routes/user.ts+ Sumsub integration) - Bank account linking (AISP consent) — owned by
bankingmodule (routes/bank-accounts.ts) - Merchant registration — owned by
merchantsmodule (routes/merchants.ts) - Notifications delivery — owned by
notificationsmodule (routes/notifications.ts)
Why this is a separate module:
Payments is the core revenue-generating bounded context for Drop. It has distinct business rules (PSD2 compliance, FX calculation, PISP orchestration), its own data domain (transactions, recipients, exchange_rates), and its own compliance requirements (PSD2 Art. 45/46 disclosure, idempotency, AML monitoring). Separating it enables independent testing, focused security review, and future extraction to a dedicated service if transaction volume demands it (per ADR-005 extraction triggers).
2. Interface Definition (Public API)
2.1 Exported Service Interface
// Remittance
interface IRemittanceService {
/**
* Calculate pre-payment disclosure (fee, FX rate, receive amount)
* Required by PSD2 Art. 45/46 before every payment
* @throws {NotFoundError} if recipientId not found or not owned by user
* @throws {NotFoundError} if exchange rate for currency not found
*/
calculateDisclosure(dto: DisclosureDto, userId: string): Promise<DisclosureResult>;
/**
* Initiate international remittance via PISP
* @throws {ForbiddenError} if KYC not approved
* @throws {ForbiddenError} if insufficient balance
* @throws {NotFoundError} if recipient not found
* @throws {ConflictError} if idempotency key collision (returns existing tx)
* @throws {ValidationError} if amount outside 100-50000 NOK range
*/
initiateRemittance(dto: RemittanceDto, userId: string): Promise<Transaction>;
/**
* Initiate QR merchant payment via PISP (domestic)
* @throws {ForbiddenError} if KYC not approved
* @throws {NotFoundError} if merchant not found or inactive
*/
initiateQRPayment(dto: QRPaymentDto, userId: string): Promise<Transaction>;
/**
* List user's transactions with pagination and optional filters
*/
listTransactions(filter: TransactionFilter, userId: string): Promise<PaginatedTransactions>;
/**
* Get a single transaction by ID (must belong to requesting user)
* @throws {NotFoundError} if not found or access denied
*/
getTransaction(txId: string, userId: string): Promise<Transaction>;
}
// Recipients
interface IRecipientsService {
createRecipient(dto: CreateRecipientDto, userId: string): Promise<Recipient>;
listRecipients(userId: string): Promise<Recipient[]>;
getRecipient(recipientId: string, userId: string): Promise<Recipient>;
deleteRecipient(recipientId: string, userId: string): Promise<void>;
}
// Types
export type RemittanceDto = {
recipientId: string;
amount: number; // NOK, 100-50000
bankAccountId: string;
currency?: string; // Default: 'NOK'
};
export type QRPaymentDto = {
merchantId: string;
amount: number; // NOK, positive
};
export type DisclosureDto = {
type: 'remittance';
amount: number;
recipientId: string;
};
export type Transaction = {
id: string; // tx_<hex16>
userId: string;
type: 'remittance' | 'qr_payment';
status: 'processing' | 'completed' | 'failed';
amount: number;
fee: number;
receiveAmount?: number;
receiveCurrency?: string;
exchangeRate?: number;
recipientId?: string;
merchantId?: string;
idempotencyKey?: string;
createdAt: string;
};
2.2 HTTP Endpoints
| Method | Path | Auth | Rate Limit | Description |
|---|---|---|---|---|
POST |
/v1/transactions/remittance |
JWT | 10/IP + 3/user per 60s | Initiate remittance |
POST |
/v1/transactions/qr-payment |
JWT | 10/IP + 3/user per 60s | Initiate QR payment |
POST |
/v1/transactions/disclosure |
JWT | None specific | Pre-payment fee disclosure |
GET |
/v1/transactions |
JWT | None specific | List user transactions |
GET |
/v1/transactions/:id |
JWT | None specific | Get transaction by ID |
GET |
/v1/recipients |
JWT | None specific | List saved recipients |
POST |
/v1/recipients |
JWT | None specific | Add recipient |
GET |
/v1/recipients/:id |
JWT | None specific | Get recipient |
DELETE |
/v1/recipients/:id |
JWT | None specific | Delete recipient |
GET |
/v1/rates/:currency |
JWT | 120 req/60s per IP | Get exchange rate |
2.3 Events Published
The Payments module does not use an async event bus (monolith-first architecture per ADR-005). Side effects (notifications, audit log) are written synchronously within the same DB transaction.
| Side Effect | Table Written | Trigger |
|---|---|---|
| Audit trail | audit_log |
Every transaction creation |
| User notification | notifications |
Transaction created, completed, failed |
| AML monitoring | aml_alerts |
Triggered by AML rules engine (high amount, high-risk corridor) |
3. Internal Structure
routes/
├── transactions.ts # HTTP request handling, rate limiting, routes
├── recipients.ts # Recipient CRUD routes
└── rates.ts # Exchange rate lookup
lib/
├── db.ts # Dual-driver DB abstraction (query, run, transaction)
├── middleware/
│ ├── auth.ts # JWT verification, session validation
│ └── rate-limit.ts # DB-backed IP + user rate limiting
└── openbanking/
└── neonomics.ts # PISP client (Phase 2 — currently mock)
Layer rules:
- Routes only call
db.tsfunctions (no raw SQL strings outside db.ts) - All SQL uses parameterized queries — no string interpolation
- Business logic (fee calculation, validation) lives in route handlers or dedicated helper functions — not in db.ts
- External API calls (Neonomics PISP) called after DB transaction commits to ensure idempotency key is stored before external call
4. Database Schema
Primary Table: transactions
| Column | Type | Nullable | Default | Constraints | Description |
|---|---|---|---|---|---|
id |
TEXT |
NO | — | PK, tx_<hex16> |
Transaction ID |
user_id |
TEXT |
NO | — | FK → users(id) |
Initiating user |
type |
TEXT |
NO | — | CHECK('remittance','qr_payment') | Payment type |
status |
TEXT |
NO | 'processing' |
CHECK('processing','completed','failed') | Payment status |
amount |
REAL |
NO | — | NOT NULL | Amount in NOK |
currency |
TEXT |
YES | 'NOK' |
— | Source currency |
fee |
REAL |
YES | 0 |
— | Fee in NOK |
recipient_id |
TEXT |
YES | NULL | FK → recipients(id) |
Remittance target |
merchant_id |
TEXT |
YES | NULL | FK → merchants(id) |
QR payment target |
send_amount |
REAL |
YES | NULL | — | Amount in source currency |
receive_amount |
REAL |
YES | NULL | — | Amount in target currency |
receive_currency |
TEXT |
YES | NULL | — | Target currency |
exchange_rate |
REAL |
YES | NULL | — | Rate at payment time |
description |
TEXT |
YES | NULL | — | User description |
idempotency_key |
TEXT |
YES | NULL | UNIQUE | Duplicate prevention |
created_at |
TEXT |
NO | datetime('now') |
— | Creation timestamp |
Indexes:
-- PostgreSQL
CREATE INDEX CONCURRENTLY idx_transactions_user_id ON transactions(user_id);
-- Rationale: Every list query filters by user_id
CREATE UNIQUE INDEX idx_tx_idempotency ON transactions(idempotency_key)
WHERE idempotency_key IS NOT NULL;
-- Rationale: Prevent duplicate payments on retry
CREATE INDEX CONCURRENTLY idx_transactions_recipient ON transactions(recipient_id)
WHERE recipient_id IS NOT NULL;
CREATE INDEX CONCURRENTLY idx_transactions_merchant ON transactions(merchant_id)
WHERE merchant_id IS NOT NULL;
Secondary Table: recipients
| Column | Type | Nullable | Default | Constraints | Description |
|---|---|---|---|---|---|
id |
TEXT |
NO | — | PK, rec_<hex16> |
Recipient ID |
user_id |
TEXT |
NO | — | FK → users(id) |
Owner |
name |
TEXT |
NO | — | NOT NULL | Recipient full name |
country |
TEXT |
NO | — | NOT NULL | Country code (RS, BA, PL, PK, TR, EU) |
currency |
TEXT |
NO | — | NOT NULL | Target currency |
bank_account |
TEXT |
NO | — | NOT NULL | IBAN or local account number |
bank_name |
TEXT |
YES | NULL | — | Bank name (optional) |
created_at |
TEXT |
NO | datetime('now') |
— | Created timestamp |
Secondary Table: exchange_rates
| Column | Type | Nullable | Default | Constraints | Description |
|---|---|---|---|---|---|
id |
INTEGER |
NO | auto | PK | Surrogate key |
from_currency |
TEXT |
NO | — | NOT NULL | Always 'NOK' at MVP |
to_currency |
TEXT |
NO | — | NOT NULL | RSD, BAM, PLN, PKR, TRY, EUR |
rate |
REAL |
NO | — | NOT NULL | 1 NOK = N target units |
updated_at |
TEXT |
YES | — | — | Last update timestamp |
5. API Endpoints (Detailed)
POST /v1/transactions/remittance
Request:
{
"recipientId": "rec_abc123def456gh78",
"amount": 2000,
"bankAccountId": "ba_abc123def456gh78",
"currency": "NOK"
}
Validation:
recipientId: required, string, must exist inrecipientsWHEREuser_id = currentUseramount: required, number, 100 ≤ amount ≤ 50000bankAccountId: required, string, must exist inbank_accountsWHEREuser_id = currentUser
Success 201:
{
"data": {
"id": "tx_rem_abc123def456gh78",
"type": "remittance",
"status": "processing",
"amount": 2000,
"fee": 10,
"receiveAmount": 20340,
"receiveCurrency": "RSD",
"exchangeRate": 10.17,
"estimatedDelivery": "2-4 business days",
"scaRedirect": "https://bank.no/sca/pay/...",
"createdAt": "2026-02-23T10:00:00.000Z"
}
}
POST /v1/transactions/disclosure
Request:
{
"type": "remittance",
"amount": 2000,
"recipientId": "rec_abc123def456gh78"
}
Success 200:
{
"data": {
"sendAmount": 2000,
"sendCurrency": "NOK",
"fee": 10,
"feePercentage": 0.5,
"exchangeRate": 10.17,
"receiveAmount": 20340,
"receiveCurrency": "RSD",
"totalCost": 2010,
"estimatedDelivery": "2-4 business days"
}
}
6. Business Logic Specifications
6.1 Business Rules
| Rule ID | Rule | Enforced In | Error |
|---|---|---|---|
| BR-001 | User must have kyc_status = 'approved' before initiating any payment |
Route handler | kyc_required (403) |
| BR-002 | Remittance amount must be 100–50,000 NOK | Route validation | amount_out_of_range (422) |
| BR-003 | Fee = 0.5% of send amount for remittance | Route calculation | N/A (business calculation) |
| BR-004 | QR payment fee = merchants.fee_rate (default 1%) |
Route calculation | N/A |
| BR-005 | Recipient must belong to the authenticated user | DB query WHERE user_id | recipient_not_found (404) |
| BR-006 | Cached bank balance must cover amount + fee | Route check | insufficient_balance (403) |
| BR-007 | Idempotency key uniqueness prevents duplicate payment on retry | UNIQUE DB constraint | Return existing transaction (409 or 200) |
| BR-008 | Pre-payment disclosure must be shown before every remittance (PSD2 Art. 45/46) | Frontend enforces; API provides via /disclosure |
N/A |
| BR-009 | Drop never initiates PISP without recording transaction in DB first | Atomic transaction: INSERT tx → PISP call | Rollback on PISP error |
6.2 Validation Rules
| Field | Type | Required | Validation | Error Message |
|---|---|---|---|---|
recipientId |
string |
Yes | Exists in recipients WHERE user_id |
"Mottaker ikke funnet" |
amount |
number |
Yes | 100 ≤ amount ≤ 50000, positive | "Beløp må være mellom 100 og 50 000 kr" |
bankAccountId |
string |
Yes | Exists in bank_accounts WHERE user_id |
"Bankkonto ikke funnet" |
merchantId |
string |
Yes (QR) | Exists in merchants WHERE status='active' |
"Butikk ikke funnet" |
6.3 Authorization Rules
| Operation | Required Role | Additional Conditions |
|---|---|---|
| Initiate remittance | user |
kyc_status = 'approved' |
| Initiate QR payment | user |
kyc_status = 'approved' |
| Get disclosure | user |
Must own recipient |
| List transactions | user |
Only own transactions (user_id = currentUser) |
| Add recipient | user |
Any authenticated user |
| Delete recipient | user |
Must own recipient |
| View exchange rates | user |
Any authenticated user |
7. Event Publishing / Consuming
The Payments module operates in a monolith (ADR-005) — no async message bus. All side effects are synchronous within DB transactions:
7.1 Side Effects on Payment Creation
| Side Effect | Table Written | Method | Notes |
|---|---|---|---|
| Audit trail | audit_log |
INSERT within transaction | action='transaction.create', resource_type='transaction' |
| User notification | notifications |
INSERT within transaction | title='Overføring startet', body='Din overføring på {amount} kr er under behandling' |
| AML monitoring | aml_alerts |
INSERT if rule triggered | Checked post-commit by AML rules engine |
7.2 Events Consumed
The module receives PISP payment status updates via:
- HTTP webhooks from Open Banking provider (Neonomics in production) →
POST /v1/webhooks/openbanking - Polling in mock mode — transaction status checked by frontend polling
GET /v1/transactions/:id
8. Dependencies
8.1 Upstream (what this module depends on)
| Dependency | Type | Coupling | Reason |
|---|---|---|---|
middleware/auth.ts |
Internal module | Hard (required for every route) | JWT validation, user identity |
middleware/rate-limit.ts |
Internal module | Hard (required for payment routes) | IP + user rate limiting |
lib/db.ts |
Shared library | Hard (required) | All data access |
| PostgreSQL / SQLite | Infrastructure | Hard | Primary storage |
| Open Banking PISP (Neonomics) | External API | Hard (prod) | Payment initiation — module unavailable if PISP down |
8.2 Downstream (what depends on this module)
| Consumer | What they use | Notes |
|---|---|---|
drop-web (Next.js) |
REST API /v1/transactions/*, /v1/recipients/* |
Via fetch with cookie auth |
drop-mobile (Expo) |
REST API /v1/transactions/*, /v1/recipients/* |
Via fetch with Bearer token |
compliance/aml module |
transactions table reads |
AML rules engine monitors transaction patterns |
9. Error Handling & Recovery
| Error Scenario | Handling | User Impact | Recovery |
|---|---|---|---|
| DB connection lost | Retry 1x, then 503 | Request fails — user retries | Auto-recover when DB reconnects |
| PISP API timeout | Return 502, transaction stays processing |
Payment may or may not have gone through | PISP idempotency key prevents double charge; poll status endpoint |
| Duplicate submission | Detect via UNIQUE constraint on idempotency_key | Return existing transaction | No user action needed |
| KYC not approved | Return 403 immediately | Clear error message with KYC link | User completes KYC verification |
| Exchange rate missing | Return 404 — payment blocked | Clear error: corridor not supported | Admin updates exchange_rates table |
| Balance insufficient | Return 403 | Clear error with balance shown | User reduces amount or top-up bank account |
10. Configuration & Feature Flags
Environment Variables
| Variable | Type | Default | Description |
|---|---|---|---|
NEXT_PUBLIC_SERVICE_MODE |
string |
mock |
mock = simulated PISP; production = real Neonomics calls |
OPEN_BANKING_API_URL |
string |
— | Neonomics base URL (production) |
OPEN_BANKING_CLIENT_ID |
string |
— | eIDAS client ID for Neonomics |
OPEN_BANKING_CLIENT_SECRET |
string |
— | eIDAS client secret (from Secrets Manager) |
Feature Flags (environment variables)
| Flag | Type | Default | Description |
|---|---|---|---|
FEATURE_QR_ENABLED |
boolean |
true |
Toggle QR payment feature |
FEATURE_WITHDRAW_ENABLED |
boolean |
false |
Toggle withdrawal feature (Phase 3) |
11. Monitoring & Health Checks
Health Check Endpoint
GET /v1/health (owned by health route, includes payment module indicators)
{
"status": "ok",
"version": "0.1.0",
"uptime": 3600,
"db": "connected",
"dbLatencyMs": 1,
"timestamp": "2026-02-23T10:00:00.000Z"
}
Key Metrics (via Sentry + CloudWatch)
| Metric | Type | Alert Threshold | Dashboard |
|---|---|---|---|
Remittance 201 rate |
Counter | Drop > 50% over 5m | Sentry Issues |
Remittance 502 (PISP down) |
Counter | Any occurrence | Sentry Alert |
| Transaction processing time | Histogram | p99 > 2000ms | CloudWatch |
kyc_required 403 rate |
Counter | > 20% of remittance attempts | Sentry |
insufficient_balance 403 rate |
Counter | > 30% of remittance attempts | Sentry |
12. Primary Flow — Sequence Diagram
sequenceDiagram
autonumber
participant C as Client (Web/Mobile)
participant RL as Rate Limiter
participant Auth as Auth Middleware
participant Route as Transactions Route
participant DB as PostgreSQL
participant PISP as Open Banking PISP (Neonomics)
C->>RL: POST /v1/transactions/remittance
RL->>RL: Check rate_limits (10/IP, 3/user per 60s)
RL->>Auth: Forward if within limits
Auth->>DB: Verify JWT + session + user
Auth-->>Route: {userId, role, kycStatus}
Route->>Route: Validate: kycStatus='approved', amount 100-50000
alt Validation fails
Route-->>C: 400/403/422
end
Route->>DB: SELECT recipient WHERE id=? AND user_id=?
Route->>DB: SELECT exchange_rate WHERE to_currency=?
Route->>DB: SELECT bank_account WHERE id=? AND user_id=?
Route->>Route: Calculate fee, totalCost, receiveAmount
alt balance < totalCost
Route-->>C: 403 insufficient_balance
end
Route->>DB: BEGIN TRANSACTION
Route->>DB: UPDATE bank_accounts SET balance = balance - totalCostInOere
Route->>DB: INSERT INTO transactions (status='processing', idempotency_key=?)
Route->>DB: INSERT INTO audit_log
Route->>DB: INSERT INTO notifications
Route->>DB: COMMIT
Route->>PISP: POST /v1/payments/sepa-credit-transfers (with idempotency_key)
PISP-->>Route: {paymentId, scaRedirect, transactionStatus: "RCVD"}
Route-->>C: 201 {transactionId, status: "processing", scaRedirect}
Approval
| Role | Name | Date | Signature |
|---|---|---|---|
| Author | Petter Graff | 2026-02-23 | |
| Module Owner | John (AI Director) | ||
| Tech Lead | John (AI Director) | ||
| Reviewer | Alem Bašić |
Integration Design Document
Integration Design Document
Project: Drop Integration: BankID OIDC + Open Banking (Neonomics AISP/PISP) + Sumsub KYC/AML Version: 1.0 Date: 2026-02-23 Author: Petter Graff, Senior Enterprise Architect Status: Approved Reviewers: Alem Bašić (CEO), John (AI Director)
Document History
| Version | Date | Author | Changes |
|---|---|---|---|
| 0.1 | 2026-02-23 | Petter Graff | Initial draft from real integration docs |
1. Integration Overview & Context
Integration Name: Drop External Integration Stack (BankID OIDC + Neonomics Open Banking + Sumsub KYC) Type: Synchronous (REST/HTTPS) + Asynchronous (Webhooks)
Business Purpose: Drop cannot function without these three integrations:
- BankID: Every user must authenticate with Norwegian Strong Customer Authentication — no other login method exists
- Open Banking (Neonomics/ASPSP): All balance reads (AISP) and payment initiations (PISP) require live bank API connectivity — Drop never holds funds
- Sumsub KYC: Norwegian AML law (hvitvaskingsloven) requires identity verification before any financial operation
Criticality:
- BankID: Critical — all logins blocked if down; RTO 1 hour
- Open Banking PISP: Critical — all payments blocked if down; RTO 2 hours; RPO 0 (no payment data lost — payments either completed at bank or not initiated)
- Open Banking AISP: High — balance display degrades to cached value; acceptable for 24 hours
- Sumsub: High — new user KYC blocked; existing approved users unaffected; RTO 4 hours
Parties:
| Party | System | Team | Contact |
|---|---|---|---|
| Consumer (Drop) | drop-api (Hono v4) | ALAI Backend | john@alai.no |
| Provider: BankID | auth.bankid.no OIDC | BankID Norge | developer.bankid.no |
| Provider: Open Banking | Neonomics REST API | Neonomics | neonomics.io |
| Provider: KYC | Sumsub REST API + Webhooks | Sumsub | sumsub.com |
2. Integration Topology Diagram
flowchart TB
subgraph UserSide["User Devices"]
Browser["Browser (Next.js)"]
App["Mobile (Expo)"]
end
subgraph DropPlatform["Drop Platform (AWS eu-north-1)"]
subgraph Edge["Cloudflare Edge"]
CF["WAF + CDN + DDoS"]
end
subgraph App_["drop-web (Next.js BFF)"]
WebBFF["Next.js API Routes\n/api/auth/bankid/*"]
end
subgraph API["drop-api (Hono v4)"]
AuthRoute["routes/auth.ts\nBankID OIDC callback"]
TxRoute["routes/transactions.ts\nPISP orchestration"]
KYCRoute["routes/user.ts\nKYC initiation + webhooks"]
WebhookRoute["POST /v1/webhooks/sumsub\nHMAC-verified"]
end
subgraph DB["PostgreSQL 16"]
Users["users + sessions"]
Txs["transactions"]
Kyc["screening_results\nkyc_status"]
end
end
subgraph BankIDProvider["BankID Norge (auth.bankid.no)"]
BIDAuth["OIDC /auth endpoint"]
BIDToken["OIDC /token endpoint"]
BIDJWKS["JWKS /certs endpoint"]
end
subgraph Neonomics["Neonomics Open Banking"]
NeoAISP["AISP: GET /v1/accounts/{id}/balances"]
NeoPISP["PISP: POST /v1/payments/sepa-credit-transfers"]
NeoCB["Circuit Breaker\n3 failures → 60s cooldown"]
end
subgraph SumsubProvider["Sumsub KYC"]
SumAPI["REST API\n/resources/applicants"]
SumWebhook["Webhooks (HMAC-signed)\nPOST → Drop /v1/webhooks/sumsub"]
SumSDK["WebSDK (web) +\nReact Native SDK (mobile)"]
end
Browser & App --> CF
CF --> WebBFF & API
WebBFF --> BIDAuth
BIDToken --> AuthRoute
BIDJWKS --> AuthRoute
AuthRoute --> Users
TxRoute --> NeoCB --> NeoPISP & NeoAISP
KYCRoute --> SumAPI
SumWebhook --> WebhookRoute
WebhookRoute --> Kyc
SumSDK --> Browser & App
3. Service Contracts
3.1 Integration: BankID OIDC (Authentication)
Protocol: OpenID Connect 1.0 Authorization Code Flow over HTTPS
Direction: Drop → BankID Norge
Idempotency: YES — state and nonce parameters per-request
Authentication
| Method | Details |
|---|---|
| Type | OAuth2 Authorization Code with Client Secret |
| Client ID Header | Sent in token exchange POST body: client_id=BANKID_CLIENT_ID |
| Client Secret | Sent in token exchange POST body: client_secret=BANKID_CLIENT_SECRET |
| Key rotation | BankID JWKS keys rotate periodically — jose library handles automatic JWKS refresh |
| Token endpoint | https://auth.bankid.no/auth/realms/prod/protocol/openid-connect/token |
Request Contract — Step 1: Initiate (Redirect)
Endpoint: GET https://auth.bankid.no/auth/realms/prod/protocol/openid-connect/auth
Query Parameters:
client_id=BANKID_CLIENT_ID
redirect_uri=https://getdrop.no/api/auth/bankid/callback
response_type=code
scope=openid+profile
state=CRYPTO_RANDOM_UUID
nonce=CRYPTO_RANDOM_UUID
Drop stores state in: httpOnly cookie bankid_state={state} (web) or in-memory (mobile)
Request Contract — Step 2: Token Exchange
Endpoint: POST https://auth.bankid.no/auth/realms/prod/protocol/openid-connect/token
Request Body:
grant_type=authorization_code
code=AUTH_CODE
redirect_uri=https://getdrop.no/api/auth/bankid/callback
client_id=BANKID_CLIENT_ID
client_secret=BANKID_CLIENT_SECRET
Successful Response 200:
{
"id_token": "eyJ...",
"access_token": "...",
"token_type": "Bearer",
"expires_in": 300
}
ID Token Claims Used by Drop
| Claim | Type | Usage |
|---|---|---|
pid |
string | Norwegian fødselsnummer (11 digits). Hashed (SHA-256) → users.national_id_hash |
name |
string | Full name. Split into users.first_name + users.last_name |
sub |
string | Fallback subject identifier if pid absent |
iss |
string | Validated: https://auth.bankid.no/auth/realms/prod |
aud |
string | Validated: matches BANKID_CLIENT_ID |
exp |
number | Token expiry — verified via jose |
nonce |
string | Anti-replay — matched against stored nonce |
Error Handling
| HTTP Status | Error Code | Drop Action |
|---|---|---|
| User cancels BankID | N/A (redirect with error=access_denied) |
Return bankid_cancelled 400 |
400 |
invalid_grant |
Return token_exchange_failed 502 |
401 |
unauthorized_client |
Alert ops — client ID invalid |
| JWKS verification fails | N/A | Return jwks_verification_failed 502 — alert ops |
pid age check fails (< 18) |
N/A | Return underage 403 — Norwegian legal requirement |
Retry Policy
Token exchange: No retry (state-dependent flow — retry means restarting from login)
JWKS fetch: jose handles with built-in caching (5-minute TTL)
On JWKS failure: Alert Sentry, return 502 to user
Rate Limiting
| Limit | Value | Window | Action when exceeded |
|---|---|---|---|
| BankID initiate | 10 req | 60s per IP | HTTP 429 from Drop rate limiter |
| BankID callback | 10 req | 60s per IP | HTTP 429 from Drop rate limiter |
3.2 Integration: Open Banking AISP (Balance Reads)
Protocol: Berlin Group NextGenPSD2 v1.3.12+ over HTTPS
Direction: Drop → Neonomics → ASPSP (user's bank)
Idempotency: YES — X-Request-ID UUID per request
Authentication
| Method | Details |
|---|---|
| Type | OAuth2 Client Credentials (Neonomics) + eIDAS QWAC certificate (ASPSP direct) |
| Header | Authorization: Bearer {neonomics_access_token} |
| Key rotation | OAuth2 token refresh; eIDAS cert rotation annually |
| Token endpoint | https://api.neonomics.io/auth/token |
Request Contract — AISP Balance Read
Endpoint: GET https://api.neonomics.io/v1/accounts/{accountId}/balances
Headers:
Authorization: Bearer {NEONOMICS_ACCESS_TOKEN}
X-Request-ID: {UUID}
X-Consent-ID: {consentId}
Content-Type: application/json
Successful Response 200:
{
"balances": [
{
"balanceType": "expected",
"balanceAmount": {
"currency": "NOK",
"amount": "45230.00"
},
"lastChangeDateTime": "2026-02-23T08:00:00.000Z"
}
]
}
PSD2 Constraint: Maximum 4 TPP-initiated balance reads per account per day (RTS Art. 36(6)). User-initiated reads are unlimited.
Drop caches in: bank_accounts.balance (in øre) + bank_accounts.balance_synced_at
Error Handling
| HTTP Status | Berlin Group Code | Drop Handling |
|---|---|---|
403 |
CONSENT_EXPIRED |
Delete consent, prompt user to re-link bank account |
429 |
ACCESS_EXCEEDED |
Back off, show cached balance with timestamp |
500+ |
Server error | Circuit breaker, show cached balance |
Circuit Breaker Configuration
Failure threshold: 3 failures in 60s window
Open duration: 60s
Half-open test: 1 request
Alert on: Circuit open for > 5 minutes → Sentry HIGH alert
Fallback: Return cached balance from bank_accounts.balance with staleness warning
3.3 Integration: Open Banking PISP (Payment Initiation)
Protocol: Berlin Group NextGenPSD2 v1.3.12+ over HTTPS
Direction: Drop → Neonomics → ASPSP (user's bank)
Idempotency: YES — X-Request-ID = idempotency_key from transactions table
Request Contract — PISP Remittance
Endpoint: POST https://api.neonomics.io/v1/payments/sepa-credit-transfers
Headers:
Authorization: Bearer {NEONOMICS_ACCESS_TOKEN}
Content-Type: application/json
X-Request-ID: {idempotency_key}
X-Consent-ID: {pisConsentId}
Request Body:
{
"debtorAccount": {
"iban": "NO9386011117947"
},
"instructedAmount": {
"currency": "NOK",
"amount": "2010.00"
},
"creditorName": "Marko Petrovic",
"creditorAccount": {
"bban": "265-1234567-89"
},
"remittanceInformationUnstructured": "Drop remittance tx_rem_abc123"
}
Successful Response 201:
{
"paymentId": "pay_xyz123",
"transactionStatus": "RCVD",
"_links": {
"scaRedirect": {
"href": "https://dnb.no/sca/pay/abc123"
}
}
}
SCA Redirect Flow: Drop returns scaRedirect URL to client → client redirects user to bank → user authenticates with BankID at bank → bank redirects back to Drop callback → Drop polls payment status.
Retry Policy
Max retries: 3 (retry only on 500, 502, 503 — NOT on 4xx)
Strategy: Exponential backoff with jitter
Delays: [1000ms, 2000ms, 4000ms]
Timeout per attempt: 30000ms
Idempotency: X-Request-ID = idempotency_key prevents double-payment on retry
Timeout Configuration
| Timeout Type | Value | Notes |
|---|---|---|
| Connection timeout | 5000ms | Fail fast if Neonomics unreachable |
| Read timeout | 30000ms | ASPSP processing can take up to 30s |
| SCA callback timeout | 300s (5 min) | Mark transaction failed if no callback received |
3.4 Integration: Sumsub KYC/AML
Protocol: REST HTTPS (outbound) + Webhooks HTTPS (inbound)
Direction: Drop → Sumsub (applicant creation), Sumsub → Drop (webhook status updates)
Idempotency: YES — webhook idempotency via screening_results table check
Authentication
| Method | Details |
|---|---|
| Outbound (Drop → Sumsub) | Authorization: Bearer {SUMSUB_APP_TOKEN} |
| Inbound Webhook Verification | HMAC-SHA256 of request body using SUMSUB_SECRET_KEY |
| Webhook Header | X-Payload-Digest: HMAC-SHA256(body, SUMSUB_SECRET_KEY) |
| Key rotation | Manual rotation in Sumsub dashboard; update SUMSUB_SECRET_KEY in Secrets Manager |
Request Contract — KYC Initiation
Endpoint: POST https://api.sumsub.com/resources/applicants
Headers:
Authorization: Bearer {SUMSUB_APP_TOKEN}
Content-Type: application/json
X-Request-ID: {UUID}
Request Body:
{
"externalUserId": "usr_abc123def456gh78",
"email": "usr_abc123@bankid.drop.local",
"levelName": "basic-kyc-level"
}
Successful Response 201:
{
"id": "5f9e1b2c3d4e5f6g7h8i9j0k",
"externalUserId": "usr_abc123def456gh78",
"review": {
"reviewStatus": "init"
}
}
Webhook Contract (Sumsub → Drop)
Endpoint: POST /v1/webhooks/sumsub
Verification: HMAC-SHA256(rawBody, SUMSUB_SECRET_KEY) must match X-Payload-Digest header
Webhook Payload:
{
"type": "applicantReviewed",
"applicantId": "5f9e1b2c3d4e5f6g7h8i9j0k",
"externalUserId": "usr_abc123def456gh78",
"reviewResult": {
"reviewAnswer": "GREEN",
"rejectLabels": []
},
"levelName": "basic-kyc-level",
"createdAt": "2026-02-23T10:00:00.000Z"
}
Drop Action per Event:
| Event Type | Drop kyc_status Change |
Side Effects |
|---|---|---|
applicantReviewed + GREEN |
pending → approved |
notification: "Du er nå verifisert", audit_log |
applicantReviewed + RED |
pending → rejected |
notification: "Verifisering avslått", audit_log, AML check |
applicantReviewed + RETRY |
stays pending |
notification: "Vennligst prøv igjen", audit_log |
applicantPending |
stays pending |
audit_log only |
applicantOnHold |
stays pending |
audit_log only |
Idempotency Strategy for Webhooks
For each webhook delivery:
1. Check screening_results WHERE user_id = ? AND screening_type = 'kyc' AND result = new_result
2. If found (duplicate): return 200 OK, skip processing
3. If not found: process, INSERT INTO screening_results, UPDATE users.kyc_status
4. Return 200 OK to prevent Sumsub retry
Sumsub Retry Policy (on non-2xx response):
- 8 retry attempts over 7h 42m (immediate → 30s → 2m → 10m → 30m → 1h → 2h → 4h)
Rate Limiting
| Limit | Value | Notes |
|---|---|---|
| Applicant creation | 100 req/min | Sumsub API limit |
| Webhook endpoint | No rate limit | Must always return 200 quickly |
4. Event-Driven Integrations
4.1 Sumsub Webhook Events
Published by: Sumsub
Consumed by: Drop POST /v1/webhooks/sumsub
Event: applicantReviewed
{
"type": "applicantReviewed",
"applicantId": "sumsub_internal_id",
"externalUserId": "usr_abc123def456gh78",
"inspectionId": "inspection_id",
"correlationId": "correlation_id",
"levelName": "basic-kyc-level",
"sandboxMode": false,
"reviewStatus": "completed",
"createdAt": "2026-02-23T10:00:00.000Z",
"reviewResult": {
"reviewAnswer": "GREEN",
"rejectLabels": [],
"reviewRejectType": null,
"moderationComment": null
}
}
4.2 Topics / Queues
Drop does not use a message broker at current scale. Webhook delivery is direct HTTP. Internal side effects use synchronous DB writes.
4.3 Ordering Guarantees
| Integration | Ordering | Notes |
|---|---|---|
| BankID OIDC callback | Per-session (stateful via state cookie) | State cookie ensures correct session |
| Sumsub webhooks | Best-effort | Idempotency key prevents duplicate processing |
| Open Banking payment callbacks | Per-payment (paymentId) | Poll status if callback missing |
4.4 Idempotency Strategy
BankID callback:
State cookie + nonce = per-session idempotency; JWT issuance is idempotent (same pid = same user)
Open Banking PISP:
X-Request-ID = transactions.idempotency_key
Unique DB index ensures duplicate INSERT is rejected → return existing transaction
Sumsub webhooks:
Check screening_results for existing result before processing
Duplicate: acknowledge (200 OK), skip processing
5. Data Consistency Patterns
5.1 Consistency Model
Model: Strong (within Drop DB) + Eventual (between Drop and external systems) Acceptable lag: PISP status lag: 5 minutes max; AISP balance lag: 6 hours (PSD2 constraint)
5.2 PISP Payment Saga
sequenceDiagram
autonumber
participant Drop as Drop API
participant DB as PostgreSQL
participant PISP as Neonomics PISP
participant ASPSP as User's Bank
participant User as User
Drop->>DB: INSERT transactions (status='processing', idempotency_key)
Drop->>DB: COMMIT
Drop->>PISP: POST /v1/payments/sepa-credit-transfers (X-Request-ID=idempotency_key)
PISP-->>Drop: {paymentId, scaRedirect}
Drop-->>User: 201 + scaRedirect URL
User->>ASPSP: Complete BankID SCA at bank
ASPSP-->>User: Redirect to Drop callback
User->>Drop: GET /api/payments/callback?paymentId=xxx
Drop->>PISP: GET /v1/payments/{paymentId}/status
PISP-->>Drop: {transactionStatus: "ACCP"}
Drop->>DB: UPDATE transactions SET status='completed'
Compensation strategies:
| Step | Compensation | Notes |
|---|---|---|
| DB INSERT failed | Rollback automatically — no PISP call made | Clean state |
| PISP initiation failed | Transaction stays processing; PISP idempotency key prevents double charge on retry |
Alert ops if persistent |
| SCA timeout (no callback in 5min) | UPDATE transactions SET status='failed'; restore cached balance | User notified to retry |
6. Integration Testing Strategy
6.1 Contract Testing
- BankID: Integration tests against BankID test environment (
BANKID_MOCK=false, BankID preprod) - Neonomics: Integration tests against Neonomics sandbox
- Sumsub: Mock SDK (
NEXT_PUBLIC_SERVICE_MODE=mock) for unit tests; staging Sumsub account for integration
6.2 Integration Test Environments
| Environment | Purpose | Trigger |
|---|---|---|
| Local (BANKID_MOCK=true) | Dev testing with BankID mock | Manual |
| Staging | Full integration with BankID test + Neonomics sandbox + Sumsub staging | Every PR merge |
| Production | Synthetic monitoring via GET /v1/health | Every 5 minutes |
6.3 Test Scenarios
Happy path:
- BankID login → JWT issued → dashboard loads
- Sumsub KYC initiated → webhook GREEN → kyc_status=approved
- AISP balance read → cached in bank_accounts (Phase 2)
- PISP remittance → SCA redirect → payment completed (Phase 2)
Error scenarios:
- BankID auth cancelled → 400
bankid_cancelled - User under 18 → 403
underage - Sumsub webhook with invalid HMAC → 401 (rejected)
- Duplicate Sumsub webhook → 200 (idempotent skip)
- Neonomics API returns 500 → circuit breaker opens → 502 (Phase 2)
- PISP SCA timeout → transaction marked failed (Phase 2)
7. Monitoring & Alerting
7.1 Key Metrics
| Metric | Type | Alert Condition | Severity |
|---|---|---|---|
bankid_login_errors_total |
Counter | > 10/min | HIGH |
bankid_underage_rejections_total |
Counter | > 5/min (unusual spike) | MEDIUM |
pisp_payment_failures_total |
Counter | > 5/min | CRITICAL |
pisp_circuit_open |
Gauge | == 1 | CRITICAL |
sumsub_webhook_failures_total |
Counter | > 0 | HIGH |
aisp_balance_staleness_hours |
Gauge | > 12h | MEDIUM |
kyc_approval_rate |
Gauge | < 70% over 1h | HIGH |
7.2 Distributed Tracing
- Trace ID propagation:
x-request-idheader generated per request (UUID), propagated to all external calls - Sampling rate: 100% in staging, 10% in production (Sentry performance monitoring)
- Tracing tool: Sentry Performance — transactions tracked per endpoint
7.3 Alert Routing
| Condition | Alert Channel | Escalation |
|---|---|---|
| BankID OIDC unreachable | Sentry HIGH alert | On-call engineer via Sentry |
| PISP circuit breaker open | Sentry CRITICAL alert | On-call + Alem notification |
| Sumsub webhook HMAC failure | Sentry HIGH alert | Check SUMSUB_SECRET_KEY rotation |
| AISP balance > 12h stale | Sentry MEDIUM alert | Investigate Neonomics API |
Approval
| Role | Name | Date | Signature |
|---|---|---|---|
| Author | Petter Graff | 2026-02-23 | |
| Consumer Team Lead | John (AI Director) | ||
| Provider Team Lead | External (BankID/Neonomics/Sumsub) | ||
| Platform/Infra | |||
| Approver (CEO) | Alem Bašić |
Data Flow Document
Data Flow Document
Project: {{PROJECT_NAME}} Version: {{VERSION}} Date: {{DATE}} Author: {{AUTHOR}} Status: Draft | In Review | Approved Reviewers: {{REVIEWERS}} Classification: Public | Internal | Confidential | Restricted
Document History
| Version | Date | Author | Changes |
|---|---|---|---|
| 0.1 | {{DATE}} | {{AUTHOR}} | Initial draft |
1. Data Flow Overview
System: {{SYSTEM_NAME}} Data Owner: {{DATA_OWNER_ROLE}} DPO Contact: {{DPO_EMAIL}}
Overview: {{DESCRIBE_WHAT_DATA_FLOWS_THROUGH_SYSTEM}}
High-Level Data Flow
flowchart LR
subgraph Inputs["Data Sources / Ingestion"]
U[Users — Web/App]
API_IN[External API]
IMPORT[Bulk Import]
WEBHOOK[Webhooks]
end
subgraph Processing["Processing Layer"]
VAL[Validation & Sanitization]
TRANS[Business Logic / Transformation]
ENRICH[Data Enrichment]
end
subgraph Storage["Storage Layer"]
DB[(Primary DB\nPostgreSQL)]
CACHE[(Cache\nRedis)]
BLOB[Object Storage\nS3/Blob]
SEARCH[Search Index\nElasticsearch]
DW[Data Warehouse\n{{DW_TECH}}]
end
subgraph Outputs["Data Consumers / Egress"]
API_OUT[REST API]
REPORTS[Reports / Analytics]
EXPORT[Data Export]
THIRD[Third-party Integrations]
EMAIL[Email / Notifications]
end
U & API_IN & IMPORT & WEBHOOK --> VAL
VAL --> TRANS
TRANS --> ENRICH
ENRICH --> DB & BLOB
DB --> CACHE & SEARCH
DB --> DW
DB --> API_OUT & REPORTS & EXPORT
DW --> REPORTS
ENRICH --> THIRD
DB --> EMAIL
2. Data Sources & Ingestion
| Source | Type | Protocol | Volume (est.) | Format | PII? | Validation |
|---|---|---|---|---|---|---|
| Web application users | Real-time | HTTPS POST | {{REQ_PER_DAY}} req/day | JSON | YES | Schema + business rules |
| Mobile app users | Real-time | HTTPS POST | {{REQ_PER_DAY}} req/day | JSON | YES | Schema + business rules |
{{EXTERNAL_SYSTEM}} API |
Real-time | Webhooks | {{EVENTS_PER_DAY}} events/day | JSON | {{YES/NO}} | HMAC signature + schema |
| CSV bulk import | Batch | File upload | {{IMPORTS_PER_DAY}} files/day | CSV | {{YES/NO}} | Column mapping + row validation |
{{THIRD_PARTY_API}} |
Polling | REST/HTTPS | {{CALLS_PER_HOUR}}/hour | JSON | {{YES/NO}} | Response schema validation |
Ingestion Error Handling
| Error Type | Action | Notification |
|---|---|---|
| Schema validation failure | Reject with error details | Return 400 to caller |
| Duplicate record | Upsert (prefer existing) or reject | Log, return 409 |
| PII fields contain unexpected data | Quarantine + alert | Slack #{{CHANNEL}} |
| Import file corrupted | Reject entire file | Email uploader + error report |
3. Data Transformations
3.1 Ingestion Transformations (before storage)
| Step | Input | Transformation | Output | Notes |
|---|---|---|---|---|
| 1. Sanitization | Raw user input | Strip HTML, trim whitespace | Clean strings | Prevents XSS |
| 2. Normalization | {{FIELD}} |
Lowercase + trim | Normalized {{FIELD}} |
e.g., email normalization |
| 3. Enrichment | User IP | GeoIP lookup | {country, region, city} |
Third-party API call |
| 4. PII masking | {{PII_FIELD}} |
Hash / mask for logs | Masked value | Never log raw PII |
| 5. Encryption | Sensitive fields | AES-256-GCM | Encrypted blob | At application layer |
3.2 ETL Pipeline (to Data Warehouse)
flowchart LR
subgraph Extract["Extract"]
PGLOG[PostgreSQL WAL / CDC]
SCHED[Scheduled SQL Export]
end
subgraph Transform["Transform ({{TRANSFORM_TOOL}})"]
CLEAN[Data Cleaning]
JOIN[Joins & Aggregations]
DEDUP[Deduplication]
ANON[PII Anonymization]
end
subgraph Load["Load"]
DW[({{DATA_WAREHOUSE}})]
end
PGLOG --> CLEAN
SCHED --> CLEAN
CLEAN --> JOIN
JOIN --> DEDUP
DEDUP --> ANON
ANON --> DW
Pipeline schedule: {{PIPELINE_SCHEDULE}} (e.g., hourly incremental, daily full) Latency: Source to DW within {{MAX_LATENCY}} Tool: {{ETL_TOOL}} (e.g., dbt, Airbyte, custom)
4. Data Storage
| Storage System | Technology | Purpose | Data Classification | Encryption at Rest |
|---|---|---|---|---|
| Primary Database | {{DB_TECH}} {{VERSION}} | Transactional data | Confidential | AES-256 ({{KEY_MGMT}}) |
| Cache | Redis {{VERSION}} | Hot data, sessions | Internal | AES-256 |
| Object Storage | {{S3_COMPATIBLE}} | Files, documents, media | {{CLASSIFICATION}} | SSE-S3 / SSE-KMS |
| Search Index | Elasticsearch | Full-text search | Internal | TLS + at-rest encryption |
| Data Warehouse | {{DW}} | Analytics, reporting | Anonymized | {{DW_ENCRYPTION}} |
| Backup Storage | {{BACKUP_TECH}} | Disaster recovery | Restricted | AES-256 |
| Audit Logs | {{LOG_STORAGE}} | Compliance / audit trail | Restricted | Immutable, encrypted |
5. Data Access Patterns
5.1 Read Patterns
| Consumer | Data Accessed | Frequency | Access Method | Caching |
|---|---|---|---|---|
| Web application | User profile, settings | Per request | REST API | Redis 5min TTL |
| Web application | {{ENTITY}} list | Per page load | REST API (paginated) | CDN + Redis |
| Reporting service | Aggregated metrics | Every 1h | DW query | Materialized views |
| Admin dashboard | Raw records | On demand | REST API (admin) | No cache |
| External partner | {{SUBSET_OF_DATA}} | {{FREQUENCY}} | REST API (scoped JWT) | {{CACHING}} |
5.2 Write Patterns
| Writer | Data Written | Frequency | Write Method | Consistency |
|---|---|---|---|---|
| User actions (web) | CRUD operations | Per user action | REST API | Strong (synchronous) |
| Background worker | Aggregates, computed fields | Every {{INTERVAL}} | Direct DB write | Eventual |
| Import process | Bulk records | {{FREQUENCY}} | Batch insert | Strong (per batch) |
| Event consumer | Denormalized cache | On event | Direct DB write | Eventual |
6. Data Retention & Archival
| Data Category | Retention Period | Legal Basis | Action at Expiry | Automated? |
|---|---|---|---|---|
| User account data | Duration of relationship + {{N}} years | Contract | Soft delete → anonymize | Automated (nightly job) |
| Transaction records | {{N}} years | Legal obligation ({{REGULATION}}) | Archive to cold storage | Automated |
| Audit logs | {{N}} years | Legitimate interest (security) | Delete | Automated |
| Session tokens | {{N}} hours/days | Technical necessity | Auto-expire via TTL | Yes (Redis TTL) |
| Marketing consent | Until withdrawn | Consent | Delete within {{N}} days of withdrawal | Manual + automated |
| Analytics data | {{N}} years (anonymized) | Legitimate interest | Delete | Automated |
| Backup files | {{N}} days | Business continuity | Overwrite (rolling) | Automated |
| Error logs | {{N}} days | Legitimate interest | Delete | Automated |
Retention schedule job: retention-policy.job.ts — runs daily at {{TIME}} UTC
Archival target: {{COLD_STORAGE_LOCATION}}
7. Data Quality Rules
7.1 Validation Rules
| Field | Rule | Error Action | Severity |
|---|---|---|---|
email |
Valid RFC 5322 format | Reject | CRITICAL |
phone |
E.164 format | Reject | HIGH |
{{DATE_FIELD}} |
Not in future | Reject | HIGH |
{{AMOUNT_FIELD}} |
>= 0 | Reject | CRITICAL |
{{FK_FIELD}} |
References existing record | Reject | CRITICAL |
{{TEXT_FIELD}} |
Max {{N}} characters | Reject | MEDIUM |
7.2 Data Quality Metrics
| Metric | Target | Current | Alert Threshold |
|---|---|---|---|
| Null rate on required fields | 0% | {{CURRENT}} | > 0.1% |
| Duplicate rate | < 0.01% | {{CURRENT}} | > 0.1% |
| Schema validation pass rate | > 99.9% | {{CURRENT}} | < 99% |
| ETL pipeline success rate | > 99.5% | {{CURRENT}} | < 98% |
8. PII Data Flow Mapping
8.1 PII Inventory
| PII Category | Fields | Storage Location | Encrypted? | Access Controls | Lawful Basis |
|---|---|---|---|---|---|
| Contact info | email, phone, address |
Primary DB, Email system | Yes | Role-based (user self + admin) | Contract |
| Identity | full_name, date_of_birth |
Primary DB | Yes (field-level) | Role-based | Contract |
| Financial | {{PAYMENT_FIELD}} |
{{PAYMENT_PROVIDER}} (tokenized) | Tokenized | PCI scope only | Contract |
| Behavioral | login_history, click_events |
Analytics DB | No (anonymized) | Admin only | Legitimate interest |
| Location | ip_address (→ geo) |
Logs (masked) | N/A | Admin only | Legitimate interest |
| Device | user_agent, device_id |
Analytics DB | No | Admin only | Legitimate interest |
8.2 PII Flow Diagram
flowchart TD
USER([Data Subject]) -->|Provides| INGESTION[Ingestion Layer]
INGESTION -->|Validates & encrypts| DB[(Primary DB\nPII encrypted at rest)]
DB -->|Pseudonymized| DW[(Data Warehouse\nNo direct PII)]
DB -->|Masked in logs| LOGS[Log Aggregator]
DB -->|Tokenized| PAYMENT[Payment Provider\nPCI scope]
DB -->|Explicit consent| EMAIL[Email Provider\nEmail + name only]
DB -->|Right to erasure| DELETION[Anonymization Service]
DELETION -->|Anonymized| DB
DB -->|Audit trail| AUDIT[Audit Log\nRestricted access]
style DB fill:#ffcccc
style DW fill:#ccffcc
style LOGS fill:#ffffcc
style AUDIT fill:#ffcccc
9. Cross-Border Data Transfer
| Transfer | From | To | Data Category | Mechanism | DPA/SCCs? |
|---|---|---|---|---|---|
| {{TRANSFER_1}} | EU ({{COUNTRY}}) | US ({{PROVIDER}}) | {{DATA_CATEGORY}} | Standard Contractual Clauses (SCCs) | Yes — signed {{DATE}} |
| {{TRANSFER_2}} | EU | {{COUNTRY}} | {{DATA_CATEGORY}} | Adequacy decision | N/A |
| {{TRANSFER_3}} | EU | {{COUNTRY}} | {{DATA_CATEGORY}} | Binding Corporate Rules | {{YES/NO}} |
Third-party processors with data access:
| Processor | Service | Data Accessed | DPA Signed | Location |
|---|---|---|---|---|
| {{PROCESSOR_1}} | {{SERVICE}} | {{DATA}} | Yes | {{LOCATION}} |
| {{PROCESSOR_2}} | {{SERVICE}} | {{DATA}} | Yes | {{LOCATION}} |
10. Data Lineage Tracking
Lineage tool: {{LINEAGE_TOOL}} (e.g., Apache Atlas, DataHub, custom) Coverage: Primary DB + DW
Lineage Events Captured
{
"eventType": "DATA_WRITE",
"timestamp": "ISO8601",
"actor": "system/user-id",
"action": "CREATE | UPDATE | DELETE | EXPORT | IMPORT",
"resource": {
"type": "{{ENTITY}}",
"id": "UUID"
},
"fields_modified": ["{{field1}}", "{{field2}}"],
"sourceSystem": "{{SOURCE}}",
"traceId": "UUID"
}
11. Backup & Recovery for Data
| Storage | Backup Method | Frequency | Retention | RTO | RPO | Test Frequency |
|---|---|---|---|---|---|---|
| Primary DB | Continuous WAL archiving + snapshots | Continuous / Daily | 30 days | 1h | 5min | Monthly |
| Object Storage | Cross-region replication | Continuous | 30 days | 4h | 1h | Quarterly |
| Data Warehouse | Snapshot | Daily | 14 days | 8h | 24h | Quarterly |
| Redis Cache | RDB snapshots | Every 15min | 24h | 15min | 15min | Monthly |
Last backup test: {{DATE}} — Result: {{PASS/FAIL}} Recovery runbook: {{LINK_TO_RUNBOOK}}
Approval
| Role | Name | Date | Signature |
|---|---|---|---|
| Author | |||
| Data Owner | |||
| DPO / Privacy | |||
| Security | |||
| Tech Lead |
API Specification
API Specification
Project: {{PROJECT_NAME}} API Name: {{API_NAME}} Version: {{API_VERSION}} Date: {{DATE}} Author: {{AUTHOR}} Status: Draft | In Review | Approved Reviewers: {{REVIEWERS}} Spec Format: OpenAPI 3.1
Document History
| Version | Date | Author | Changes |
|---|---|---|---|
| 0.1 | {{DATE}} | {{AUTHOR}} | Initial draft |
| {{VERSION}} | {{DATE}} | {{AUTHOR}} | {{CHANGE_SUMMARY}} |
1. API Overview
Purpose: {{API_PURPOSE}} Primary Consumers: {{CONSUMER_DESCRIPTION}} (e.g., internal frontend, partner systems, public developers) Design Philosophy: REST + JSON | Resource-oriented | Stateless | Idempotent where possible
Base URLs:
| Environment | Base URL |
|---|---|
| Production | https://api.{{DOMAIN}}/v{{MAJOR_VERSION}} |
| Staging | https://api.staging.{{DOMAIN}}/v{{MAJOR_VERSION}} |
| Development | http://localhost:{{PORT}}/api/v{{MAJOR_VERSION}} |
2. API Versioning Strategy
Strategy: URL path versioning — /api/v{MAJOR}
Versioning rules:
- MAJOR version (v1 → v2): Breaking changes — new base path, deprecation notice ≥ 6 months
- MINOR additions: Non-breaking — new optional fields, new endpoints — no version bump
- Patch: Bug fixes — no schema changes
Deprecation Policy:
- Deprecated endpoints marked with
DeprecationandSunsetheaders - Minimum {{DEPRECATION_PERIOD}} notice before removing deprecated endpoints
- Deprecation notices sent to: {{NOTIFICATION_CHANNEL}}
Sunset Header Example:
Deprecation: Sat, 01 Jan 2025 00:00:00 GMT
Sunset: Sat, 01 Jul 2025 00:00:00 GMT
Link: <https://api.{{DOMAIN}}/v2/{{resource}}>; rel="successor-version"
3. Authentication & Authorization
3.1 Authentication Methods
Primary: Bearer JWT (OAuth2 / OIDC)
Authorization: Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...
JWT Claims:
{
"sub": "user-uuid",
"email": "user@example.com",
"tenant_id": "tenant-uuid",
"roles": ["{{ROLE_1}}", "{{ROLE_2}}"],
"scopes": ["{{SCOPE_1}}:read", "{{SCOPE_1}}:write"],
"iat": 1700000000,
"exp": 1700003600
}
Token Lifetimes:
| Token Type | Lifetime | Storage |
|---|---|---|
| Access Token | {{ACCESS_TTL}} (e.g., 1h) | Memory only (not localStorage) |
| Refresh Token | {{REFRESH_TTL}} (e.g., 30d) | HttpOnly cookie |
| API Key | Non-expiring (rotatable) | Secure vault |
Refresh Flow:
POST /auth/refresh
Cookie: refresh_token={{REFRESH_TOKEN}}
→ 200: { "access_token": "...", "expires_in": 3600 }
→ 401: Refresh token expired — re-authenticate
3.2 API Keys (for server-to-server)
X-API-Key: ak_live_{{KEY_PREFIX_SHOWN_TO_USER}}
- API keys scoped to specific permissions
- Prefixed:
ak_live_(production),ak_test_(test) - Rotate via:
POST /api-keys/{id}/rotate
3.3 OAuth2 Scopes
| Scope | Description | Grantable to |
|---|---|---|
{{resource}}:read |
Read {{resource}} data | All auth users |
{{resource}}:write |
Create/update {{resource}} | {{ROLE_REQUIRED}} |
{{resource}}:delete |
Delete {{resource}} | {{ROLE_REQUIRED}} |
admin:* |
Full admin access | Admin users only |
4. Common Headers
Request Headers
| Header | Required | Description |
|---|---|---|
Authorization |
Yes (except public endpoints) | Bearer {JWT} or N/A |
Content-Type |
Yes (POST/PUT/PATCH) | application/json |
Accept |
No | application/json (default) |
X-Request-ID |
Recommended | UUID v4 — echoed in response for tracing |
X-Idempotency-Key |
Yes (POST mutations) | UUID v4 — prevents duplicate operations |
Accept-Language |
No | en, no, de — for localized error messages |
Response Headers
| Header | Description |
|---|---|
X-Request-ID |
Echo of request ID (or generated if not provided) |
X-RateLimit-Limit |
Rate limit ceiling |
X-RateLimit-Remaining |
Remaining requests in current window |
X-RateLimit-Reset |
Unix timestamp when rate limit resets |
X-Response-Time |
Server processing time in ms |
Cache-Control |
Caching directives |
ETag |
Entity tag for conditional requests |
5. Error Response Format (RFC 7807)
{
"type": "https://api.{{DOMAIN}}/errors/{{ERROR_CODE}}",
"title": "Human-readable error title",
"status": 400,
"detail": "Specific, actionable error description",
"instance": "/api/v1/{{resource}}/{{id}}",
"traceId": "{{TRACE_ID}}",
"errors": [
{
"field": "{{FIELD_NAME}}",
"code": "{{VALIDATION_CODE}}",
"message": "{{FIELD_SPECIFIC_MESSAGE}}"
}
]
}
Standard Error Codes
| HTTP Status | Error Type | When to Use |
|---|---|---|
400 |
validation-error |
Request body/params fail validation |
401 |
unauthorized |
Missing or invalid authentication |
403 |
forbidden |
Authenticated but lacks permission |
404 |
not-found |
Resource does not exist |
405 |
method-not-allowed |
HTTP method not supported |
409 |
conflict |
Duplicate or state conflict |
410 |
gone |
Resource permanently deleted |
422 |
business-rule-violation |
Business logic rejection |
429 |
rate-limit-exceeded |
Too many requests |
500 |
internal-error |
Unexpected server error |
502 |
bad-gateway |
Upstream service failure |
503 |
service-unavailable |
Planned downtime or overload |
6. Pagination Strategy
Strategy: Cursor-based (preferred) | Offset-based (legacy support)
Cursor-Based (default for all new endpoints)
Request:
GET /api/v1/{{resource}}?limit=20&after={{CURSOR}}
Response:
{
"data": [],
"pagination": {
"hasNextPage": true,
"hasPreviousPage": false,
"startCursor": "{{BASE64_CURSOR}}",
"endCursor": "{{BASE64_CURSOR}}",
"limit": 20
}
}
Offset-Based (legacy)
Request:
GET /api/v1/{{resource}}?page=1&limit=20
Response:
{
"data": [],
"pagination": {
"page": 1,
"limit": 20,
"total": 500,
"totalPages": 25
}
}
Limits: Minimum 1, Maximum 100 items per request.
7. Rate Limiting
| Tier | Limit | Window | Scope |
|---|---|---|---|
| Anonymous | {{N}} req | per minute | Per IP |
| Authenticated (free) | {{N}} req | per minute | Per API key |
| Authenticated (paid) | {{N}} req | per minute | Per API key |
| Admin | {{N}} req | per minute | Per user |
Rate limit exceeded response:
HTTP/1.1 429 Too Many Requests
X-RateLimit-Limit: {{LIMIT}}
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1700003600
Retry-After: 37
{
"type": "https://api.{{DOMAIN}}/errors/rate-limit-exceeded",
"title": "Rate Limit Exceeded",
"status": 429,
"detail": "You have exceeded {{N}} requests per minute. Retry after 37 seconds."
}
8. Endpoint Documentation
Resource: {{Resource Name}}
POST /{{resource}}
Summary: Create a new {{entity}}
Auth: Required | Scope: {{resource}}:write
Idempotency: Required — provide X-Idempotency-Key
Request:
POST /api/v1/{{resource}} HTTP/1.1
Authorization: Bearer {{TOKEN}}
Content-Type: application/json
X-Idempotency-Key: 550e8400-e29b-41d4-a716-446655440000
{
"{{field1}}": "string (required)",
"{{field2}}": 123,
"{{field3}}": "ENUM_VALUE_A | ENUM_VALUE_B"
}
Response 201 Created:
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"{{field1}}": "value",
"{{field2}}": 123,
"{{field3}}": "ENUM_VALUE_A",
"createdAt": "2024-01-01T00:00:00.000Z",
"updatedAt": "2024-01-01T00:00:00.000Z"
}
Error scenarios:
| Scenario | Status | Error Code |
|---|---|---|
Missing required {{field1}} |
400 | validation-error |
{{field1}} exceeds max length |
400 | validation-error |
Duplicate {{unique_field}} |
409 | conflict |
| Invalid enum value | 400 | validation-error |
| Business rule: {{RULE_DESCRIPTION}} | 422 | business-rule-violation |
GET /{{resource}}/:id
Summary: Retrieve a {{entity}} by ID
Auth: Required | Scope: {{resource}}:read
Cache: ETag + Last-Modified supported
Path Parameters:
| Parameter | Type | Description |
|---|---|---|
id |
UUID |
The {{entity}} unique identifier |
Response 200 OK:
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"{{field1}}": "value",
"createdAt": "2024-01-01T00:00:00.000Z"
}
Error scenarios:
| Scenario | Status | Error Code |
|---|---|---|
| Invalid UUID format | 400 | validation-error |
| {{entity}} not found | 404 | not-found |
| Access to other tenant's data | 403 | forbidden |
GET /{{resource}}
Summary: List {{entities}} with filtering and pagination
Auth: Required | Scope: {{resource}}:read
Query Parameters:
| Parameter | Type | Default | Description |
|---|---|---|---|
limit |
integer [1-100] |
20 |
Items per page |
after |
string |
— | Cursor for next page |
before |
string |
— | Cursor for previous page |
sort |
string |
createdAt:desc |
Sort: {field}:{asc|desc} |
{{FILTER_1}} |
string |
— | Filter by {{FILTER_1}} |
{{FILTER_2}} |
string (ISO8601) |
— | Filter by date range: after:DATE |
search |
string |
— | Full-text search |
Response 200 OK:
{
"data": [
{ "id": "...", "{{field1}}": "..." }
],
"pagination": {
"hasNextPage": true,
"endCursor": "{{CURSOR}}"
}
}
9. Webhook Documentation
9.1 Webhook Configuration
Register endpoint: POST /webhooks
Test endpoint: POST /webhooks/{id}/test
Signature verification: HMAC-SHA256
9.2 Signature Verification
// Verify webhook authenticity
const payload = request.rawBody;
const signature = request.headers['X-Webhook-Signature'];
const secret = process.env.WEBHOOK_SECRET;
const expected = 'sha256=' + crypto
.createHmac('sha256', secret)
.update(payload)
.digest('hex');
const isValid = crypto.timingSafeEqual(
Buffer.from(signature),
Buffer.from(expected)
);
9.3 Webhook Events
| Event | Description | Payload |
|---|---|---|
{{entity}}.created |
{{entity}} created | {id, ...} |
{{entity}}.updated |
{{entity}} updated | {id, changes: {...}} |
{{entity}}.deleted |
{{entity}} deleted | {id, deletedAt} |
9.4 Webhook Delivery
- Timeout: 30 seconds per delivery attempt
- Retries: 5 attempts with exponential backoff (1min, 5min, 30min, 2h, 12h)
- Success: Any 2xx response
- Dead delivery: Alert and suspend after 5 consecutive failures
10. OpenAPI 3.1 YAML Skeleton
openapi: '3.1.0'
info:
title: '{{API_NAME}}'
description: '{{API_DESCRIPTION}}'
version: '{{API_VERSION}}'
contact:
name: '{{TEAM_NAME}}'
email: '{{TEAM_EMAIL}}'
license:
name: 'Proprietary'
servers:
- url: 'https://api.{{DOMAIN}}/v{{MAJOR_VERSION}}'
description: 'Production'
- url: 'https://api.staging.{{DOMAIN}}/v{{MAJOR_VERSION}}'
description: 'Staging'
security:
- bearerAuth: []
tags:
- name: '{{Resource}}'
description: 'Operations on {{resource}}'
paths:
/{{resource}}:
post:
tags: ['{{Resource}}']
summary: 'Create {{entity}}'
operationId: 'create{{Entity}}'
security:
- bearerAuth: ['{{resource}}:write']
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/Create{{Entity}}Request'
responses:
'201':
description: 'Created'
content:
application/json:
schema:
$ref: '#/components/schemas/{{Entity}}'
'400':
$ref: '#/components/responses/ValidationError'
'401':
$ref: '#/components/responses/Unauthorized'
'409':
$ref: '#/components/responses/Conflict'
get:
tags: ['{{Resource}}']
summary: 'List {{entities}}'
operationId: 'list{{Entities}}'
parameters:
- $ref: '#/components/parameters/limit'
- $ref: '#/components/parameters/after'
responses:
'200':
description: 'OK'
content:
application/json:
schema:
$ref: '#/components/schemas/Paginated{{Entity}}Response'
components:
securitySchemes:
bearerAuth:
type: http
scheme: bearer
bearerFormat: JWT
parameters:
limit:
name: limit
in: query
schema:
type: integer
minimum: 1
maximum: 100
default: 20
after:
name: after
in: query
schema:
type: string
schemas:
{{Entity}}:
type: object
required: [id, {{field1}}, createdAt]
properties:
id:
type: string
format: uuid
{{field1}}:
type: string
createdAt:
type: string
format: date-time
Create{{Entity}}Request:
type: object
required: [{{field1}}]
properties:
{{field1}}:
type: string
minLength: 1
maxLength: 255
ProblemDetails:
type: object
properties:
type:
type: string
format: uri
title:
type: string
status:
type: integer
detail:
type: string
instance:
type: string
traceId:
type: string
Paginated{{Entity}}Response:
type: object
properties:
data:
type: array
items:
$ref: '#/components/schemas/{{Entity}}'
pagination:
type: object
properties:
hasNextPage:
type: boolean
endCursor:
type: string
responses:
ValidationError:
description: 'Validation Error'
content:
application/problem+json:
schema:
$ref: '#/components/schemas/ProblemDetails'
Unauthorized:
description: 'Unauthorized'
content:
application/problem+json:
schema:
$ref: '#/components/schemas/ProblemDetails'
Conflict:
description: 'Conflict'
content:
application/problem+json:
schema:
$ref: '#/components/schemas/ProblemDetails'
11. SDK Generation Notes
Generation tool: {{SDK_TOOL}} (e.g., openapi-generator, Speakeasy, Fern) Generated SDKs:
| Language | Package | Registry |
|---|---|---|
| TypeScript/JS | @{{ORG}}/{{sdk-name}} |
npm |
| Python | {{org}}-{{sdk-name}} |
PyPI |
| Go | github.com/{{org}}/{{sdk-name}} |
pkg.go.dev |
Generation command:
openapi-generator-cli generate \
-i ./api-specification.yaml \
-g typescript-fetch \
-o ./sdk/typescript \
--additional-properties=npmName=@{{org}}/{{sdk-name}}
12. API Changelog
| Version | Date | Change Type | Description |
|---|---|---|---|
{{API_VERSION}} |
{{DATE}} | Added | POST /{{resource}} endpoint |
{{API_VERSION}} |
{{DATE}} | Changed | {{FIELD}} is now optional (was required) |
{{API_VERSION}} |
{{DATE}} | Deprecated | GET /{{old-resource}} — use GET /{{new-resource}} |
{{API_VERSION}} |
{{DATE}} | Removed | DELETE /{{old-resource}} — deprecated since {{DATE}} |
Approval
| Role | Name | Date | Signature |
|---|---|---|---|
| Author | |||
| API Consumer Rep | |||
| Security Review | |||
| Tech Lead |
Database Schema Document
Database Schema Document
Project: {{PROJECT_NAME}} Database: {{DATABASE_NAME}} Version: {{VERSION}} Date: {{DATE}} Author: {{AUTHOR}} Status: Draft | In Review | Approved Reviewers: {{REVIEWERS}}
Related Standards
Cross-references to ALAI DATABASE/ standards. Apply these alongside this schema document.
| Document | Path | Purpose |
|---|---|---|
| alai-db-standards.md | DATABASE/ | Master naming conventions, type standards, column defaults |
| indexing-strategy.md | DATABASE/ | Index design guide — B-tree, GIN, partial, composite |
| migration-strategy.md | DATABASE/ | Migration tooling, zero-downtime patterns, rollback procedures |
| rls-policy-guide.md | DATABASE/ | Row Level Security for multi-tenant schemas |
| connection-pooling.md | DATABASE/ | PgBouncer and application pool configuration |
Document History
| Version | Date | Author | Changes |
|---|---|---|---|
| 0.1 | {{DATE}} | {{AUTHOR}} | Initial draft |
1. Database Technology & Version
| Property | Value |
|---|---|
| Technology | {{DB_TECHNOLOGY}} (e.g., PostgreSQL, MySQL, MongoDB, DynamoDB) |
| Version | {{DB_VERSION}} |
| Hosting | {{HOSTING}} (e.g., AWS RDS, Cloud SQL, self-hosted) |
| Instance type | {{INSTANCE_TYPE}} |
| Storage | {{STORAGE_SIZE}} — auto-scaling: {{YES/NO}} |
| Read replicas | {{N}} replicas in {{REGIONS}} |
| Connection pooling | {{POOLER}} (e.g., PgBouncer, RDS Proxy) — pool size: {{POOL_SIZE}} |
| Encoding | UTF-8 |
| Timezone | UTC (all timestamps in UTC) |
| Migration tool | {{MIGRATION_TOOL}} (e.g., Flyway, Liquibase, Prisma Migrate, custom) |
2. ER Diagram
erDiagram
TENANT {
uuid id PK
string name
string slug UK
string plan
timestamptz created_at
timestamptz deleted_at
}
USER {
uuid id PK
uuid tenant_id FK
string email UK
string full_name
string password_hash
string role
boolean is_verified
timestamptz last_login_at
timestamptz created_at
timestamptz deleted_at
}
{{ENTITY_1}} {
uuid id PK
uuid tenant_id FK
uuid created_by FK
string {{field_1}}
string {{field_2}}
string status
int version
timestamptz created_at
timestamptz updated_at
timestamptz deleted_at
}
{{ENTITY_2}} {
uuid id PK
uuid {{entity_1_id}} FK
string {{field_1}}
decimal {{amount_field}}
timestamptz created_at
}
AUDIT_LOG {
uuid id PK
uuid tenant_id FK
uuid actor_id FK
string entity_type
uuid entity_id
string action
jsonb old_values
jsonb new_values
string ip_address
timestamptz created_at
}
TENANT ||--o{ USER : "has"
TENANT ||--o{ {{ENTITY_1}} : "owns"
USER ||--o{ {{ENTITY_1}} : "creates"
{{ENTITY_1}} ||--o{ {{ENTITY_2}} : "contains"
USER ||--o{ AUDIT_LOG : "generates"
3. Schema Conventions
3.1 Naming Conventions
| Element | Convention | Example |
|---|---|---|
| Tables | snake_case, plural |
user_profiles, order_items |
| Columns | snake_case |
created_at, tenant_id |
| Primary keys | Always id (UUID) |
id UUID PRIMARY KEY |
| Foreign keys | {referenced_table_singular}_id |
user_id, tenant_id |
| Indexes | idx_{table}_{column(s)} |
idx_users_email |
| Unique indexes | uq_{table}_{column(s)} |
uq_users_tenant_email |
| Enum types | snake_case |
user_role, order_status |
| Junction tables | {table1}_{table2} (alphabetical) |
role_permissions |
| Sequences | Auto (via gen_random_uuid()) |
3.2 Standard Columns (all tables)
| Column | Type | Nullable | Default | Description |
|---|---|---|---|---|
id |
UUID |
NO | gen_random_uuid() |
Surrogate primary key |
created_at |
TIMESTAMPTZ |
NO | NOW() |
Immutable — set on insert |
updated_at |
TIMESTAMPTZ |
NO | NOW() |
Auto-updated via trigger |
deleted_at |
TIMESTAMPTZ |
YES | NULL |
Soft delete (NULL = active) |
version |
INTEGER |
NO | 1 |
Optimistic lock counter |
3.3 Data Type Standards
| Data | PostgreSQL Type | Notes |
|---|---|---|
| Primary keys | UUID |
gen_random_uuid() default |
| Short strings | VARCHAR(N) |
Specify max length |
| Long text | TEXT |
No length limit |
| Money / currency | NUMERIC(19, 4) |
Never FLOAT for money |
| Booleans | BOOLEAN |
NOT NULL with DEFAULT |
| Enums | custom ENUM type |
Define in migrations |
| JSON data | JSONB |
Prefer JSONB over JSON |
| IP addresses | INET |
Native IP type |
| URLs | TEXT |
Validated at app layer |
| Timestamps | TIMESTAMPTZ |
Always with timezone |
| Dates (no time) | DATE |
|
| Durations | INTERVAL |
4. Tables by Domain
4.1 Identity & Access Domain
Table: tenants
Purpose: Top-level multi-tenancy isolation unit. Every resource belongs to a tenant.
| Column | Type | Nullable | Default | Constraints | Description |
|---|---|---|---|---|---|
id |
UUID |
NO | gen_random_uuid() |
PK | Tenant identifier |
name |
VARCHAR(255) |
NO | NOT NULL | Display name | |
slug |
VARCHAR(100) |
NO | UNIQUE, NOT NULL | URL-safe identifier | |
plan |
tenant_plan |
NO | 'free' |
NOT NULL | Subscription plan |
settings |
JSONB |
NO | '{}'::jsonb |
Tenant configuration | |
created_at |
TIMESTAMPTZ |
NO | NOW() |
||
deleted_at |
TIMESTAMPTZ |
YES | NULL |
-- Enum
CREATE TYPE tenant_plan AS ENUM ('free', 'starter', 'pro', 'enterprise');
-- Table
CREATE TABLE tenants (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(255) NOT NULL,
slug VARCHAR(100) NOT NULL,
plan tenant_plan NOT NULL DEFAULT 'free',
settings JSONB NOT NULL DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
deleted_at TIMESTAMPTZ
);
-- Indexes
CREATE UNIQUE INDEX uq_tenants_slug ON tenants(slug) WHERE deleted_at IS NULL;
Table: users
Purpose: System users. Authentication identity.
| Column | Type | Nullable | Default | Constraints | Description |
|---|---|---|---|---|---|
id |
UUID |
NO | gen_random_uuid() |
PK | |
tenant_id |
UUID |
NO | FK → tenants(id) |
Tenant membership | |
email |
VARCHAR(320) |
NO | NOT NULL | Normalized lowercase | |
password_hash |
VARCHAR(255) |
YES | NULL |
bcrypt/Argon2 hash. NULL for SSO users | |
full_name |
VARCHAR(255) |
NO | NOT NULL | ||
role |
user_role |
NO | 'member' |
NOT NULL | RBAC role |
is_verified |
BOOLEAN |
NO | FALSE |
NOT NULL | Email verified |
last_login_at |
TIMESTAMPTZ |
YES | NULL |
||
mfa_enabled |
BOOLEAN |
NO | FALSE |
NOT NULL | |
mfa_secret |
TEXT |
YES | NULL |
Encrypted TOTP secret | |
created_at |
TIMESTAMPTZ |
NO | NOW() |
||
updated_at |
TIMESTAMPTZ |
NO | NOW() |
||
deleted_at |
TIMESTAMPTZ |
YES | NULL |
||
version |
INTEGER |
NO | 1 |
CREATE TYPE user_role AS ENUM ('owner', 'admin', 'member', 'viewer', 'api');
CREATE TABLE users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE RESTRICT,
email VARCHAR(320) NOT NULL,
password_hash VARCHAR(255),
full_name VARCHAR(255) NOT NULL,
role user_role NOT NULL DEFAULT 'member',
is_verified BOOLEAN NOT NULL DEFAULT FALSE,
last_login_at TIMESTAMPTZ,
mfa_enabled BOOLEAN NOT NULL DEFAULT FALSE,
mfa_secret TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
deleted_at TIMESTAMPTZ,
version INTEGER NOT NULL DEFAULT 1
);
CREATE UNIQUE INDEX uq_users_tenant_email ON users(tenant_id, lower(email))
WHERE deleted_at IS NULL;
CREATE INDEX idx_users_tenant_id ON users(tenant_id)
WHERE deleted_at IS NULL;
4.2 {{DOMAIN_NAME}} Domain
Table: {{table_name}}
Purpose: {{TABLE_PURPOSE}}
| Column | Type | Nullable | Default | Constraints | Description |
|---|---|---|---|---|---|
id |
UUID |
NO | gen_random_uuid() |
PK | |
tenant_id |
UUID |
NO | FK → tenants(id) |
||
created_by |
UUID |
NO | FK → users(id) |
||
{{field_1}} |
{{TYPE}} |
{{YES/NO}} | {{DEFAULT}} |
{{CONSTRAINTS}} | {{DESCRIPTION}} |
{{field_2}} |
{{TYPE}} |
{{YES/NO}} | {{DEFAULT}} |
{{CONSTRAINTS}} | {{DESCRIPTION}} |
status |
{{status_enum}} |
NO | '{{DEFAULT_STATUS}}' |
NOT NULL | |
created_at |
TIMESTAMPTZ |
NO | NOW() |
||
updated_at |
TIMESTAMPTZ |
NO | NOW() |
||
deleted_at |
TIMESTAMPTZ |
YES | NULL |
||
version |
INTEGER |
NO | 1 |
5. Enums & Lookup Tables
5.1 Enum Types
CREATE TYPE user_role AS ENUM ('owner', 'admin', 'member', 'viewer', 'api');
CREATE TYPE tenant_plan AS ENUM ('free', 'starter', 'pro', 'enterprise');
CREATE TYPE {{entity_1}}_status AS ENUM ('draft', 'active', 'suspended', 'archived');
CREATE TYPE {{entity_2}}_type AS ENUM ('{{VALUE_1}}', '{{VALUE_2}}', '{{VALUE_3}}');
5.2 Lookup Tables
Table: {{lookup_table}}
| Column | Type | Description |
|---|---|---|
code |
VARCHAR(50) PK |
Machine-readable identifier |
label |
VARCHAR(255) |
Human-readable label |
description |
TEXT |
Detailed description |
sort_order |
INTEGER |
Display ordering |
is_active |
BOOLEAN |
Whether selectable |
6. Views & Materialized Views
6.1 Views
View: active_{{entities}}
Purpose: Filter out soft-deleted records for common queries Refreshed: N/A (standard view)
CREATE VIEW active_{{entities}} AS
SELECT * FROM {{table_name}}
WHERE deleted_at IS NULL;
6.2 Materialized Views
Materialized View: {{entity}}_summary
Purpose: Pre-aggregated summary for dashboard queries Refreshed: Every {{INTERVAL}} via scheduled job Staleness acceptable: Up to {{MAX_STALENESS}}
CREATE MATERIALIZED VIEW {{entity}}_summary AS
SELECT
tenant_id,
DATE_TRUNC('day', created_at) AS date,
COUNT(*) AS total,
COUNT(*) FILTER (WHERE status = 'active') AS active_count
FROM {{table_name}}
WHERE deleted_at IS NULL
GROUP BY tenant_id, DATE_TRUNC('day', created_at);
CREATE UNIQUE INDEX ON {{entity}}_summary(tenant_id, date);
-- Refresh command (run by scheduler):
-- REFRESH MATERIALIZED VIEW CONCURRENTLY {{entity}}_summary;
7. Stored Procedures & Functions
updated_at_trigger()
Purpose: Auto-update updated_at column on any row update
CREATE OR REPLACE FUNCTION updated_at_trigger()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
-- Apply to every table with updated_at:
CREATE TRIGGER set_updated_at
BEFORE UPDATE ON {{table_name}}
FOR EACH ROW EXECUTE FUNCTION updated_at_trigger();
8. Migration Strategy & Tooling
Tool: {{MIGRATION_TOOL}} (e.g., Flyway, Liquibase, Prisma Migrate)
Convention: V{timestamp}__{description}.sql or {NNN}_{description}.{up|down}.sql
Location: db/migrations/
Executed by: CI/CD pipeline before application deployment
Zero-Downtime Migration Checklist
Before every DDL migration:
- Can this run on a live table without locking? (Use
CONCURRENTLYfor index creation) - Does this add a column with a default? (Avoid volatile defaults on large tables)
- Does this remove a column? (Ensure app code is already deployed without references first)
- Does this rename? (Use multi-step: add new, backfill, update app, remove old)
- What's the estimated lock time? (Test on staging data of production size)
Expansion-Contraction Pattern
Step 1 (Expand): Add new_column alongside old_column
Step 2 (App deploy): Write to both, read from old
Step 3 (Backfill): Copy data from old to new
Step 4 (App deploy): Read from new, write to both
Step 5 (App deploy): Write to new only
Step 6 (Contract): Drop old_column
9. Seed Data Requirements
9.1 Required Seed Data (production)
-- System tenant (for internal operations)
INSERT INTO tenants (id, name, slug, plan)
VALUES ('00000000-0000-0000-0000-000000000001', 'System', 'system', 'enterprise')
ON CONFLICT DO NOTHING;
-- Default lookup values
INSERT INTO {{lookup_table}} (code, label, sort_order) VALUES
('{{VALUE_1}}', '{{LABEL_1}}', 1),
('{{VALUE_2}}', '{{LABEL_2}}', 2)
ON CONFLICT (code) DO UPDATE SET label = EXCLUDED.label;
9.2 Development Seed Data
Script: db/seeds/development.sql
Volume: {{N}} tenants, {{N}} users per tenant, {{N}} sample records
Command: npm run db:seed or make seed-dev
10. Performance Considerations
10.1 Partitioning
| Table | Partition Strategy | Partition Key | Partition Size |
|---|---|---|---|
audit_logs |
Range (time) | created_at |
Monthly |
{{events_table}} |
Range (time) | created_at |
Weekly |
{{large_table}} |
List (tenant) | tenant_id |
Per tenant |
CREATE TABLE audit_logs (
id UUID NOT NULL,
tenant_id UUID NOT NULL,
created_at TIMESTAMPTZ NOT NULL
) PARTITION BY RANGE (created_at);
CREATE TABLE audit_logs_2024_01
PARTITION OF audit_logs
FOR VALUES FROM ('2024-01-01') TO ('2024-02-01');
10.2 Query Performance Standards
| Query Pattern | Target (p99) | Optimization |
|---|---|---|
| PK lookup | < 5ms | B-tree index on id |
| Tenant-scoped list | < 50ms | Composite index (tenant_id, created_at) |
| Full-text search | < 200ms | GIN index on search_vector |
| Aggregation (dashboard) | < 500ms | Materialized view |
| Cross-tenant report | < 30s | Data warehouse |
10.3 Connection Pooling
Application connections → PgBouncer (transaction mode) → PostgreSQL
Pool size: min={{MIN_POOL}}, max={{MAX_POOL}} per application instance
Max DB connections: {{MAX_DB_CONNECTIONS}} (= pool_size × instances + 10 reserve)
11. Backup & Recovery Procedures
| Backup Type | Method | Frequency | Retention | Location |
|---|---|---|---|---|
| Continuous WAL | pg_wal_archive | Continuous | {{N}} days | {{BACKUP_LOCATION}} |
| Base snapshot | pg_basebackup / cloud snapshot | Daily | {{N}} days | {{BACKUP_LOCATION}} |
| Logical dump | pg_dump (select tables) | Weekly | {{N}} weeks | {{COLD_STORAGE}} |
| Schema-only | pg_dump --schema-only | On every migration | Indefinite | Git repository |
RTO target: {{RTO}} | RPO target: {{RPO}}
Recovery test schedule: Monthly ({{DAY_OF_MONTH}}) Recovery runbook: {{LINK_TO_RUNBOOK}}
# Point-in-time recovery command
pg_restore \
--host={{HOST}} \
--port=5432 \
--username={{USER}} \
--dbname={{DB}} \
--target-time="{{TIMESTAMP}}" \
{{BACKUP_FILE}}
Approval
| Role | Name | Date | Signature |
|---|---|---|---|
| Author | |||
| DBA / Platform | |||
| Security Review | |||
| Tech Lead |
High-Level Design (HLD)
High-Level 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. Executive Summary
Purpose: {{ONE_LINE_DESCRIPTION_OF_SYSTEM}}
Business Context: {{WHY_THIS_SYSTEM_EXISTS}}
Key Outcomes:
- {{OUTCOME_1}}
- {{OUTCOME_2}}
- {{OUTCOME_3}}
Scope: This document covers {{IN_SCOPE}} and excludes {{OUT_OF_SCOPE}}.
2. System Context (C4 Level 1)
C4Context
title System Context — {{PROJECT_NAME}}
Person(user, "{{PRIMARY_USER_TYPE}}", "{{USER_DESCRIPTION}}")
Person(admin, "System Administrator", "Manages and configures the system")
System(system, "{{SYSTEM_NAME}}", "{{SYSTEM_SHORT_DESCRIPTION}}")
System_Ext(extSystem1, "{{EXTERNAL_SYSTEM_1}}", "{{EXT_SYSTEM_1_DESCRIPTION}}")
System_Ext(extSystem2, "{{EXTERNAL_SYSTEM_2}}", "{{EXT_SYSTEM_2_DESCRIPTION}}")
System_Ext(emailSystem, "Email Provider", "Sends transactional emails")
Rel(user, system, "Uses", "HTTPS")
Rel(admin, system, "Manages", "HTTPS")
Rel(system, extSystem1, "Calls", "REST/HTTPS")
Rel(system, extSystem2, "Publishes events to", "AMQP")
Rel(system, emailSystem, "Sends emails via", "SMTP/API")
3. Container Diagram (C4 Level 2)
C4Container
title Container Diagram — {{PROJECT_NAME}}
Person(user, "{{PRIMARY_USER_TYPE}}")
Container_Boundary(system, "{{SYSTEM_NAME}}") {
Container(webApp, "Web Application", "{{FRONTEND_TECH}}", "Single-page application served to users")
Container(api, "API Gateway / Backend", "{{BACKEND_TECH}}", "Handles business logic and orchestration")
Container(workerService, "Background Worker", "{{WORKER_TECH}}", "Processes async jobs and scheduled tasks")
ContainerDb(database, "Primary Database", "{{DB_TECH}}", "Stores persistent application data")
ContainerDb(cache, "Cache Layer", "Redis", "Session storage and hot data caching")
Container(messageQueue, "Message Queue", "{{QUEUE_TECH}}", "Async event bus between services")
}
System_Ext(extApi, "{{EXTERNAL_API}}", "Third-party integration")
Rel(user, webApp, "Visits", "HTTPS")
Rel(webApp, api, "Calls", "REST/HTTPS")
Rel(api, database, "Reads/Writes", "TCP")
Rel(api, cache, "Reads/Writes", "TCP")
Rel(api, messageQueue, "Publishes events", "AMQP")
Rel(workerService, messageQueue, "Consumes events", "AMQP")
Rel(workerService, database, "Reads/Writes", "TCP")
Rel(api, extApi, "Calls", "REST/HTTPS")
4. Component Overview
| Component | Responsibility | Technology | Owner Team |
|---|---|---|---|
| {{COMPONENT_1}} | {{RESPONSIBILITY_1}} | {{TECH_1}} | {{TEAM_1}} |
| {{COMPONENT_2}} | {{RESPONSIBILITY_2}} | {{TECH_2}} | {{TEAM_2}} |
| {{COMPONENT_3}} | {{RESPONSIBILITY_3}} | {{TECH_3}} | {{TEAM_3}} |
Component Descriptions
{{COMPONENT_1}}
Responsibility: {{DETAILED_RESPONSIBILITY}} Key Interfaces: {{INTERFACE_DESCRIPTION}} Rationale: {{WHY_SEPARATE}}
5. Technology Stack
| Layer | Technology | Version | Rationale |
|---|---|---|---|
| Frontend Framework | {{FE_FRAMEWORK}} | {{VERSION}} | {{RATIONALE}} |
| UI Component Library | {{UI_LIB}} | {{VERSION}} | {{RATIONALE}} |
| Backend Language | {{LANG}} | {{VERSION}} | {{RATIONALE}} |
| Backend Framework | {{BE_FRAMEWORK}} | {{VERSION}} | {{RATIONALE}} |
| Primary Database | {{DB}} | {{VERSION}} | {{RATIONALE}} |
| Cache | {{CACHE}} | {{VERSION}} | {{RATIONALE}} |
| Message Queue | {{QUEUE}} | {{VERSION}} | {{RATIONALE}} |
| Search Engine | {{SEARCH}} | {{VERSION}} | {{RATIONALE}} |
| Object Storage | {{STORAGE}} | {{VERSION}} | {{RATIONALE}} |
| Container Runtime | {{CONTAINER}} | {{VERSION}} | {{RATIONALE}} |
| Orchestration | {{ORCHESTRATION}} | {{VERSION}} | {{RATIONALE}} |
| API Gateway | {{GATEWAY}} | {{VERSION}} | {{RATIONALE}} |
| Auth Provider | {{AUTH}} | {{VERSION}} | {{RATIONALE}} |
| Observability | {{OBSERVABILITY}} | {{VERSION}} | {{RATIONALE}} |
| CI/CD | {{CICD}} | {{VERSION}} | {{RATIONALE}} |
6. Data Flow Overview
6.1 Primary Write Flow
flowchart LR
A([User]) -->|HTTPS POST| B[API Gateway]
B -->|Authenticate| C[Auth Service]
C -->|JWT validated| B
B -->|Route request| D[Business Service]
D -->|Validate input| D
D -->|Write| E[(Database)]
D -->|Publish event| F[Message Queue]
F -->|Consume| G[Worker Service]
G -->|Side effects| H[External APIs]
G -->|Notify| I[Email/Push]
D -->|Cache invalidate| J[(Cache)]
D -->|Return 201| B
B -->|Response| A
6.2 Primary Read Flow
flowchart LR
A([User]) -->|HTTPS GET| B[API Gateway]
B -->|Authenticate| C[Auth Service]
B -->|Route| D[Business Service]
D -->|Cache check| E[(Redis Cache)]
E -->|Cache hit| D
E -->|Cache miss| F[(Database)]
F -->|Read| D
D -->|Populate cache| E
D -->|Return 200| A
7. Integration Points
7.1 External Integrations
| System | Direction | Protocol | Auth | Data Exchanged | SLA/Criticality |
|---|---|---|---|---|---|
| {{EXT_SYSTEM_1}} | Outbound | REST/HTTPS | API Key | {{DATA}} | {{SLA}} / {{CRITICALITY}} |
| {{EXT_SYSTEM_2}} | Inbound | Webhooks | HMAC | {{DATA}} | {{SLA}} / {{CRITICALITY}} |
| {{EXT_SYSTEM_3}} | Bidirectional | gRPC | mTLS | {{DATA}} | {{SLA}} / {{CRITICALITY}} |
7.2 Internal Service Integrations
| Service | Integration Type | Protocol | Notes |
|---|---|---|---|
| {{INTERNAL_SERVICE_1}} | Synchronous | REST | {{NOTES}} |
| {{INTERNAL_SERVICE_2}} | Asynchronous | Events | {{NOTES}} |
8. Deployment Overview
flowchart TB
subgraph Internet
CDN[CDN / Edge Cache]
DNS[DNS]
end
subgraph Cloud["Cloud Provider — {{CLOUD_PROVIDER}}"]
subgraph LoadBalancer["Load Balancer Layer"]
LB[Application Load Balancer]
end
subgraph AppTier["Application Tier — {{REGION}}"]
direction LR
API1[API Pod 1]
API2[API Pod 2]
API3[API Pod N]
end
subgraph WorkerTier["Worker Tier"]
W1[Worker Pod 1]
W2[Worker Pod N]
end
subgraph DataTier["Data Tier"]
DB_PRIMARY[(DB Primary)]
DB_REPLICA[(DB Replica)]
REDIS[(Redis Cluster)]
MQ[Message Queue]
end
subgraph Observability["Observability Stack"]
LOGS[Log Aggregator]
METRICS[Metrics / Prometheus]
TRACES[Distributed Tracing]
end
end
DNS --> CDN
CDN --> LB
LB --> API1 & API2 & API3
API1 & API2 & API3 --> DB_PRIMARY
API1 & API2 & API3 --> REDIS
API1 & API2 & API3 --> MQ
DB_PRIMARY --> DB_REPLICA
MQ --> W1 & W2
API1 & API2 & API3 --> LOGS & METRICS & TRACES
Environments
| Environment | URL | Purpose | Scale |
|---|---|---|---|
| Development | http://localhost:{{PORT}} | Local dev | Single instance |
| Staging | https://staging.{{DOMAIN}} | Pre-prod testing | Minimal (1 replica) |
| Production | https://{{DOMAIN}} | Live traffic | Auto-scaled |
9. Cross-Cutting Concerns
9.1 Authentication & Authorization
- Strategy: {{AUTH_STRATEGY}} (e.g., JWT Bearer tokens / OAuth2 / Session-based)
- Identity Provider: {{IDP}} (e.g., Auth0, Keycloak, custom)
- Authorization Model: {{AUTHZ_MODEL}} (e.g., RBAC, ABAC)
- Token Lifetime: Access: {{ACCESS_TTL}} | Refresh: {{REFRESH_TTL}}
- MFA: {{MFA_REQUIRED}} — {{MFA_METHOD}}
9.2 Logging
- Framework: {{LOGGING_FRAMEWORK}}
- Format: JSON structured logs
- Levels: DEBUG (dev), INFO (staging/prod), WARN/ERROR (alerts)
- Correlation IDs: X-Request-ID header propagated across all services
- Retention: {{LOG_RETENTION_DAYS}} days in {{LOG_STORAGE}}
- PII Handling: PII fields masked/redacted before logging
9.3 Error Handling
- API Errors: RFC 7807 Problem Details format
- Retry Strategy: Exponential backoff with jitter (max {{MAX_RETRIES}} retries)
- Circuit Breaker: Enabled on external calls — threshold: {{CB_THRESHOLD}}% failure rate
- Dead Letter Queue: Failed messages → DLQ with {{DLQ_RETENTION}} retention
9.4 Caching
- Strategy: Cache-aside pattern
- Cache Invalidation: {{INVALIDATION_STRATEGY}}
- TTLs: Session: {{SESSION_TTL}} | API responses: {{API_CACHE_TTL}} | Reference data: {{REF_TTL}}
- Cache Penetration Protection: Bloom filter / null value caching
9.5 Rate Limiting
- Implementation: {{RATE_LIMIT_IMPLEMENTATION}} (e.g., Redis sliding window)
- Default Limits: {{REQUESTS_PER_MINUTE}} req/min per IP | {{AUTH_REQUESTS_PER_MINUTE}} req/min per authenticated user
- Response: HTTP 429 with Retry-After header
9.6 Secrets Management
- Tool: {{SECRETS_MANAGER}} (e.g., HashiCorp Vault, AWS Secrets Manager)
- Rotation: {{ROTATION_POLICY}}
- Principle: No secrets in code, environment files committed to VCS, or logs
10. Quality Attributes & Architectural Trade-offs
| Quality Attribute | Target | Approach | Trade-off |
|---|---|---|---|
| Availability | {{SLA_PERCENT}} uptime | Multi-AZ deployment, health checks, auto-restart | Higher infrastructure cost |
| Performance (p99 latency) | < {{P99_LATENCY}}ms | Caching, query optimization, CDN | Cache invalidation complexity |
| Scalability | {{CONCURRENT_USERS}} concurrent users | Horizontal scaling, stateless services | Distributed state challenges |
| Security | OWASP Top 10 compliant | WAF, input validation, RBAC | Added latency from security checks |
| Maintainability | {{DEPLOY_FREQUENCY}} deploys/week | CI/CD pipeline, test coverage > {{TEST_COVERAGE}}% | Initial investment in tooling |
| Data Consistency | {{CONSISTENCY_MODEL}} | {{CONSISTENCY_APPROACH}} | {{CONSISTENCY_TRADEOFF}} |
11. Key Architectural Decisions
| ADR | Decision | Status | Date |
|---|---|---|---|
| ADR-001 | {{DECISION_SUMMARY_1}} | Accepted | {{DATE}} |
| ADR-002 | {{DECISION_SUMMARY_2}} | Accepted | {{DATE}} |
| ADR-003 | {{DECISION_SUMMARY_3}} | Proposed | {{DATE}} |
12. Constraints & Assumptions
12.1 Constraints
| # | Constraint | Category | Impact |
|---|---|---|---|
| C1 | {{CONSTRAINT_1}} | Technical/Regulatory/Business | {{IMPACT}} |
| C2 | {{CONSTRAINT_2}} | Technical/Regulatory/Business | {{IMPACT}} |
| C3 | {{CONSTRAINT_3}} | Technical/Regulatory/Business | {{IMPACT}} |
12.2 Assumptions
| # | Assumption | Validation Method | Risk if Wrong |
|---|---|---|---|
| A1 | {{ASSUMPTION_1}} | {{HOW_TO_VALIDATE}} | {{RISK}} |
| A2 | {{ASSUMPTION_2}} | {{HOW_TO_VALIDATE}} | {{RISK}} |
13. Risks & Mitigations
| Risk | Likelihood | Impact | Score | Mitigation | Contingency |
|---|---|---|---|---|---|
| {{RISK_1}} | {{1-5}} | {{1-5}} | {{L×I}} | {{MITIGATION}} | {{CONTINGENCY}} |
| {{RISK_2}} | {{1-5}} | {{1-5}} | {{L×I}} | {{MITIGATION}} | {{CONTINGENCY}} |
| {{RISK_3}} | {{1-5}} | {{1-5}} | {{L×I}} | {{MITIGATION}} | {{CONTINGENCY}} |
| Single database bottleneck | 3 | 5 | 15 | Read replicas, connection pooling | Add read replicas, implement CQRS |
| Third-party API unavailability | 4 | 3 | 12 | Circuit breaker, cached fallback | Fallback to cached data, async retry |
| Data breach via injection | 2 | 5 | 10 | Input validation, parameterized queries, WAF | Incident response plan, GDPR notification |
Approval
| Role | Name | Date | Signature |
|---|---|---|---|
| Author | |||
| Technical Lead | |||
| Security Review | |||
| Architect | |||
| Approver (CTO/Lead) |
Module Design
Module Design Document
Project: {{PROJECT_NAME}} Module: {{MODULE_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. Module Overview & Responsibility
Module: {{MODULE_NAME}}
Layer: Domain | Application | Infrastructure | Presentation
Repository: {{REPO_OR_MONOREPO_PATH}}
Team Owner: {{TEAM_NAME}}
Single Responsibility Statement:
{{THE_MODULE_IS_RESPONSIBLE_FOR_ONE_THING}}
This module owns:
- {{OWNED_RESOURCE_1}} (data + business logic)
- {{OWNED_RESOURCE_2}}
This module does NOT own:
- {{NOT_OWNED_1}} — owned by
{{OTHER_MODULE}} - {{NOT_OWNED_2}} — owned by
{{OTHER_MODULE}}
Why this is a separate module: {{RATIONALE_FOR_SEPARATION}} — e.g., "Separate bounded context, different team ownership, different scaling requirements"
2. Interface Definition (Public API)
2.1 Exported Service Interface
// Public interface exported by this module
export interface I{{ModuleName}}Service {
/**
* {{METHOD_1_DESCRIPTION}}
* @throws {ValidationError} if dto is invalid
* @throws {ConflictError} if {{UNIQUE_FIELD}} already exists
*/
create(dto: Create{{Entity}}Dto, context: RequestContext): Promise<{{Entity}}>;
/**
* {{METHOD_2_DESCRIPTION}}
* @throws {NotFoundError} if not found
*/
findById(id: string, context: RequestContext): Promise<{{Entity}}>;
/**
* {{METHOD_3_DESCRIPTION}}
*/
findAll(filter: {{Filter}}Dto, context: RequestContext): Promise<PaginatedResult<{{Entity}}>>;
/**
* {{METHOD_4_DESCRIPTION}}
* @throws {NotFoundError} if not found
* @throws {ForbiddenError} if user lacks permission
*/
update(id: string, dto: Update{{Entity}}Dto, context: RequestContext): Promise<{{Entity}}>;
/**
* {{METHOD_5_DESCRIPTION}}
* @throws {NotFoundError} if not found
*/
delete(id: string, context: RequestContext): Promise<void>;
}
// DTOs exported for consumers
export type Create{{Entity}}Dto = { /* ... */ };
export type Update{{Entity}}Dto = { /* ... */ };
export type {{Filter}}Dto = { /* ... */ };
export type {{Entity}} = { /* ... */ };
2.2 HTTP Endpoints (if applicable)
| Method | Path | Auth | Description |
|---|---|---|---|
POST |
/api/v{{V}}/{{resource}} |
JWT | Create {{entity}} |
GET |
/api/v{{V}}/{{resource}} |
JWT | List {{entities}} |
GET |
/api/v{{V}}/{{resource}}/:id |
JWT | Get by ID |
PUT |
/api/v{{V}}/{{resource}}/:id |
JWT | Full update |
PATCH |
/api/v{{V}}/{{resource}}/:id |
JWT | Partial update |
DELETE |
/api/v{{V}}/{{resource}}/:id |
JWT | Soft delete |
2.3 Events Published
| Event | Topic/Queue | Schema | Triggered By |
|---|---|---|---|
{{entity}}.created |
{{TOPIC}} |
See §5 | POST endpoint |
{{entity}}.updated |
{{TOPIC}} |
See §5 | PUT/PATCH endpoint |
{{entity}}.deleted |
{{TOPIC}} |
See §5 | DELETE endpoint |
{{entity}}.{{CUSTOM_EVENT}} |
{{TOPIC}} |
See §5 | {{BUSINESS_TRIGGER}} |
3. Internal Structure
{{MODULE_NAME}}/
├── controllers/
│ └── {{entity}}.controller.ts # HTTP request handling, input parsing
├── services/
│ └── {{entity}}.service.ts # Business logic
├── repositories/
│ ├── {{entity}}.repository.ts # Data access interface
│ └── {{entity}}.repository.pg.ts # PostgreSQL implementation
├── domain/
│ ├── {{entity}}.entity.ts # Domain entity / value objects
│ ├── {{entity}}.events.ts # Domain events
│ └── {{entity}}.errors.ts # Domain-specific errors
├── dto/
│ ├── create-{{entity}}.dto.ts
│ ├── update-{{entity}}.dto.ts
│ └── {{entity}}-filter.dto.ts
├── mappers/
│ └── {{entity}}.mapper.ts # DB record ↔ domain entity ↔ DTO
├── __tests__/
│ ├── unit/
│ └── integration/
└── {{entity}}.module.ts # Module registration / DI wiring
Layer rules (enforced by linting):
- Controllers only call Services (never Repositories directly)
- Services only call Repositories and publish Events
- Domain entities have no framework dependencies
- Mappers live at service layer — not in controllers
4. Database Schema
Primary Table: {{table_name}}
| Column | Type | Nullable | Default | Constraints | Description |
|---|---|---|---|---|---|
id |
UUID |
NO | gen_random_uuid() |
PK | Surrogate key |
created_at |
TIMESTAMPTZ |
NO | NOW() |
Immutable creation time | |
updated_at |
TIMESTAMPTZ |
NO | NOW() |
Auto-updated on write | |
deleted_at |
TIMESTAMPTZ |
YES | NULL |
Soft delete marker | |
version |
INTEGER |
NO | 1 |
Optimistic lock version | |
{{FIELD_1}} |
{{TYPE}} |
{{YES/NO}} | {{DEFAULT}} |
{{CHECK/UNIQUE/FK}} | {{DESCRIPTION}} |
{{FIELD_2}} |
{{TYPE}} |
{{YES/NO}} | {{DEFAULT}} |
{{CHECK/UNIQUE/FK}} | {{DESCRIPTION}} |
{{FK_ID}} |
UUID |
NO | FK → {{other_table}}(id) |
{{RELATIONSHIP}} |
Indexes:
CREATE INDEX CONCURRENTLY idx_{{table_name}}_{{column}} ON {{table_name}}({{column}})
WHERE deleted_at IS NULL;
-- Rationale: {{WHY_THIS_INDEX}}
CREATE UNIQUE INDEX idx_{{table_name}}_{{unique_column}} ON {{table_name}}({{unique_column}})
WHERE deleted_at IS NULL;
-- Rationale: Enforce uniqueness for active records only
RLS (Row-Level Security):
-- TODO: Enable if multi-tenant
-- ALTER TABLE {{table_name}} ENABLE ROW LEVEL SECURITY;
-- CREATE POLICY {{table_name}}_tenant_isolation ON {{table_name}}
-- USING (tenant_id = current_setting('app.current_tenant_id')::uuid);
5. API Endpoints (Detailed)
POST /api/v{{V}}/{{resource}}
Request:
{
"{{field1}}": "string (required, max 255 chars)",
"{{field2}}": "number (required, > 0)",
"{{field3}}": "string (optional, enum: [A, B, C])"
}
Success 201:
{
"id": "uuid",
"{{field1}}": "value",
"{{field2}}": 0,
"createdAt": "ISO8601"
}
Event published: {{entity}}.created
{
"specversion": "1.0",
"type": "{{entity}}.created",
"source": "/{{resource}}",
"id": "{{EVENT_UUID}}",
"time": "ISO8601",
"data": {
"entityId": "uuid",
"tenantId": "uuid",
"createdBy": "uuid",
"{{field1}}": "value"
}
}
6. Business Logic Specifications
6.1 Business Rules
| Rule ID | Rule | Enforced In | Error |
|---|---|---|---|
| BR-001 | {{RULE_1_DESCRIPTION}} | Service | {{ERROR_CODE}} |
| BR-002 | {{RULE_2_DESCRIPTION}} | Domain Entity | {{ERROR_CODE}} |
| BR-003 | {{RULE_3_DESCRIPTION}} | Service + DB constraint | ConflictError |
6.2 Validation Rules
| Field | Type | Required | Validation | Error Message |
|---|---|---|---|---|
{{field1}} |
string |
Yes | Min 1, Max 255 chars | "{{field1}} must be 1-255 characters" |
{{field2}} |
number |
Yes | Positive integer | "{{field2}} must be a positive integer" |
{{field3}} |
enum |
No | One of: [A, B, C] | "{{field3}} must be A, B, or C" |
6.3 Authorization Rules
| Operation | Required Role | Additional Conditions |
|---|---|---|
| Create | {{ROLE}} |
— |
| Read own | Any authenticated user | userId === resource.createdBy |
| Read any | {{ADMIN_ROLE}} |
— |
| Update | {{ROLE}} |
userId === resource.createdBy OR isAdmin |
| Delete | {{ADMIN_ROLE}} |
Soft delete only |
| Hard delete | SUPER_ADMIN |
Requires 2FA confirmation |
7. Event Publishing / Consuming
7.1 Events Published
| Event | When | Payload Schema | Idempotency Key |
|---|---|---|---|
{{entity}}.created |
After successful create | {entityId, tenantId, ...} |
entityId |
{{entity}}.updated |
After successful update | {entityId, changes: {...}} |
entityId + version |
{{entity}}.deleted |
After soft delete | {entityId, deletedAt} |
entityId |
7.2 Events Consumed
| Event | Source Module | Handler | Processing Guarantee |
|---|---|---|---|
{{other_entity}}.deleted |
{{OTHER_MODULE}} |
Cascade soft-delete related records | At-least-once |
{{other_entity}}.updated |
{{OTHER_MODULE}} |
Update denormalized cache | At-least-once |
Idempotency strategy: All consumers check processed_events table before processing. Duplicate events are logged and skipped.
8. Dependencies
8.1 Upstream (what this module depends on)
| Dependency | Type | Coupling | Reason |
|---|---|---|---|
AuthModule |
Internal module | Loose (interface) | JWT validation, user context |
{{OTHER_MODULE}} |
Internal module | Loose (events) | {{REASON}} |
| PostgreSQL | Infrastructure | Required | Primary storage |
| Redis | Infrastructure | Optional | Caching (degrades gracefully) |
{{EXTERNAL_SDK}} |
External library | Hard | {{REASON}} |
8.2 Downstream (what depends on this module)
| Consumer | What they use | Notes |
|---|---|---|
{{OTHER_MODULE}} |
{{entity}}.created events |
Read-only consumer |
{{FRONTEND}} |
HTTP API | Via API gateway |
9. Error Handling & Recovery
| Error Scenario | Handling | User Impact | Recovery |
|---|---|---|---|
| DB connection lost | Retry 3x with backoff, then 503 | Request fails gracefully | Auto-recover when DB reconnects |
| External API timeout | Return cached data or 503 | Degraded feature | Async retry, alert on-call |
| Duplicate submission | Detect via unique constraint, return 409 | Clear error message | None needed |
| Invalid state transition | Return 422 with state machine error | Clear error message | User corrects input |
| Event publish failure | Log to retry queue, return 202 | Async delay | Background retry |
10. Configuration & Feature Flags
Environment Variables
| Variable | Type | Default | Description |
|---|---|---|---|
{{MODULE_NAME}}_CACHE_TTL |
number |
300 |
Cache TTL seconds |
{{MODULE_NAME}}_MAX_LIST_SIZE |
number |
100 |
Max items per page |
{{MODULE_NAME}}_RATE_LIMIT_RPM |
number |
60 |
Rate limit per minute |
Feature Flags
| Flag | Type | Default | Description |
|---|---|---|---|
{{MODULE_NAME}}_ENABLE_CACHE |
boolean |
true |
Toggle Redis caching |
{{MODULE_NAME}}_ENABLE_{{FEATURE}} |
boolean |
false |
Gradual rollout of {{FEATURE}} |
11. Monitoring & Health Checks
Health Check Endpoint
GET /health/{{module-name}}
{
"status": "healthy | degraded | unhealthy",
"checks": {
"database": "healthy",
"cache": "healthy | degraded",
"externalApi": "healthy | degraded | unhealthy"
},
"latency": {
"database_ms": 5,
"cache_ms": 1
}
}
Key Metrics
| Metric | Type | Alert Threshold | Dashboard |
|---|---|---|---|
{{module}}_requests_total |
Counter | — | {{DASHBOARD_LINK}} |
{{module}}_request_duration_ms |
Histogram | p99 > {{THRESHOLD}}ms | {{DASHBOARD_LINK}} |
{{module}}_errors_total |
Counter | Error rate > {{THRESHOLD}}% | {{DASHBOARD_LINK}} |
{{module}}_cache_hit_rate |
Gauge | < {{THRESHOLD}}% for 5min | {{DASHBOARD_LINK}} |
{{module}}_db_pool_exhausted |
Counter | Any occurrence | {{DASHBOARD_LINK}} |
12. Primary Flow — Sequence Diagram
sequenceDiagram
autonumber
participant C as Controller
participant S as Service
participant V as Validator
participant R as Repository
participant DB as PostgreSQL
participant EB as Event Bus
participant Cache as Redis
C->>V: validate(dto)
alt Invalid input
V-->>C: ValidationError
C-->>Client: 400 Bad Request
end
V-->>C: Validated DTO
C->>S: create(dto, context)
S->>S: checkBusinessRules(dto)
alt Business rule violation
S-->>C: BusinessRuleError
C-->>Client: 422 Unprocessable
end
S->>DB: BEGIN TRANSACTION
S->>R: create(entityData)
R->>DB: INSERT INTO {{table_name}}
DB-->>R: Inserted record
R-->>S: {{Entity}} domain object
S->>DB: COMMIT
S->>EB: publish("{{entity}}.created", event)
Note over EB: Async — does not block response
S->>Cache: INVALIDATE related keys
S-->>C: {{Entity}} DTO
C-->>Client: 201 Created
Approval
| Role | Name | Date | Signature |
|---|---|---|---|
| Author | |||
| Module Owner | |||
| Tech Lead | |||
| Reviewer |
Integration Design
Integration Design Document
Project: {{PROJECT_NAME}} Integration: {{INTEGRATION_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 Overview & Context
Integration Name: {{INTEGRATION_NAME}} Type: Synchronous (REST/gRPC) | Asynchronous (Events/Queue) | Bidirectional | File-based
Business Purpose: {{WHY_THIS_INTEGRATION_EXISTS}}
Criticality: Critical | High | Medium | Low
- Impact if down: {{BUSINESS_IMPACT_IF_UNAVAILABLE}}
- Acceptable downtime: {{RTO}} | Max data loss: {{RPO}}
Parties:
| Party | System | Team | Contact |
|---|---|---|---|
| Consumer (caller) | {{CONSUMER_SYSTEM}} | {{TEAM_A}} | {{CONTACT_A}} |
| Provider (server) | {{PROVIDER_SYSTEM}} | {{TEAM_B}} | {{CONTACT_B}} |
2. Integration Topology Diagram
flowchart LR
subgraph ConsumerSide["Consumer — {{CONSUMER_SYSTEM}}"]
C_SVC[{{ConsumerService}}]
C_CB[Circuit Breaker]
C_RETRY[Retry Handler]
end
subgraph Integration["Integration Layer"]
GW[API Gateway / Load Balancer]
Q[Message Queue\n{{QUEUE_NAME}}]
DLQ[Dead Letter Queue\n{{DLQ_NAME}}]
end
subgraph ProviderSide["Provider — {{PROVIDER_SYSTEM}}"]
P_SVC[{{ProviderService}}]
P_DB[(Provider DB)]
P_WORKER[Event Worker]
end
C_SVC --> C_CB
C_CB --> C_RETRY
C_RETRY -->|HTTPS REST| GW
GW --> P_SVC
P_SVC --> P_DB
P_WORKER -->|Publish| Q
Q -->|Consume| C_SVC
Q -->|Failed| DLQ
DLQ -->|Alert| AlertSystem[PagerDuty]
3. Service Contracts
3.1 Integration: {{INTEGRATION_NAME_1}}
Protocol: REST/HTTPS | gRPC | GraphQL | WebSocket | AMQP
Direction: {{CONSUMER}} → {{PROVIDER}}
Idempotency: YES — use Idempotency-Key header | NO
Authentication
| Method | Details |
|---|---|
| Type | Bearer JWT |
| Header | Authorization: Bearer {{TOKEN}} |
| Key rotation | Every {{ROTATION_PERIOD}} — coordinated via {{ROTATION_PROCESS}} |
| Token endpoint | {{AUTH_ENDPOINT}} (if OAuth2) |
Request Contract
Endpoint: {{HTTP_METHOD}} {{BASE_URL}}/{{PATH}}
Headers:
Authorization: Bearer {{JWT_OR_API_KEY}}
Content-Type: application/json
Accept: application/json
X-Request-ID: {{UUID}}
X-Idempotency-Key: {{IDEMPOTENCY_KEY}}
Request Body:
{
"{{field1}}": "{{type}} — {{description}}",
"{{field2}}": "{{type}} — {{description}}",
"metadata": {
"sourceSystem": "{{CONSUMER_SYSTEM_ID}}",
"timestamp": "ISO8601"
}
}
Successful Response 200 / 201:
{
"{{responseField1}}": "{{type}}",
"{{responseField2}}": "{{type}}",
"requestId": "echo of X-Request-ID"
}
Error Handling
| HTTP Status | Error Code | Consumer Action |
|---|---|---|
400 |
VALIDATION_ERROR |
Log error, do NOT retry — fix request |
401 |
UNAUTHORIZED |
Refresh token, retry once |
403 |
FORBIDDEN |
Alert engineering, do NOT retry |
404 |
NOT_FOUND |
Log, do NOT retry — check resource ID |
409 |
CONFLICT |
Log, skip (idempotent) |
422 |
BUSINESS_RULE |
Log error, do NOT retry — escalate |
429 |
RATE_LIMITED |
Backoff per Retry-After header |
500 |
INTERNAL_ERROR |
Retry with exponential backoff |
502/503 |
UNAVAILABLE |
Circuit breaker — fail fast |
Retry Policy
Max retries: {{MAX_RETRIES}} (retry only on 500, 502, 503, 429, network errors)
Strategy: Exponential backoff with jitter
Delays: [{{DELAY_1}}ms, {{DELAY_2}}ms, {{DELAY_3}}ms]
Timeout per attempt: {{TIMEOUT_MS}}ms
Circuit Breaker Configuration
Failure threshold: {{FAILURE_PERCENT}}% failures in {{WINDOW_SECONDS}}s window
Open duration: {{OPEN_DURATION_SECONDS}}s
Half-open test: 1 request
Alert on: Circuit open for > {{ALERT_THRESHOLD_SECONDS}}s
Rate Limiting
| Limit | Value | Window | Action when exceeded |
|---|---|---|---|
| Requests per minute | {{RPM}} | 60s sliding | HTTP 429, Retry-After |
| Burst limit | {{BURST}} | 1s | HTTP 429 immediately |
| Daily quota | {{DAILY}} | 24h | HTTP 429, contact support |
Timeout Configuration
| Timeout Type | Value | Notes |
|---|---|---|
| Connection timeout | {{CONN_TIMEOUT_MS}}ms | Time to establish connection |
| Read timeout | {{READ_TIMEOUT_MS}}ms | Time to receive first byte |
| Total request timeout | {{TOTAL_TIMEOUT_MS}}ms | End-to-end budget |
3.2 Integration: {{INTEGRATION_NAME_2}} (if applicable)
Protocol: gRPC Service definition:
service {{ServiceName}} {
rpc {{MethodName}} ({{RequestMessage}}) returns ({{ResponseMessage}});
rpc {{StreamMethodName}} ({{RequestMessage}}) returns (stream {{ResponseMessage}});
}
message {{RequestMessage}} {
string id = 1;
string tenant_id = 2;
{{FieldType}} {{field_name}} = 3;
}
message {{ResponseMessage}} {
string id = 1;
{{FieldType}} {{field_name}} = 2;
google.protobuf.Timestamp created_at = 3;
}
4. Event-Driven Integrations
4.1 Event Schemas (CloudEvents 1.0)
Event: {{entity}}.{{ACTION}}
Published by: {{PUBLISHER_SYSTEM}}
Consumed by: {{CONSUMER_SYSTEM_1}}, {{CONSUMER_SYSTEM_2}}
{
"specversion": "1.0",
"type": "{{REVERSE_DNS_EVENT_TYPE}}",
"source": "https://{{SYSTEM_DOMAIN}}/{{resource}}",
"id": "{{UUID}}",
"time": "2024-01-01T00:00:00Z",
"datacontenttype": "application/json",
"subject": "{{RESOURCE_ID}}",
"data": {
"entityId": "UUID of affected resource",
"tenantId": "UUID of tenant",
"actorId": "UUID of user who triggered event",
"{{DOMAIN_FIELD_1}}": "domain-specific data",
"{{DOMAIN_FIELD_2}}": "domain-specific data",
"previousState": null,
"newState": "{{STATE}}"
}
}
4.2 Topics / Queues
| Topic/Queue | Partitions | Retention | Consumers | Producer |
|---|---|---|---|---|
{{TOPIC_NAME_1}} |
{{N}} | {{RETENTION}} | {{CONSUMER_GROUPS}} | {{PRODUCER_SERVICE}} |
{{TOPIC_NAME_2}} |
{{N}} | {{RETENTION}} | {{CONSUMER_GROUPS}} | {{PRODUCER_SERVICE}} |
4.3 Ordering Guarantees
| Integration | Ordering | Scope | Notes |
|---|---|---|---|
| {{INTEGRATION_1}} | Strict order | Per tenantId |
Kafka partition by tenantId |
| {{INTEGRATION_2}} | Best-effort | Global | FIFO queue — no strict ordering |
| {{INTEGRATION_3}} | No ordering | N/A | Independent events |
4.4 Idempotency Strategy
For each consumed event:
1. Check processed_events table: SELECT 1 WHERE event_id = $1 AND consumer_group = $2
2. If found: log "Duplicate event skipped" and ACK (do not reprocess)
3. If not found: process event
4. On success: INSERT INTO processed_events (event_id, consumer_group, processed_at)
5. ACK message
Deduplication window: {{DEDUP_WINDOW}} (keep processed_events for this duration)
5. Data Consistency Patterns
5.1 Consistency Model
Model: Strong | Eventual | Causal Acceptable lag: {{MAX_LAG_SECONDS}}s
5.2 Saga Pattern (if used for distributed transactions)
sequenceDiagram
autonumber
participant O as Orchestrator
participant S1 as {{SERVICE_1}}
participant S2 as {{SERVICE_2}}
participant S3 as {{SERVICE_3}}
O->>S1: Execute Step 1
S1-->>O: Step 1 succeeded {result1}
O->>S2: Execute Step 2 (with result1)
S2-->>O: Step 2 succeeded {result2}
O->>S3: Execute Step 3 (with result2)
S3-->>O: Step 3 FAILED
Note over O: Compensating transactions (reverse order)
O->>S2: Compensate Step 2
S2-->>O: Compensated
O->>S1: Compensate Step 1
S1-->>O: Compensated
O-->>Client: Transaction rolled back
Compensation strategies:
| Step | Compensation | Notes |
|---|---|---|
| {{STEP_1}} | {{COMPENSATION_1}} | {{NOTES}} |
| {{STEP_2}} | {{COMPENSATION_2}} | {{NOTES}} |
6. Integration Testing Strategy
6.1 Contract Testing (Pact)
- Consumer-driven contracts: Consumer writes tests defining expected provider behavior
- Provider verification: Provider CI runs consumer contracts on every build
- Pact Broker:
{{PACT_BROKER_URL}}
6.2 Integration Test Environments
| Environment | Purpose | Trigger |
|---|---|---|
| Local | Dev testing with mocked provider | Manual |
| Staging | Full integration with staging provider | Every PR merge |
| Production | Synthetic monitoring | Every 5 minutes |
6.3 Test Scenarios
Happy path:
- {{SCENARIO_1}} — expected outcome
- {{SCENARIO_2}} — expected outcome
Error scenarios:
- Provider returns 500 — circuit breaker opens after threshold
- Provider times out — retry policy kicks in
- Auth token expired — token refresh flow works
- Rate limit exceeded — 429 handled, backoff applied
- Duplicate event consumed — idempotency key prevents double-processing
7. Monitoring & Alerting
7.1 Key Metrics
| Metric | Type | Alert Condition | Severity |
|---|---|---|---|
integration_{{name}}_requests_total |
Counter | — | — |
integration_{{name}}_error_rate |
Gauge | > {{THRESHOLD}}% for 5m | HIGH |
integration_{{name}}_latency_p99_ms |
Histogram | > {{THRESHOLD}}ms for 5m | MEDIUM |
integration_{{name}}_circuit_open |
Gauge | == 1 | CRITICAL |
integration_{{name}}_dlq_depth |
Gauge | > 0 | HIGH |
integration_{{name}}_consumer_lag |
Gauge | > {{LAG_THRESHOLD}} | HIGH |
7.2 Distributed Tracing
- Trace ID propagation:
X-Request-IDandtraceparentheaders forwarded - Sampling rate: {{SAMPLE_RATE}}% in production, 100% in staging
- Tracing tool: {{TRACING_TOOL}} — dashboard: {{DASHBOARD_URL}}
7.3 Alert Routing
| Condition | Alert Channel | Escalation |
|---|---|---|
| Circuit breaker open | PagerDuty {{TEAM_A}} + Slack #{{CHANNEL}} | On-call engineer |
| DLQ depth > 0 | Slack #{{CHANNEL}} | Investigate within 1h |
| Error rate > {{THRESHOLD}}% | PagerDuty | On-call engineer |
Approval
| Role | Name | Date | Signature |
|---|---|---|---|
| Author | |||
| Consumer Team Lead | |||
| Provider Team Lead | |||
| Platform/Infra | |||
| Approver |