# Architecture

Detailed architecture documentation — HLD, LLD, ADRs, Integration, Database

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

1. [System Context (C4 Level 1)](hld/system-context.md) -- Drop and all external actors (banks, BankID, regulators)
2. [Security Architecture](hld/security-architecture.md) -- Security controls and threat model
3. [ADR-003: PSD2 Pass-through Model](adr/ADR-003-psd2-pass-through.md) -- Why Drop never holds money

### Architect Track

For a complete understanding of system design and key decisions.

1. **High-Level Design (C4)**
   - [System Context (C4 Level 1)](hld/system-context.md) -- External actors and trust boundaries
   - [Container Diagram (C4 Level 2)](hld/container-diagram.md) -- Internal containers and data flow
   - [Component Overview (C4 Level 3)](hld/component-overview.md) -- Module-level breakdown
2. **Cross-Cutting Concerns**
   - [Security Architecture](hld/security-architecture.md) -- Auth, encryption, input validation
   - [Data Architecture](hld/data-architecture.md) -- Data flow, storage, and processing
   - [Deployment Architecture](hld/deployment-architecture.md) -- AWS App Runner, Cloudflare, CI/CD
3. **Architecture Decision Records**
   - [ADR Index](adr/README.md) -- All 12 ADRs with template
4. **Integration Specifications**
   - [Open Banking AISP/PISP](integration/open-banking-aisp-pisp.md) -- PSD2 banking integration
   - [BankID OIDC Integration](integration/bankid-oidc-integration.md) -- Norwegian eID authentication
   - [Sumsub KYC Integration](integration/sumsub-kyc-integration.md) -- Identity verification
   - [Payment Processing](integration/payment-processing.md) -- Remittance and QR payment flows
   - [Sentry Observability](integration/sentry-observability.md) -- Error tracking and monitoring

### Developer Track

For implementing features and understanding code flows.

1. **Low-Level Designs (User Flows)**
   - [Login Authentication](lld/flow-login-authentication.md) -- BankID login flow (frontend)
   - [Login Authentication Backend](lld/flow-login-authentication-backend.md) -- Auth backend implementation
   - [Registration & Onboarding](lld/flow-registration-onboarding.md) -- New user registration
   - [Bank Account Linking](lld/flow-bank-account-linking.md) -- AISP consent and account linking
   - [Remittance](lld/flow-remittance.md) -- International money transfer flow
   - [QR Payment](lld/flow-qr-payment.md) -- Merchant QR payment flow
   - [Transaction History](lld/flow-transaction-history.md) -- Transaction listing and filtering
   - [Merchant Onboarding](lld/flow-merchant-onboarding.md) -- Business registration flow
   - [Profile & Settings](lld/flow-profile-settings.md) -- User preferences management
   - [Notifications](lld/flow-notifications.md) -- Push notification flow
2. **Database**
   - [Database Design](database/database-design.md) -- Schema rationale and entity relationships
   - [Migration Strategy](database/migration-strategy.md) -- SQLite to PostgreSQL migration
   - [Data Lifecycle](database/data-lifecycle.md) -- Retention, archival, and deletion policies
   - [Audit Architecture](database/audit-architecture.md) -- Compliance audit trail design
   - [Indexing Strategy](database/indexing-strategy.md) -- Database performance optimization
3. **Key ADRs for Developers**
   - [ADR-004: JWT httpOnly Cookies](adr/ADR-004-jwt-httponly-cookies.md) -- Token storage security
   - [ADR-006: SQLite to PostgreSQL](adr/ADR-006-sqlite-to-postgresql.md) -- **SUPERSEDED** by ADR-014
   - [ADR-008: Hono API Framework](adr/ADR-008-hono-api-framework.md) -- Mobile API choice
   - [ADR-009: Feature Flag System](adr/ADR-009-feature-flag-system.md) -- Runtime feature toggles
   - [ADR-010: Dual Database Driver](adr/ADR-010-dual-database-driver.md) -- **SUPERSEDED** by ADR-014
   - [ADR-014: PostgreSQL-Only Architecture](adr/ADR-014-postgresql-only.md) -- PostgreSQL 16 + Drizzle ORM (all environments)

### Compliance Track

For regulatory compliance review and audit preparation.

1. [Security Architecture](hld/security-architecture.md) -- Authentication, authorization, data protection
2. [KYC/AML Flow](lld/flow-kyc-aml.md) -- Customer due diligence, transaction monitoring, STR filing
3. [Data Lifecycle](database/data-lifecycle.md) -- GDPR retention policies, right to erasure
4. [Audit Architecture](database/audit-architecture.md) -- Audit trail for regulatory reporting
5. [Registration & Onboarding](lld/flow-registration-onboarding.md) -- Consent collection, age verification
6. [ADR-003: PSD2 Pass-through](adr/ADR-003-psd2-pass-through.md) -- Regulatory model decision
7. [ADR-007: BankID OIDC Auth](adr/ADR-007-bankid-oidc-auth.md) -- SCA compliance

---

## Document Index

### High-Level Design (HLD)

| Document | Description | Key Diagrams |
|----------|-------------|-------------|
| [System Context (C4 L1)](hld/system-context.md) | Drop and all external actors, trust boundaries, compliance zones | C4 context diagram, trust boundary diagram |
| [Container Diagram (C4 L2)](hld/container-diagram.md) | Internal containers: Next.js BFF, Hono API, databases, edge | C4 container diagram, data flow diagram |
| [Component Overview (C4 L3)](hld/component-overview.md) | Module-level breakdown: auth, transactions, merchants, compliance | Component diagrams per module |
| [Security Architecture](hld/security-architecture.md) | Auth, encryption, input validation, rate limiting, headers | Security layer diagram, threat model |
| [Data Architecture](hld/data-architecture.md) | Data flow, storage tiers, processing pipeline | Data flow diagram, storage topology |
| [Deployment Architecture](hld/deployment-architecture.md) | AWS App Runner, Cloudflare, CI/CD pipeline, environments | Deployment topology, CI/CD pipeline |

### Architecture Decision Records (ADR)

| ADR | Title | Category | Status |
|-----|-------|----------|--------|
| [ADR-001](adr/ADR-001-consolidate-backends.md) | Consolidate to Single Backend | Architecture | Accepted |
| [ADR-002](adr/ADR-002-separate-fontelepay.md) | Separate FontelePay from Drop | Architecture | Accepted |
| [ADR-003](adr/ADR-003-psd2-pass-through.md) | PSD2 Pass-through Model (No Wallet) | Architecture | Accepted |
| [ADR-004](adr/ADR-004-jwt-httponly-cookies.md) | JWT in httpOnly Cookies | Security | Accepted |
| [ADR-005](adr/ADR-005-monolith-first.md) | Monolith-First Architecture | Architecture | Accepted |
| [ADR-006](adr/ADR-006-sqlite-to-postgresql.md) | SQLite Dev, PostgreSQL Prod | Database | Superseded by ADR-014 |
| [ADR-007](adr/ADR-007-bankid-oidc-auth.md) | BankID as Sole Auth Provider | Security | Accepted |
| [ADR-008](adr/ADR-008-hono-api-framework.md) | Hono v4 for Mobile API | Backend | Accepted |
| [ADR-009](adr/ADR-009-feature-flag-system.md) | Custom Feature Flag System | Backend | Accepted |
| [ADR-010](adr/ADR-010-dual-database-driver.md) | Dual Database Driver Abstraction | Database | Superseded by ADR-014 |
| [ADR-011](adr/ADR-011-expo-mobile-framework.md) | Expo SDK 54 for Mobile | Mobile | Accepted |
| [ADR-012](adr/ADR-012-aws-app-runner-deploy.md) | AWS App Runner Deployment | Infrastructure | Accepted |
| [ADR-014](adr/ADR-014-postgresql-only.md) | PostgreSQL-Only Architecture (Drizzle ORM) | Database | Accepted |

### Integration Specifications

| Document | Description | External System |
|----------|-------------|-----------------|
| [Open Banking AISP/PISP](integration/open-banking-aisp-pisp.md) | PSD2 account access and payment initiation | Nordic banks (DNB, SpareBank1, Nordea) |
| [BankID OIDC Integration](integration/bankid-oidc-integration.md) | Norwegian eID authentication and SCA | BankID Norge |
| [Sumsub KYC Integration](integration/sumsub-kyc-integration.md) | Document verification and screening | Sumsub |
| [Payment Processing](integration/payment-processing.md) | Remittance and QR payment orchestration | Banks, SEPA, SWIFT |
| [Sentry Observability](integration/sentry-observability.md) | Error tracking and performance monitoring | Sentry |

### Low-Level Design (LLD) -- User Flows

| Document | Description | Key Endpoints |
|----------|-------------|---------------|
| [Login Authentication](lld/flow-login-authentication.md) | BankID OIDC login (frontend) | `/api/auth/bankid`, `/api/auth/bankid/callback` |
| [Login Auth Backend](lld/flow-login-authentication-backend.md) | Auth backend: JWT, sessions, revocation | `lib/auth.ts`, `middleware/auth.ts`, `middleware/rate-limit.ts` |
| [Registration & Onboarding](lld/flow-registration-onboarding.md) | New user registration, consent, bank linking | `/api/auth/bankid`, `/api/consents` |
| [Bank Account Linking](lld/flow-bank-account-linking.md) | AISP consent and account sync | Open Banking AISP APIs |
| [Remittance](lld/flow-remittance.md) | International money transfer | `POST /api/transactions/remittance` |
| [QR Payment](lld/flow-qr-payment.md) | Merchant QR scan and pay | `POST /api/transactions/qr-payment` |
| [Transaction History](lld/flow-transaction-history.md) | Transaction listing with filters | `GET /api/transactions` |
| [KYC/AML](lld/flow-kyc-aml.md) | KYC verification and AML monitoring | Sumsub webhooks, `aml_alerts`, `str_reports` |
| [Merchant Onboarding](lld/flow-merchant-onboarding.md) | Business registration and QR setup | `POST /api/merchants/register` |
| [Profile & Settings](lld/flow-profile-settings.md) | User preferences and account management | `GET/PATCH /api/settings` |
| [Notifications](lld/flow-notifications.md) | Push notification delivery | `GET/PATCH /api/notifications` |

### Database Architecture

| Document | Description |
|----------|-------------|
| [Database Design](database/database-design.md) | Schema rationale, entity relationships, 19-table overview |
| [Migration Strategy](database/migration-strategy.md) | Historical SQLite to PostgreSQL migration record (completed; see ADR-014) |
| [Data Lifecycle](database/data-lifecycle.md) | GDPR retention, AML record keeping, deletion policies |
| [Audit Architecture](database/audit-architecture.md) | `audit_log` table design and compliance trail |
| [Indexing Strategy](database/indexing-strategy.md) | Performance indexes for queries, compliance, and reporting |

---

## Related Documentation

| Document | Location | Description |
|----------|----------|-------------|
| [Main Architecture Document](../../project/architecture/architecture-document.md) | `project/architecture/` | Original architecture overview (v1.1) |
| [API Reference](../backend/API-REFERENCE.md) | `docs/backend/` | All 24+ API endpoints |
| [Database Schema](../backend/DATABASE-SCHEMA.md) | `docs/backend/` | Full table definitions |
| [Authentication System](../backend/AUTHENTICATION.md) | `docs/backend/` | BankID OIDC implementation |
| [Middleware](../backend/MIDDLEWARE.md) | `docs/backend/` | Auth, rate limiting, validation |
| [Feature Flags](../backend/FEATURE-FLAGS.md) | `docs/backend/` | Runtime feature toggles |
| [Security Architecture](../security/SECURITY-ARCHITECTURE.md) | `docs/security/` | Security controls reference |
| [Compliance Status](../security/COMPLIANCE.md) | `docs/security/` | Regulatory compliance tracking |
| [Roadmap](../../ROADMAP.md) | Root | Product roadmap and phases |

---

## Architecture Principles

1. **Pass-through model:** Drop never holds customer funds. All payments initiated via PISP from user's bank.
2. **Security-first:** httpOnly cookies, parameterized SQL, BankID SCA, rate limiting on all public endpoints.
3. **Compliance by design:** Audit logging, consent tracking, AML monitoring built into the data model.
4. **Monolith-first:** Simple deployment, extract services only when scaling demands it.
5. **PostgreSQL-only:** PostgreSQL 16 in all environments (dev, CI, staging, production) via Drizzle ORM. See ADR-014.
6. **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

```mermaid
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

```mermaid
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)](../hld/container-diagram.md) -- Internal container breakdown
- [Security Architecture](../hld/security-architecture.md) -- Detailed security controls
- [BankID OIDC Integration](../integration/bankid-oidc-integration.md) -- Authentication integration spec
- [Open Banking AISP/PISP](../integration/open-banking-aisp-pisp.md) -- Banking integration spec
- [Sumsub KYC Integration](../integration/sumsub-kyc-integration.md) -- KYC provider integration
- [ADR-003: PSD2 Pass-through Model](../adr/ADR-003-psd2-pass-through.md) -- Foundational architecture decision
- [ADR-007: BankID OIDC Auth](../adr/ADR-007-bankid-oidc-auth.md) -- 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

```mermaid
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

```mermaid
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 + `SameSite` cookie attribute.
- **Content type:** `application/json` for 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-browser` opens BankID authorize URL, redirects back via deep link `drop://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.ts` provides `query()`, `getOne()`, `run()`, `runIgnore()`, `runUpsert()`, `transaction()`.
- **Driver detection:** `DATABASE_URL` env var present = PostgreSQL via `pg.Pool`, absent = SQLite via `better-sqlite3`.
- **SQL compatibility:** Automatic conversion of SQLite dialect to PostgreSQL (placeholders `?` to `$N`, `datetime('now')` to `CURRENT_TIMESTAMP`, `INSERT OR IGNORE` to `ON CONFLICT DO NOTHING`).
- **Transaction isolation:** SQLite uses `BEGIN/COMMIT/ROLLBACK` on 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](../../backend/API-REFERENCE.md) — Full endpoint documentation with request/response examples
- **Database schema:** [DATABASE-SCHEMA.md](../../backend/DATABASE-SCHEMA.md) — All 19 tables with column definitions
- **Authentication:** [AUTHENTICATION.md](../../backend/AUTHENTICATION.md) — BankID OIDC flow, JWT structure, session management
- **Middleware:** [MIDDLEWARE.md](../../backend/MIDDLEWARE.md) — Detailed middleware documentation
- **Security:** [SECURITY-ARCHITECTURE.md](../../security/SECURITY-ARCHITECTURE.md) — Threat model, security headers, input validation
- **Deployment:** [deployment-architecture.md](deployment-architecture.md) — AWS + Cloudflare topology, CI/CD pipeline
- **Feature flags:** [FEATURE-FLAGS.md](../../backend/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

```mermaid
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)       |
+----------------------------------+
```

**BottomNav Tabs (Web):**

| 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

```mermaid
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](../../backend/API-REFERENCE.md)
- **Database schema behind API responses:** See [Database Schema](../../backend/DATABASE-SCHEMA.md)
- **Authentication flow (BankID OIDC):** See [Authentication](../../backend/AUTHENTICATION.md)
- **Page specifications:** See [PAGES.md](../../frontend/PAGES.md)
- **Figma exports (UI source of truth):** `mockups/figma-make-export/src/app/screens/`
- **Login authentication flow:** See [flow-login-authentication.md](../lld/flow-login-authentication.md)
- **QR payment flow:** See [flow-qr-payment.md](../lld/flow-qr-payment.md)
- **Transaction history flow:** See [flow-transaction-history.md](../lld/flow-transaction-history.md)
- **Notification flow:** See [flow-notifications.md](../lld/flow-notifications.md)
- **Merchant onboarding flow:** See [flow-merchant-onboarding.md](../lld/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.yml` and `docker-compose.production.yml`). No CI/CD pipeline, ECR, or GitHub Actions are configured yet.

```mermaid
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

```mermaid
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, GID `nodejs` 1001)
- **Note:** Runner stage currently includes `python3`, `make`, `g++` (installed via `apk add` for native dependency rebuilds). These should be removed in a future optimization.
- No source code — only compiled standalone output
- Data directory `/app/data` owned by `nextjs: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`

```json
// 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

1. All CI checks pass (TypeScript, lint, tests)
2. Docker image built and scanned (no CRITICAL vulnerabilities)
3. Image pushed to ECR
4. Staging deployment succeeds
5. Smoke tests pass (`GET /v1/health` returns 200)
6. Manual approval (production deployments only)
7. Production deployment with health verification
8. 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

```bash
# 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:

1. **SQLite (dev/staging):** Restore from backup (see [DEPLOYMENT.md](../../infrastructure/DEPLOYMENT.md) backup section)
2. **PostgreSQL (prod):** RDS point-in-time recovery to any second within retention window (30 days)

```bash
# 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](container-diagram.md) — C4 Level 2 container architecture
- **Deployment guide:** [DEPLOYMENT.md](../../infrastructure/DEPLOYMENT.md) — Docker compose, backup/restore procedures
- **Security architecture:** [SECURITY-ARCHITECTURE.md](../../security/SECURITY-ARCHITECTURE.md) — Security headers, CSRF, rate limiting
- **Feature flags:** [FEATURE-FLAGS.md](../../backend/FEATURE-FLAGS.md) — Environment variable driven feature gating
- **Database schema:** [DATABASE-SCHEMA.md](../../backend/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

```mermaid
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:
1. User sees **exact amount** and **payee name** during SCA
2. Authentication code is **cryptographically bound** to amount + payee
3. 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

```mermaid
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](../../security/SECURITY-ARCHITECTURE.md) — Detailed implementation-level security
- **Compliance Status:** [../../security/COMPLIANCE.md](../../security/COMPLIANCE.md) — Regulatory readiness assessment
- **BankID OIDC:** [../integration/bankid-oidc-integration.md](../integration/bankid-oidc-integration.md) — Authentication flow details
- **Open Banking:** [../integration/open-banking-aisp-pisp.md](../integration/open-banking-aisp-pisp.md) — ASPSP SCA, consent security
- **Payment Processing:** [../integration/payment-processing.md](../integration/payment-processing.md) — Transaction integrity, idempotency
- **Database Schema:** [../../backend/DATABASE-SCHEMA.md](../../backend/DATABASE-SCHEMA.md) — All 19 tables including compliance tables
- **API Reference:** [../../backend/API-REFERENCE.md](../../backend/API-REFERENCE.md) — Endpoint security requirements
- **Authentication:** [../../backend/AUTHENTICATION.md](../../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:

```mermaid
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](../hld/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

```mermaid
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

```mermaid
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](../../backend/DATABASE-SCHEMA.md)
- **Database design rationale:** [database-design.md](../database/database-design.md)
- **Migration strategy:** [migration-strategy.md](../database/migration-strategy.md)
- **Data lifecycle and GDPR:** [data-lifecycle.md](../database/data-lifecycle.md)
- **Audit architecture:** [audit-architecture.md](../database/audit-architecture.md)
- **Indexing strategy:** [indexing-strategy.md](../database/indexing-strategy.md)
- **API reference:** [API-REFERENCE.md](../../backend/API-REFERENCE.md)
- **Security architecture:** [SECURITY-ARCHITECTURE.md](../../security/SECURITY-ARCHITECTURE.md)
- **Authentication:** [AUTHENTICATION.md](../../backend/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 `sessions` table
- Age verification (>= 18) enforced during BankID callback

---

## 2. Web Login Flow (BankID OIDC)

### 2.1 Sequence Diagram — Web BankID Login

```mermaid
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:

```mermaid
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

```mermaid
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

```mermaid
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:

1. Reads `drop_token` cookie / Bearer token
2. Verifies current JWT and session validity
3. Revokes old session (`UPDATE sessions SET revoked = 1`)
4. Creates new session + JWT
5. Sets new `drop_token` cookie (Max-Age=604800, HttpOnly, SameSite=Lax)
6. 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:
1. Calls `GET /api/auth/me` on mount
2. If 401 → redirects to `/login`
3. Returns `{ user, loading }` to the page component
4. 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](../../backend/AUTHENTICATION.md)
- **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](../../backend/API-REFERENCE.md)
- **Session database schema:** `sessions` table — See [Database Schema](../../backend/DATABASE-SCHEMA.md)
- **Component overview:** See [component-overview.md](../hld/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](../../frontend/PAGES.md)
- **Registration flow:** See [flow-registration-onboarding.md](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)

```typescript
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`

1. Determine algorithm (HS256 or RS256)
2. Call `jose.jwtVerify(token, key, { issuer: "drop-api", audience: "drop" })`
3. Extract `userId`, `email`, `role` from payload
4. Type-check: both `userId` and `email` must be strings
5. Default `role` to `"user"` if not present

---

## JWT Refresh Flow

```mermaid
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`

1. Auth middleware validates current token
2. **All existing sessions revoked** (`revokeAllSessions(user.id)`)
3. New JWT signed with fresh `iat` and `exp` (7 days from now)
4. New session record created in `sessions` table
5. Cookie set for web clients (`Set-Cookie` header)
6. 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

```mermaid
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:

1. **Token signature verification** — JWT must be valid and not expired
2. **Session lookup** — `SELECT id FROM sessions WHERE token_hash = SHA256(token) AND revoked = 0 AND expires_at > NOW()`
3. **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)
4. **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:
1. `x-real-ip` header (nginx/Cloudflare)
2. First IP in `x-forwarded-for` chain (proxy chain)
3. 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

```json
{
  "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 |
| Email | `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)

### 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](../../backend/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](../../security/SECURITY-ARCHITECTURE.md) — Cookie settings, JWT configuration
- **API reference:** [API-REFERENCE.md](../../backend/API-REFERENCE.md) — Full endpoint documentation
- **Database schema:** [DATABASE-SCHEMA.md](../../backend/DATABASE-SCHEMA.md) — `sessions`, `users`, `audit_log` tables

# 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

```mermaid
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

```mermaid
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 `amr` claim)

| 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:**
1. User navigates to Settings > Privacy
2. Toggles marketing consent off
3. `POST /api/consents` with `{ consentType: "marketing", granted: false }`
4. Record updated: `granted = 0`, `withdrawn_at = now()`
5. User can re-grant at any time

---

## First Bank Account Linking

After consent collection, the user links their first bank account via AISP:

```mermaid
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](flow-kyc-aml.md) -- Detailed KYC verification and AML monitoring
- [Login Authentication Flow](flow-login-authentication.md) -- BankID login implementation details
- [Bank Account Linking Flow](flow-bank-account-linking.md) -- AISP integration details
- [Authentication System](../../backend/AUTHENTICATION.md) -- JWT, sessions, BankID OIDC
- [BankID OIDC Integration](../integration/bankid-oidc-integration.md) -- BankID technical specification
- [API Reference](../../backend/API-REFERENCE.md) -- Consent and auth endpoints
- [Database Schema](../../backend/DATABASE-SCHEMA.md) -- users, consents, bank_accounts tables
- [ADR-007: BankID OIDC Auth](../adr/ADR-007-bankid-oidc-auth.md) -- Authentication provider decision
- [ADR-003: PSD2 Pass-through](../adr/ADR-003-psd2-pass-through.md) -- 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:

1. **Onboarding KYC** -- Identity verification at registration via BankID + Sumsub document verification
2. **Transaction monitoring** -- Real-time and periodic analysis of transaction patterns
3. **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

```mermaid
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

```mermaid
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

```mermaid
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`):

1. User record is soft-deleted (`deleted_at` timestamp set)
2. Active sessions are revoked
3. A `data_access_request` record is created (type: `erasure`, status: `completed`)
4. **AML-required data is RETAINED** for 5 years per hvitvaskingsloven s. 4-18
5. 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](flow-registration-onboarding.md) -- User registration with KYC trigger
- [System Context (C4 Level 1)](../hld/system-context.md) -- Sumsub and Okokrim external actors
- [Sumsub KYC Integration](../integration/sumsub-kyc-integration.md) -- Technical integration specification
- [Database Schema](../../backend/DATABASE-SCHEMA.md) -- Compliance tables (aml_alerts, str_reports, screening_results, consents)
- [Compliance Status](../../security/COMPLIANCE.md) -- Current compliance readiness
- [ADR-003: PSD2 Pass-through](../adr/ADR-003-psd2-pass-through.md) -- Regulatory context
- [Data Lifecycle](../database/data-lifecycle.md) -- 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

```mermaid
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

```mermaid
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:**

```json
{
  "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:
1. See what data Drop is requesting (balances, transactions)
2. Authenticate with BankID (SCA: 2 of 3 factors)
3. Approve or deny the consent
4. 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):

1. `DELETE /v1/consents/{consentId}` at the ASPSP (revoke consent)
2. `DELETE FROM bank_accounts WHERE id = ?` in Drop DB
3. `UPDATE consents SET withdrawn_at = now WHERE aspsp_consent_id = ?`
4. If the removed account was primary, promote another linked account to primary
5. 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](../integration/open-banking-aisp-pisp.md) — Berlin Group API details, consent properties
- **BankID OIDC:** [../integration/bankid-oidc-integration.md](../integration/bankid-oidc-integration.md) — Drop authentication (separate from bank SCA)
- **Security Architecture:** [../hld/security-architecture.md](../hld/security-architecture.md) — Trust boundaries, data classification
- **Remittance Flow:** [flow-remittance.md](./flow-remittance.md) — Uses linked bank account for PISP
- **Database Schema:** [../../backend/DATABASE-SCHEMA.md](../../backend/DATABASE-SCHEMA.md) — `bank_accounts`, `consents` tables
- **API Reference:** [../../backend/API-REFERENCE.md](../../backend/API-REFERENCE.md) — `GET /api/auth/me` returns 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

```mermaid
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

```mermaid
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

When camera is unavailable or denied:
- **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

```json
{
  "merchantId": "mer_a1b2c3d4e5f6g7h8",
  "amount": 129
}
```

### 8.2 Response: 201 Created

```json
{
  "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)

```sql
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](../../backend/API-REFERENCE.md)
- **Merchant registration:** `POST /api/merchants/register` — See [API Reference](../../backend/API-REFERENCE.md)
- **Transaction schema:** `transactions` table — See [Database Schema](../../backend/DATABASE-SCHEMA.md)
- **Merchant schema:** `merchants` table — See [Database Schema](../../backend/DATABASE-SCHEMA.md)
- **Component overview:** See [component-overview.md](../hld/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](../../frontend/PAGES.md)
- **Mobile scan screen:** `src/drop-mobile/app/(tabs)/scan.js` — See [MOBILE-APP.md](../../mobile/MOBILE-APP.md)
- **Merchant onboarding flow:** See [flow-merchant-onboarding.md](flow-merchant-onboarding.md)
- **PSD2 PISP details:** See [open-banking-aisp-pisp.md](../integration/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:

1. **Select recipient** (or add new)
2. **Enter amount** (see FX rate + fee in real-time)
3. **Review** (PSD2 Art. 45 pre-payment disclosure)
4. **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

```mermaid
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

```mermaid
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 settlement
- `completed` — ASPSP accepted payment, settlement in progress or done
- `failed` — 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

1. When `POST /api/transactions/remittance` is called, the current rate is read from `exchange_rates`
2. The rate is stored in `transactions.exchange_rate` at insert time
3. If the SCA takes longer than 15 minutes, the reconciliation job detects the stale transaction and marks it `failed`
4. 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:

```json
{
  "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](../integration/payment-processing.md) — SEPA/SWIFT settlement, FX, fees
- **Open Banking AISP/PISP:** [../integration/open-banking-aisp-pisp.md](../integration/open-banking-aisp-pisp.md) — PISP API details
- **BankID OIDC:** [../integration/bankid-oidc-integration.md](../integration/bankid-oidc-integration.md) — Drop authentication
- **Security Architecture:** [../hld/security-architecture.md](../hld/security-architecture.md) — Fraud detection, AML pipeline
- **Bank Account Linking:** [flow-bank-account-linking.md](./flow-bank-account-linking.md) — Prerequisite: linked bank account
- **Database Schema:** [../../backend/DATABASE-SCHEMA.md](../../backend/DATABASE-SCHEMA.md) — `transactions`, `recipients`, `exchange_rates` tables
- **API Reference:** [../../backend/API-REFERENCE.md](../../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

```mermaid
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

```mermaid
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:**
```json
{
  "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`

```json
{
  "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'` |

**Authorization gates:**
- `GET /api/merchants/dashboard` — requires `role = 'merchant'`
- `GET /api/merchants/qr` — requires `role = 'merchant'`
- `GET /api/merchants/transactions` — requires `role = '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](../../backend/API-REFERENCE.md)
- **Merchant dashboard API:** `GET /api/merchants/dashboard` — See [API Reference](../../backend/API-REFERENCE.md)
- **Merchant QR API:** `GET /api/merchants/qr` — See [API Reference](../../backend/API-REFERENCE.md)
- **Merchant transactions API:** `GET /api/merchants/transactions` — See [API Reference](../../backend/API-REFERENCE.md)
- **Merchants schema:** `merchants` table — See [Database Schema](../../backend/DATABASE-SCHEMA.md)
- **Transactions schema:** `transactions` table — See [Database Schema](../../backend/DATABASE-SCHEMA.md)
- **Component overview:** See [component-overview.md](../hld/component-overview.md)
- **Figma merchant dashboard:** `mockups/figma-make-export/src/app/screens/MerchantDashboard.tsx`
- **QR payment flow:** See [flow-qr-payment.md](flow-qr-payment.md)
- **Authentication flow:** See [flow-login-authentication.md](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

```mermaid
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

```mermaid
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:

```json
{
  "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](../../backend/API-REFERENCE.md)
- **Transaction detail API:** `GET /api/transactions/{id}` — See [API Reference](../../backend/API-REFERENCE.md)
- **Receipt API:** `GET /api/transactions/{id}/receipt` — See [API Reference](../../backend/API-REFERENCE.md)
- **Complaints API:** `POST /api/complaints` — See [API Reference](../../backend/API-REFERENCE.md)
- **Transaction schema:** `transactions` table — See [Database Schema](../../backend/DATABASE-SCHEMA.md)
- **Component overview:** See [component-overview.md](../hld/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](../../frontend/PAGES.md)
- **Mobile screen:** `src/drop-mobile/app/history.js` — See [MOBILE-APP.md](../../mobile/MOBILE-APP.md)
- **QR payment flow:** See [flow-qr-payment.md](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

```mermaid
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

```mermaid
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

```mermaid
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 |
| Email | 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](../../backend/API-REFERENCE.md)
- **Settings API:** `GET/PATCH /api/settings` — See [API Reference](../../backend/API-REFERENCE.md)
- **Data export API:** `GET /api/user/data-export` — See [API Reference](../../backend/API-REFERENCE.md)
- **Account deletion API:** `DELETE /api/user/account` — See [API Reference](../../backend/API-REFERENCE.md)
- **Consents API:** `GET/POST /api/consents` — See [API Reference](../../backend/API-REFERENCE.md)
- **Settings schema:** `settings` table — See [Database Schema](../../backend/DATABASE-SCHEMA.md)
- **Data access requests schema:** `data_access_requests` table — See [Database Schema](../../backend/DATABASE-SCHEMA.md)
- **Component overview:** See [component-overview.md](../hld/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](../../frontend/PAGES.md)
- **Mobile profile screen:** `src/drop-mobile/app/(tabs)/profile.js` — See [MOBILE-APP.md](../../mobile/MOBILE-APP.md)
- **Authentication flow:** See [flow-login-authentication.md](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

```mermaid
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)

```mermaid
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

```mermaid
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

```mermaid
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](../../backend/API-REFERENCE.md)
- **Settings API:** `PATCH /api/settings` (push/email toggles) — See [API Reference](../../backend/API-REFERENCE.md)
- **Notifications schema:** `notifications` table — See [Database Schema](../../backend/DATABASE-SCHEMA.md)
- **Settings schema:** `settings` table — See [Database Schema](../../backend/DATABASE-SCHEMA.md)
- **Component overview:** See [component-overview.md](../hld/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](../../frontend/PAGES.md)
- **Profile settings flow:** See [flow-profile-settings.md](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](./hld.md)

## 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 (`transactions` table), exchange rates (`exchange_rates` table), fee calculation, pre-payment disclosure, idempotency enforcement, PISP payment initiation orchestration
- **Does NOT own:** User authentication (auth module), recipient management (`recipients` table 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:**
1. 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
2. Every transaction requires `kyc_status = 'approved'` on the initiating user
3. Remittance amounts: 100 NOK minimum, 50,000 NOK maximum; fee = 0.5% of send amount
4. QR payments: fee = merchant `fee_rate` (default 1%); validated via HMAC QR code
5. `idempotency_key` (unique index on `transactions`) prevents double-charging on retry

---

## 2. Class / Module Diagram

```mermaid
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)

```sql
-- transaction type
CHECK(type IN ('remittance', 'qr_payment'))

-- transaction status
CHECK(status IN ('processing', 'completed', 'failed'))
```

### 3.3 Migration Notes

- Migration: Included in `db.ts` `initializeDatabase()` — runs on startup (SQLite) or via separate migration script (PostgreSQL)
- Zero-downtime: YES — only `INSERT` and `UPDATE status` needed; `CREATE INDEX CONCURRENTLY` for 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:**
```json
{
  "recipientId": "rec_abc123def456gh78",
  "amount": 2000,
  "bankAccountId": "ba_abc123def456gh78",
  "currency": "NOK"
}
```

**Success Response — `201 Created`:**
```json
{
  "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:**
```json
{
  "merchantId": "mer_abc123def456gh78",
  "amount": 450
}
```

**Success Response — `201 Created`:**
```json
{
  "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:**
```json
{
  "type": "remittance",
  "amount": 2000,
  "recipientId": "rec_abc123def456gh78"
}
```

**Success Response — `200 OK`:**
```json
{
  "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`:**
```json
{
  "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)

```pseudocode
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}`

```pseudocode
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

```mermaid
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

```mermaid
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

```mermaid
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

```json
{
  "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:**
- [x] Remittance — success path (mock PISP)
- [x] Remittance — KYC not approved → 403
- [x] Remittance — amount < 100 NOK → 422
- [x] Remittance — amount > 50,000 NOK → 422
- [x] Remittance — recipient not found → 404
- [x] Remittance — duplicate request (idempotency key) → 409 with existing transaction
- [x] QR payment — success path
- [x] QR payment — merchant inactive → 404
- [x] 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

```mermaid
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

1. **Authentication** -- request must include a valid JWT token (authMiddleware).
2. **Input validation** -- reason is checked against the allowlist; both reason and comment are sanitized via `sanitizeText` with length limits.
3. **Record creation** -- a new `withdrawal_requests` row is inserted with status `pending`.
4. **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_at` set, 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.ts` retention endpoint and `docs/architecture/lld/flow-kyc-aml.md` for AML retention requirements.
- **Audit logging** -- See `src/drop-api/src/lib/audit.ts` for 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](https://hono.dev/) 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

```mermaid
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:**
1. Extract bearer token from `Authorization` header or cookie
2. Verify JWT signature (HS256) and check session in `sessions` table
3. If invalid or expired: return `401 Unauthorized`
4. If role mismatch (merchant/admin variants): return `403 Forbidden`
5. 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.

```typescript
// 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.

```typescript
// 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)

```mermaid
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](../hld/security-architecture.md) — Trust boundaries, STRIDE, application security controls
- [Authentication](../../backend/AUTHENTICATION.md) — JWT, session management, BankID OIDC
- [API Reference](../../backend/API-REFERENCE.md) — Endpoint specifications and security requirements
- [Login Authentication Flow](flow-login-authentication.md) — 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](./ADR-003-psd2-pass-through.md): PSD2 pass-through model — constrains us to regulated TPP AISP/PISP, not BaaS wallet
- [ADR-005](./ADR-005-monolith-first.md): 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.ts` file (deprecated Swan mock) must be removed and replaced with a `mock-openbanking.ts` compatible 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:
1. Set `NEXT_PUBLIC_SERVICE_MODE=mock` in AWS Secrets Manager
2. Restart App Runner instances — rollback complete in < 5 minutes
3. 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](./ADR-003-psd2-pass-through.md) | Prerequisite | PSD2 pass-through model requires AISP/PISP provider |
| [ADR-005](./ADR-005-monolith-first.md) | Constrained by | Single monolith means single aggregator integration point |
| [ADR-007](./ADR-007-bankid-oidc-auth.md) | 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:

1. **`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-backed `rate_limits` table, IP extraction from `X-Forwarded-For`, and standardized JSON error responses (`jsonError`).

2. **`lib/middleware/` directory** -- A more robust, modular implementation with `auth-middleware.ts` (Bearer token auth, in-memory rate limiting with `X-RateLimit-*` headers), `error-handler.ts` (typed `AppError` class with predefined error constructors), and `validation.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.

```mermaid
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

1. **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_limits` SQLite table (replacing in-memory `Map`)

2. **Remove duplicate code** after consolidation to eliminate confusion about which implementation is authoritative.

3. **Separate FontelePay** from Drop's `src/` directory (see [ADR-002](ADR-002-separate-fontelepay.md)).

4. **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](ADR-002-separate-fontelepay.md) -- Companion decision for FontelePay extraction
- [Middleware Documentation](../../backend/MIDDLEWARE.md) -- Current middleware reference
- [Security Architecture](../../security/SECURITY-ARCHITECTURE.md) -- 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 |

```mermaid
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:

1. Move `src/fontelepay/` to `~/ALAI/products/FontelePay/` (or archive)
2. Preserve FontelePay's git history by keeping its `.git/` intact during move
3. Keep only a reference document in Drop pointing to FontelePay's new location
4. Copy relevant research documents (particularly `rnd/mobilebank-research/`) to Drop's `rnd/` 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](ADR-001-consolidate-backends.md) -- Companion decision for middleware cleanup
- [ADR-003: PSD2 Pass-through Model](ADR-003-psd2-pass-through.md) -- 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 `users` database 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**.

```mermaid
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:

1. **No wallet:** Remove all local balance, top-up, and fund-holding functionality
2. **AISP for balance:** User sees their bank account balance via Open Banking API (read-only). The `bank_accounts.balance` field stores a cached AISP read -- not a Drop-held balance
3. **PISP for payments:** Remittance and QR payments are initiated from the user's own bank account via Open Banking payment initiation with SCA
4. **No card storage:** Cards feature gated behind feature flags (all default `false`); future card issuance via PCI-compliant partner only
5. **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 |

```mermaid
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)](../hld/system-context.md) -- Shows Drop's external system relationships
- [Open Banking Integration](../integration/open-banking-aisp-pisp.md) -- AISP/PISP integration specification
- [Security Architecture](../../security/SECURITY-ARCHITECTURE.md) -- Security controls for pass-through model
- [Compliance Status](../../security/COMPLIANCE.md) -- Regulatory compliance tracking
- [Roadmap](../../../ROADMAP.md) -- 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.**

Web cookie configuration (`auth.ts:48-54`):

| 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) and `SameSite=Lax` (changed from the originally specified strict/24h to support BankID OIDC redirect flows).

CSRF protection layers:
1. `sameSite: "Lax"` -- browser refuses to send cookie on cross-origin POST requests
2. Origin header validation against allowed origins whitelist (`app.ts:23-30` CORS middleware)
3. CSRF token generation available (`generateCsrfToken()`) for additional protection

```mermaid
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: strict` provides 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 `sessions` table 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 `sameSite` adjustment
- Mobile app requires separate Bearer token flow (dual auth pattern)

### Risks
- **CSP bypass:** If CSP includes `unsafe-inline` or `unsafe-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](../../security/SECURITY-ARCHITECTURE.md) -- Full security controls documentation
- [Authentication System](../../backend/AUTHENTICATION.md) -- Auth flow implementation details
- [Middleware Documentation](../../backend/MIDDLEWARE.md) -- CSRF and auth middleware
- [ADR-007: BankID OIDC Auth](ADR-007-bankid-oidc-auth.md) -- 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.

```mermaid
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](../../../project/architecture/architecture-document.md) -- Section 1.2: Architecture Style
- [System Context (C4 Level 1)](../hld/system-context.md) -- High-level system view
- [Container Diagram (C4 Level 2)](../hld/container-diagram.md) -- Internal container structure
- [ADR-012: AWS App Runner](ADR-012-aws-app-runner-deploy.md) -- 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](ADR-014-postgresql-only.md) (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.

```mermaid
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](ADR-010-dual-database-driver.md) for the abstraction layer details.

## Consequences

### Positive
- Zero-config local development: `npm run dev` just 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](ADR-010-dual-database-driver.md) -- Abstraction layer implementation
- [Database Schema](../../backend/DATABASE-SCHEMA.md) -- Full schema documentation
- [Database Design](../database/database-design.md) -- Database architecture decisions
- [Migration Strategy](../database/migration-strategy.md) -- 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:
1. Parse `pid` (fodselsnummer, 11 digits) from BankID ID token
2. Hash `pid` with SHA-256 for storage (`national_id_hash` column)
3. Check for existing user by `national_id_hash`
4. If new: create user with `kyc_status = 'approved'`, `kyc_method = 'bankid'`
5. Verify age >= 18 from `pid` birthdate encoding

```mermaid
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 OIDC
- `POST /auth/register` -- automatic via BankID
- `POST /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_provider` field 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](../../backend/AUTHENTICATION.md) -- Full auth implementation documentation
- [BankID OIDC Integration](../integration/bankid-oidc-integration.md) -- Integration specification
- [ADR-004: JWT httpOnly Cookies](ADR-004-jwt-httponly-cookies.md) -- Token storage decision
- [ADR-003: PSD2 Pass-through](ADR-003-psd2-pass-through.md) -- SCA requirement origin
- [Security Architecture](../../security/SECURITY-ARCHITECTURE.md) -- 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.**

```mermaid
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
```

Both APIs share the same database access layer, BankID integration, and validation utilities. The difference is in auth pattern and deployment:

| 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)](../hld/container-diagram.md) -- Shows both API containers
- [Authentication System](../../backend/AUTHENTICATION.md) -- Web vs mobile auth flows
- [API Reference](../../backend/API-REFERENCE.md) -- Next.js API endpoints
- [ADR-005: Monolith First](ADR-005-monolith-first.md) -- 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:

1. **Gradual rollout:** Cards feature requires a card issuing partner before activation -- must be gated
2. **Kill switches:** Ability to disable features instantly in production if compliance or operational issues arise
3. **Development:** Feature-in-progress code can be merged to main without being user-visible
4. **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:

```mermaid
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()`:**
```typescript
const gate = featureGate("physicalCards");
if (gate) return gate;  // Returns 404: "Feature not available"
```

**Client-side conditional rendering via `useFeatureFlag()`:**
```typescript
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](../../backend/FEATURE-FLAGS.md) -- Full API reference and flag listing
- [API Reference](../../backend/API-REFERENCE.md) -- Routes using `featureGate()`
- [Security Architecture](../../security/SECURITY-ARCHITECTURE.md) -- Feature flags section
- [ADR-005: Monolith First](ADR-005-monolith-first.md) -- Single deployment model

# ADR-010: Dual Database Driver

# ADR-010: Dual Database Driver Abstraction

**Status:** SUPERSEDED by [ADR-014: PostgreSQL-Only Architecture](ADR-014-postgresql-only.md) (2026-03-03)
**Date:** 2026-02-21
**Deciders:** John (AI Director)
**Category:** Database

---

## Context

Per [ADR-006](ADR-006-sqlite-to-postgresql.md), 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;
```

```mermaid
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](ADR-006-sqlite-to-postgresql.md) -- Database strategy decision
- [Database Schema](../../backend/DATABASE-SCHEMA.md) -- Table definitions for both dialects
- [Migration Strategy](../database/migration-strategy.md) -- Data migration plan
- [Database Design](../database/database-design.md) -- 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:

1. **Code sharing:** Drop's web app uses React 19. Expo enables sharing React components, hooks, types, and business logic between web and mobile.
2. **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.
3. **QR scanning:** Core feature requires camera access. `expo-camera` provides this with barcode scanning built in.
4. **OTA updates:** Financial apps need rapid hotfix deployment. Expo Application Services (EAS) provides over-the-air JavaScript bundle updates without App Store review.
5. **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.**

```mermaid
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:
1. `GET /v1/auth/bankid/initiate?platform=mobile` returns `{ redirectUrl, state }`
2. Open BankID in `expo-web-browser` (secure, isolated browser)
3. BankID redirects to `drop://auth/callback?code=&state=`
4. `expo-linking` catches the deep link
5. `POST /v1/auth/bankid/callback` exchanges code for Bearer token
6. 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-camera` provides built-in barcode scanning for QR payments
- `expo-web-browser` provides 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](ADR-008-hono-api-framework.md) -- Mobile API backend
- [Authentication System](../../backend/AUTHENTICATION.md) -- Mobile BankID flow
- [Component Overview (C4 Level 3)](../hld/component-overview.md) -- Mobile app components
- [ADR-007: BankID OIDC Auth](ADR-007-bankid-oidc-auth.md) -- 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:

1. **WebSocket/long connections:** App Runner supports them; Lambda does not (29s timeout)
2. **PostgreSQL connectivity:** App Runner runs in VPC, can connect to RDS; Lambda requires NAT gateway ($32/mo)
3. **Operational simplicity:** App Runner is "push container, get HTTPS endpoint" -- no load balancer, target group, or service mesh to configure
4. **Cost at scale:** App Runner pricing is straightforward (vCPU-hour + memory-hour); ECS/Fargate pricing is similar but with more configuration
5. **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).**

```mermaid
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

```mermaid
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.md` describes 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](../hld/deployment-architecture.md) -- Full deployment topology
- [System Context (C4 Level 1)](../hld/system-context.md) -- Infrastructure components
- [ADR-005: Monolith First](ADR-005-monolith-first.md) -- Single deployment model
- [ADR-008: Hono API Framework](ADR-008-hono-api-framework.md) -- 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](ADR-001-consolidate-backends.md) | Consolidate to Single Backend | Accepted | 2026-02-12 | Architecture |
| [ADR-002](ADR-002-separate-fontelepay.md) | Separate FontelePay from Drop Repository | Accepted | 2026-02-12 | Architecture |
| [ADR-003](ADR-003-psd2-pass-through.md) | Adopt PSD2 Pass-through Model (No Wallet) | Accepted | 2026-02-12 | Architecture |
| [ADR-004](ADR-004-jwt-httponly-cookies.md) | JWT Storage in httpOnly Cookies | Accepted | 2026-02-21 | Security |
| [ADR-005](ADR-005-monolith-first.md) | Monolith-First Architecture | Accepted | 2026-02-21 | Architecture |
| [ADR-006](ADR-006-sqlite-to-postgresql.md) | SQLite for Dev, PostgreSQL for Production | Superseded by ADR-014 | 2026-02-21 | Database |
| [ADR-007](ADR-007-bankid-oidc-auth.md) | BankID as Sole Authentication Provider | Accepted | 2026-02-21 | Security |
| [ADR-008](ADR-008-hono-api-framework.md) | Hono v4 for Mobile API | Accepted | 2026-02-21 | Backend |
| [ADR-009](ADR-009-feature-flag-system.md) | Custom Feature Flag System | Accepted | 2026-02-21 | Backend |
| [ADR-010](ADR-010-dual-database-driver.md) | Dual Database Driver Abstraction | Superseded by ADR-014 | 2026-02-21 | Database |
| [ADR-011](ADR-011-expo-mobile-framework.md) | Expo SDK 54 for Mobile App | Accepted | 2026-02-21 | Mobile |
| [ADR-012](ADR-012-aws-app-runner-deploy.md) | AWS App Runner for Deployment | Accepted | 2026-02-21 | Infrastructure |
| [ADR-014](ADR-014-postgresql-only.md) | PostgreSQL-Only Architecture (Drizzle ORM) | Accepted | 2026-02-26 | Database |

---

## ADR Lifecycle

```mermaid
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.

```markdown
# 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

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

2. **Scope:** One ADR per decision. If a decision has multiple parts, consider splitting into separate ADRs.

3. **Numbering:** Use sequential three-digit numbers (001, 002, ...). Never reuse numbers.

4. **Review process:** Draft as `Proposed`, share with the team. Once approved by the AI Director (John) and/or CEO (Alem), change status to `Accepted`.

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

6. **Context matters:** Future readers need to understand *why* the decision was made. Include constraints, alternatives considered, and the reasoning.

---

## Cross-References

- [Architecture Document](../../../project/architecture/architecture-document.md) -- Main architecture overview
- [System Context (C4 Level 1)](../hld/system-context.md) -- System context diagram
- [Compliance Status](../../security/COMPLIANCE.md) -- 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)

```mermaid
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)

```mermaid
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)

```mermaid
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

```typescript
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:
1. Extracts JWT from cookie (web) or Authorization header (mobile)
2. Verifies JWT signature and expiry with `jose.jwtVerify()`
3. Hashes the JWT with SHA-256
4. Looks up the session by `token_hash`
5. Verifies `revoked = 0` and `expires_at > now`
6. 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:**

1. **Parse birthdate** from pid digits → ISO date string (`YYYY-MM-DD`)
2. **Verify age >= 18** by comparing birthdate to current date (`isOver18()`)
3. **Hash pid** with SHA-256 for storage: `crypto.createHash("sha256").update(pid).digest("hex")`
4. **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:

1. **Drop authentication (login):** BankID OIDC — satisfies SCA for account access
2. **Payment SCA (PISP):** Delegated to ASPSP — user re-authenticates with BankID at the bank for each payment (see [open-banking-aisp-pisp.md](./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

```mermaid
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:**
1. `initiateOIDC()` generates a redirect URL (same structure, but not called)
2. Frontend skips BankID redirect, posts a mock code to callback
3. `exchangeAndVerify()` detects `isMock=true` and returns mock user info
4. `findOrCreateUser()` 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:

1. Register Vipps Login client at `portal.vipps.no`
2. Add Vipps OIDC endpoints alongside BankID
3. Vipps also returns Norwegian pid (fødselsnummer)
4. User deduplication via `national_id_hash` — same hash regardless of whether user logs in with BankID or Vipps
5. 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](../../backend/AUTHENTICATION.md) — Full auth system documentation
- **Open Banking AISP/PISP:** [open-banking-aisp-pisp.md](./open-banking-aisp-pisp.md) — ASPSP SCA (separate from BankID login SCA)
- **Security Architecture:** [../hld/security-architecture.md](../hld/security-architecture.md) — JWT security, session management
- **Login Flow (LLD):** [../lld/flow-login-authentication.md](../lld/flow-login-authentication.md) — Step-by-step login UX
- **Database Schema:** [../../backend/DATABASE-SCHEMA.md](../../backend/DATABASE-SCHEMA.md) — `users`, `sessions` tables
- **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:

1. **Register with Finanstilsynet** (Norwegian FSA) as a payment institution or authorized agent
2. **Obtain eIDAS certificates** (QWAC for TLS, QSeal for signing) from a qualified TSP
3. **Register in the EBA TPP Register** for EU-wide passporting
4. **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).

```mermaid
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/me` returns `totalBalance` and `bankAccounts[].balance` — these are cached AISP reads from the `bank_accounts` table
- `bank_accounts.balance_synced_at` tracks 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)).

```mermaid
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:

1. Generate `idempotency_key` from: `{userId}:{recipientId}:{amount}:{timestamp_minute}`
2. Include as `X-Request-ID` header in PISP API calls
3. If ASPSP returns a duplicate error, look up existing transaction by `idempotency_key`
4. Return the existing transaction to the user (no double-charge)

---

## 5. Consent Lifecycle

### 5.1 State Diagram

```mermaid
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:

1. **`consents` table** — GDPR consent records (consent_type: `psd2_aisp`, `psd2_pisp`)
2. **`bank_accounts` table** — Links to ASPSP consent ID for each linked account

When a consent expires or is revoked:
- `bank_accounts.balance` is zeroed (stale data removed)
- `bank_accounts.balance_synced_at` is 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:

1. Drop sends AISP consent or PISP payment request to ASPSP
2. ASPSP returns an `scaRedirect` URL
3. Drop redirects the user to the bank's SCA page
4. User authenticates with BankID (knowledge + possession factors)
5. 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](./bankid-oidc-integration.md) — Drop's authentication layer (separate from ASPSP SCA)
- **Payment Processing:** [payment-processing.md](./payment-processing.md) — SEPA/SWIFT settlement, FX, fees
- **Security Architecture:** [../hld/security-architecture.md](../hld/security-architecture.md) — Threat model, SCA controls
- **Bank Account Linking Flow:** [../lld/flow-bank-account-linking.md](../lld/flow-bank-account-linking.md) — Detailed AISP consent UX
- **Remittance Flow:** [../lld/flow-remittance.md](../lld/flow-remittance.md) — Detailed PISP payment UX
- **Database Schema:** [../../backend/DATABASE-SCHEMA.md](../../backend/DATABASE-SCHEMA.md) — `bank_accounts`, `transactions`, `consents` tables
- **API Reference:** [../../backend/API-REFERENCE.md](../../backend/API-REFERENCE.md) — Drop API endpoints
- **Compliance Status:** [../../security/COMPLIANCE.md](../../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:

1. **Remittance** — cross-border money transfers to 30+ countries (via SEPA SCT/SCTInst for EEA, SWIFT gpi for non-EEA)
2. **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

```mermaid
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

```mermaid
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**:

1. User sees rate on the disclosure screen
2. Rate is locked when user confirms (before SCA)
3. If SCA completes within 15 minutes, the locked rate applies
4. 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

```mermaid
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`):

```sql
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:**
1. Before creating a transaction, check if `idempotency_key` already exists
2. If exists, return the existing transaction (no duplicate)
3. If not, insert new transaction with the key
4. Pass the same key as `X-Request-ID` to 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

```json
{
  "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 COMMITTED` isolation (default) prevents dirty reads; use `SERIALIZABLE` for 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](./open-banking-aisp-pisp.md) — Berlin Group API integration, consent lifecycle
- **BankID OIDC:** [bankid-oidc-integration.md](./bankid-oidc-integration.md) — Authentication (not payment SCA)
- **Security Architecture:** [../hld/security-architecture.md](../hld/security-architecture.md) — Fraud detection, AML screening
- **Remittance Flow (LLD):** [../lld/flow-remittance.md](../lld/flow-remittance.md) — Step-by-step remittance UX
- **Database Schema:** [../../backend/DATABASE-SCHEMA.md](../../backend/DATABASE-SCHEMA.md) — `transactions`, `exchange_rates`, `bank_accounts` tables
- **API Reference:** [../../backend/API-REFERENCE.md](../../backend/API-REFERENCE.md) — Transaction, disclosure, and rate endpoints
- **Compliance:** [../../security/COMPLIANCE.md](../../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

```mermaid
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.

```typescript
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:
- `authorization` (Bearer tokens)
- `cookie` (session cookies)
- `set-cookie` (response cookies)

---

## 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):

```json
{
  "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:
1. Set as a Hono context variable (`app.ts:34`)
2. Returned in the `x-request-id` response header (`app.ts:36`)
3. Included in all log entries for the request
4. Attached as a Sentry tag for cross-referencing

---

## Error Handling Flow

```mermaid
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

```bash
# 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`, or `production`
- **Commits:** Automatically linked via `--auto` flag
- **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/SECURITY-ARCHITECTURE.md) — Security monitoring context
- **Deployment:** [deployment-architecture.md](../hld/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

```mermaid
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

```mermaid
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:
1. Read raw request body
2. Compute `HMAC-SHA256(body, SUMSUB_SECRET_KEY)`
3. Compare with `X-Payload-Digest` header (timing-safe comparison)
4. 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:

1. On webhook receipt, check if `screening_results` already has an entry with matching `user_id` + `screening_type` + same `result`
2. If duplicate: return 200 OK immediately (acknowledge but don't process)
3. If new or changed result: process the update, insert new `screening_results` record

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

```typescript
// 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)

```typescript
// 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:

1. Check `screening_results` table for latest result
2. Query Sumsub API: `GET /resources/applicants/{externalUserId}/status`
3. If Sumsub shows `GREEN` but DB shows `pending`: manually update via admin endpoint
4. 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" }` immediately
- `checkKycStatus()` 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](../../backend/AUTHENTICATION.md) — BankID auto-approves KYC
- **Database schema:** [DATABASE-SCHEMA.md](../../backend/DATABASE-SCHEMA.md) — `screening_results`, `aml_alerts`, `users.kyc_status`
- **Compliance:** [COMPLIANCE.md](../../security/COMPLIANCE.md) — AML/KYC regulatory requirements
- **Security:** [SECURITY-ARCHITECTURE.md](../../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
1. Osnovati d.o.o. u Srbiji (ALAI Tech d.o.o.)
2. Pripremiti dokumentaciju po članu 82 stav 1 (tačke 1, 2, 4, 5, 8, 10, 12, 15, 19, 20, 21, 23)
3. Zaključiti ugovor o osiguranju od odgovornosti sa društvom za osiguranje
4. 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
- [Zakon o platnim uslugama (PDF)](https://www.nbs.rs/export/sites/NBS_site/documents/propisi/zakoni/zakon_o_platnim_uslugama_13082024.pdf)

## 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
1. Dohvatiti puni tekst emaila sa svim detaljima dokumentacije
2. Pripremiti listu potrebnih dokumenata po članu 82a
3. Istražiti opcije osiguranja od odgovornosti u Srbiji
4. 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:

1. **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.
2. **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.
3. **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

```mermaid
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:**
- `id` uses `usr_` prefix + 16 hex chars for readability and collision avoidance across distributed systems.
- `password_hash` defaults to `'EIDONLY'` sentinel value -- BankID-only users have no password. This avoids nullable password fields that complicate auth logic.
- `auth_provider` tracks how the user registered (`bankid`). Supports future Vipps Login without schema changes.
- `national_id_hash` stores SHA-256 of Norwegian fodselsnummer. Enables user deduplication across auth providers without storing the raw national ID.
- `deleted_at` enables soft delete for GDPR erasure while retaining records for AML legal obligations (5-year retention).
- `risk_level`, `pep_status`, `sanctions_cleared` are 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_amount` are stored as integers in minor units (ore for NOK, para for RSD, etc.) to avoid floating-point precision issues.
- Polymorphic reference: `recipient_id` is set for remittances, `merchant_id` for QR payments. Never both. This avoids a separate join table for a simple either/or relationship.
- `exchange_rate` is denormalized (snapshot at transaction time) because rates change. The rate at execution time must be preserved for audit and dispute resolution.
- `idempotency_key` with a unique partial index (`WHERE idempotency_key IS NOT NULL`) prevents duplicate transaction submission without requiring every transaction to have a key.
- `purpose_code` supports remittance regulatory requirements (some corridors require a transfer purpose).
- `completed_at` is separate from `created_at` to track processing duration.

#### `bank_accounts`
**Normalization:** 3NF.

**Design decisions:**
- `balance` is 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_at` tracks when the balance was last refreshed from the bank via Open Banking.
- `is_primary` flag determines which account is used for transactions by default (1 = primary, 0 = secondary).
- No unique constraint on `account_number` because the same bank account could theoretically appear under different user records (shared accounts).

#### `recipients`
**Normalization:** 3NF.

**Design decisions:**
- Scoped to user (`user_id` FK) -- recipients are private, not shared.
- `country` and `currency` stored 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_account` stores the full foreign account number. Format varies by country (IBAN for EU, local format for others).

#### `merchants`
**Normalization:** 3NF.

**Design decisions:**
- `org_number` is UNIQUE -- one merchant registration per Norwegian organization number (9 digits).
- `qr_hmac_key` is generated server-side (`hex(randomblob(32))`) for QR code integrity verification. Each merchant gets a unique key.
- `fee_rate` defaults to `0.01` (1%). Stored per merchant to allow variable pricing in the future.
- `user_id` FK links the merchant to the user who registered it. A user's role is upgraded to `merchant` upon registration.

#### `exchange_rates`
**Normalization:** 3NF.

**Design decisions:**
- `id` uses `INTEGER 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_hash` stores SHA-256 of the JWT, not the JWT itself. This prevents session hijacking even if the database is compromised.
- `revoked` flag (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_id` as 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:**
- `read` flag (0/1) for marking notifications as read in batch.
- No foreign key to the triggering entity (transaction, system event) -- `type` field 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_four` and `token_ref` -- never full card number or CVV (PCI-DSS compliance).
- `pin_hash` added via runtime migration for backward compatibility.
- `status` supports freeze/unfreeze without deletion (`active` -> `frozen` -> `active`).

#### `spending_limits` (FUTURE)
**Normalization:** 3NF.

**Design decisions:**
- `card_id` is nullable -- supports user-level limits (no specific card) or card-level limits.
- `limit_type` values (`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:**
- `key` is the IP address (TEXT PK). Simple key-value store.
- `reset_at` is a Unix timestamp. Expired entries are cleaned every 100 rate limit checks in `middleware/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_id` is nullable because some audit events occur before authentication (e.g., failed login attempts).
- `resource_type` + `resource_id` enable generic resource tracking without polymorphic FKs.
- `details` is a TEXT field (JSON string) for flexible event-specific data.
- `request_id` for correlating multiple audit entries from a single API request.
- Two indexes: `user_id` and `action` for the primary query patterns. (Note: no `timestamp` index exists in the implementation — only `idx_audit_log_user` and `idx_audit_log_action` are created in `db.ts`.)

#### `aml_alerts`
- `severity` (low/medium/high/critical) determines investigation priority and escalation timelines.
- `status` workflow: `open` -> `investigating` -> `resolved|escalated|filed`.
- `reviewed_by` and `reviewed_at` track the compliance officer's review.
- `transaction_id` FK links to the specific transaction that triggered the alert.

#### `str_reports`
- Filed with Okokrim/EFE (Norwegian financial intelligence unit).
- `reference_number` stores the authority-assigned reference after submission.
- `alert_id` links back to the originating AML alert.
- Immutable after `status = 'submitted'` -- regulatory requirement.

#### `screening_results`
- `screening_type` (pep/sanctions/adverse_media) supports multiple screening categories.
- `provider` tracks which screening service was used (future: Sumsub, Refinitiv, etc.).
- `match_details` stores full match information as TEXT (JSON) for review.
- Multiple results per user (periodic rescreening).

#### `consents`
- `consent_type` values: `terms`, `privacy`, `marketing`, `cookies_analytics`, `cookies_marketing`.
- `granted` (0/1) with separate `granted_at`/`withdrawn_at` timestamps for full consent lifecycle.
- `ip_address` stored as proof of consent action per GDPR requirements.

#### `data_access_requests`
- `request_type` covers GDPR data subject rights: export (Art. 15), erasure (Art. 17), rectification (Art. 16), restriction (Art. 18).
- `download_url` for data export files (temporary signed URLs).
- `status` workflow: `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.
- `resolution` text field filled when complaint is resolved.
- `resolved_at` timestamp 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](../../backend/DATABASE-SCHEMA.md)
- **Data architecture overview:** [data-architecture.md](../hld/data-architecture.md)
- **Migration strategy:** [migration-strategy.md](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](../adr/ADR-014-postgresql-only.md) 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-sqlite3` dependency described in this
> document have been removed. The codebase now uses Drizzle ORM with PostgreSQL 16 exclusively.
> `db.ts` and `USE_PG` no longer exist. See [ADR-014](../adr/ADR-014-postgresql-only.md).

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

```mermaid
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:

```sql
-- 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_URL` environment 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.ts` schema)
- [ ] Verify all CHECK constraints are active
- [ ] Verify all foreign key constraints are active
- [ ] Test `initDb()` function with `DATABASE_URL` set

### Data Migration

- [ ] Export SQLite data using `sqlite3 drop.db .dump` or custom export script
- [ ] Transform `INTEGER PRIMARY KEY AUTOINCREMENT` to `SERIAL`
- [ ] Transform `randomblob()` defaults to `gen_random_bytes()`
- [ ] Load data into PostgreSQL
- [ ] Reset `exchange_rates_id_seq` sequence
- [ ] 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_URL` set
- [ ] Verify `GET /api/health` returns `db: "connected"` with acceptable latency

### Cutover

- [ ] Set `DATABASE_URL` in 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

```mermaid
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:

```mermaid
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:

1. **Provision shadow PG:** Same version and configuration as production target
2. **Run schema creation:** Execute PostgreSQL DDL
3. **Load production-like data:** Export SQLite demo data, transform, load
4. **Run test suite:** `DATABASE_URL=<shadow> npm test`
5. **Run integration tests:** Full API flow tests against shadow
6. **Load testing:** Verify query performance under expected load

### Data Integrity Checks

```sql
-- 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](../../backend/DATABASE-SCHEMA.md)
- **Database design:** [database-design.md](database-design.md)
- **Data architecture:** [data-architecture.md](../hld/data-architecture.md)
- **Deployment architecture:** [deployment-architecture.md](../hld/deployment-architecture.md)
- **Roadmap Phase 2:** [ROADMAP.md](../../../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

```mermaid
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

1. **Daily job:** Identify records eligible for archival (past active retention period)
2. **Export:** Write eligible records to archive storage (S3 with server-side encryption)
3. **Verify:** Confirm archive integrity (checksum comparison)
4. **Remove from active:** Delete from primary database
5. **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:

```mermaid
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

```mermaid
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

```sql
-- 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:

1. **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
2. **Session cleanup**: Deletes expired sessions older than 90 days
3. **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](../../backend/DATABASE-SCHEMA.md)
- **Database design:** [database-design.md](database-design.md)
- **Audit architecture:** [audit-architecture.md](audit-architecture.md)
- **Compliance status:** [COMPLIANCE.md](../../security/COMPLIANCE.md)
- **Security architecture:** [SECURITY-ARCHITECTURE.md](../../security/SECURITY-ARCHITECTURE.md)
- **GDPR API endpoints:** [API-REFERENCE.md](../../backend/API-REFERENCE.md) (GDPR & Compliance section)
- **Account deletion:** `DELETE /api/user/account` in API-REFERENCE.md
- **Data export:** `GET /api/user/data-export` in 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

```sql
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

```mermaid
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]
```

```mermaid
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:

```mermaid
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`:

```sql
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](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](../../backend/DATABASE-SCHEMA.md) (audit_log section)
- **Data lifecycle:** [data-lifecycle.md](data-lifecycle.md) (retention periods)
- **Indexing strategy:** [indexing-strategy.md](indexing-strategy.md) (audit log indexes)
- **Compliance status:** [COMPLIANCE.md](../../security/COMPLIANCE.md)
- **Security architecture:** [SECURITY-ARCHITECTURE.md](../../security/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)

```mermaid
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

```mermaid
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

```typescript
// 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)

```sql
-- 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)

```sql
-- 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)

```sql
-- 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)

```sql
-- 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](../../backend/DATABASE-SCHEMA.md)
- **Database design:** [database-design.md](database-design.md)
- **Audit architecture:** [audit-architecture.md](audit-architecture.md)
- **Data architecture:** [data-architecture.md](../hld/data-architecture.md)
- **Migration strategy:** [migration-strategy.md](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)

```mermaid
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)

```mermaid
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)

```mermaid
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)

```mermaid
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

```mermaid
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) vs `merchant` (gated). Middleware `authMiddleware` + `merchantMiddleware` enforce 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-id` header
- **Levels:** ERROR/WARN sent to Sentry; INFO to CloudWatch
- **Correlation IDs:** `x-request-id` generated 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_limits` table 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_SECRET` has 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](../architecture/adr/ADR-001-consolidate-backends.md) | Consolidate to single Hono backend (remove dual middleware) | Accepted | 2026-02-12 |
| [ADR-003](../architecture/adr/ADR-003-psd2-pass-through.md) | Adopt PSD2 pass-through model — no wallet, no held funds | Accepted | 2026-02-12 |
| [ADR-004](../architecture/adr/ADR-004-jwt-httponly-cookies.md) | JWT in httpOnly cookies (web) + Bearer tokens (mobile) | Accepted | 2026-02-12 |
| [ADR-005](../architecture/adr/ADR-005-monolith-first.md) | Monolith-first architecture — extract microservices when team/scale demands | Accepted | 2026-02-21 |
| [ADR-006](../architecture/adr/ADR-006-sqlite-to-postgresql.md) | Dual-driver DB abstraction: SQLite (dev) / PostgreSQL (prod) | Accepted | 2026-02-21 |
| [ADR-007](../architecture/adr/ADR-007-bankid-oidc-auth.md) | BankID as sole identity provider (email/password removed) | Accepted | 2026-02-21 |
| [ADR-008](../architecture/adr/ADR-008-hono-api-framework.md) | Hono v4 as the API framework | Accepted | 2026-02-21 |
| [ADR-012](../architecture/adr/ADR-012-aws-app-runner-deploy.md) | 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](./hld.md)

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

---

## 1. Module Overview

<!-- GUIDANCE: Clearly state what this module/component is responsible for. What business function does it serve? What are its boundaries — what does it own and what does it NOT own? -->

**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:**
1. {{BUSINESS_RULE_1}}
2. {{BUSINESS_RULE_2}}
3. {{BUSINESS_RULE_3}}

---

## 2. Class / Module Diagram

<!-- GUIDANCE: Show the internal structure of this module. Include classes, interfaces, enums, and their relationships. Use classDiagram notation. -->

```mermaid
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

<!-- GUIDANCE: Define every table/collection this module owns. Be precise about types, nullability, defaults, constraints. Include all indexes with rationale. -->

### 3.1 Tables

#### `{{TABLE_NAME_1}}`

<!-- GUIDANCE: Describe the purpose of this table. -->
**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

```sql
CREATE TYPE {{ENUM_NAME}} AS ENUM (
    '{{VALUE_1}}',
    '{{VALUE_2}}',
    '{{VALUE_3}}'
);
```

### 3.3 Migration Notes

<!-- GUIDANCE: Note any migration concerns — zero-downtime requirements, backfills, index creation strategy. -->

- Migration file: `{{MIGRATION_FILE_NAME}}.sql`
- Zero-downtime: {{YES_NO}} — {{NOTES}}
- Backfill required: {{YES_NO}} — {{BACKFILL_STRATEGY}}
- Estimated migration time: {{ESTIMATE}}

---

## 4. API Contract

<!-- GUIDANCE: Define every endpoint this module exposes. Be complete — include all error codes, not just success cases. -->

**Base Path:** `/api/v{{VERSION}}/{{RESOURCE}}`

### `POST /{{resource}}`

**Summary:** Create a new {{entity}}

**Authentication:** Bearer JWT required

**Request Body:**
```json
{
  "{{field1}}": "{{type_and_example}}",
  "{{field2}}": "{{type_and_example}}"
}
```

**Success Response — `201 Created`:**
```json
{
  "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`:**
```json
{
  "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`:**
```json
{
  "data": [],
  "pagination": {
    "page": 1,
    "limit": 20,
    "total": 0,
    "totalPages": 0
  }
}
```

---

## 5. Algorithm Specifications

<!-- GUIDANCE: For non-trivial algorithms, write pseudocode. This is critical for: pagination cursors, search ranking, pricing calculations, state machine transitions, data transformations. -->

### 5.1 {{ALGORITHM_NAME}}

**Purpose:** {{WHAT_IT_DOES}}
**Complexity:** Time O({{TIME_COMPLEXITY}}) | Space O({{SPACE_COMPLEXITY}})

```pseudocode
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

<!-- GUIDANCE: Show the interaction between components for the key user flows. One diagram per major flow. -->

### 6.1 Create {{Entity}} Flow

```mermaid
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

```mermaid
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

<!-- GUIDANCE: For entities with complex lifecycle states, document state transitions. Include every valid transition and the trigger. -->

```mermaid
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

<!-- GUIDANCE: How does this module handle errors? Define error types, recovery strategies, and what gets logged/alerted. -->

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

```json
{
  "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

<!-- GUIDANCE: Identify any shared state, concurrent modification risks, or race conditions. Document the mitigation for each. -->

| 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

<!-- GUIDANCE: What are the performance targets for this module? What optimizations are in place? What are the known bottlenecks? -->

| 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

<!-- GUIDANCE: List every internal and external dependency this module requires. Note the version and what happens if the dependency is unavailable. -->

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

<!-- GUIDANCE: Every environment variable or configuration value this module reads. Include type, default, and whether it's required. -->

| 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

<!-- GUIDANCE: How is this module tested? List test types, tools, and coverage targets. -->

| 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}}](./ADR-{{NEW_NUMBER}}.md)
> **Reviewers:** {{REVIEWERS}}

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

---

## ADR Numbering Scheme

<!-- GUIDANCE: ADRs are numbered sequentially per project: ADR-001, ADR-002, etc. Never reuse numbers. Use leading zeros for up to 3 digits. Store in: docs/architecture/decisions/ or ARCHITECTURE/adr/. -->

**Convention:** `ADR-{NNN}-{short-slug}.md` — e.g., `ADR-001-use-postgresql.md`

---

## 1. Context

<!-- GUIDANCE: Describe the situation that forces this decision. Include: technical constraints, business requirements, team context, existing decisions that constrain options. Be factual, not prescriptive. Do NOT state the decision here — only the forces that require a decision. -->

### 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}}](./ADR-{{PARENT_NUMBER}}.md): {{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

<!-- GUIDANCE: State the decision clearly and unambiguously. One or two sentences. Start with "We will..." -->

**We will:** {{CLEAR_DECISION_STATEMENT}}

**Rationale (summary):** {{ONE_PARAGRAPH_WHY}}

---

## 3. Alternatives Considered

<!-- GUIDANCE: Minimum 2 alternatives (not including the chosen option). Be intellectually honest — include the genuine pros and cons of each. A weak alternatives section undermines trust in the decision. -->

### Option A: {{OPTION_A_NAME}} ← Selected

<!-- GUIDANCE: Include the chosen option in the alternatives for comparison. -->

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

<!-- GUIDANCE: Honest assessment of what this decision entails — positive, negative, and neutral. Consider: code changes required, operational changes, team impact, future flexibility. -->

### 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
<!-- GUIDANCE: Be honest about shortcuts or limitations introduced. -->
- {{DEBT_ITEM}}: {{PLAN_TO_ADDRESS}}
- *Acceptable because:* {{REASON}}

---

## 5. Compliance Impact

<!-- GUIDANCE: Does this decision affect regulatory compliance? GDPR, HIPAA, PCI-DSS, SOC2, etc. -->

| 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

<!-- GUIDANCE: Quantify where possible. Use measurements from benchmarks or production data from similar systems. -->

| 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

<!-- GUIDANCE: How do we get from current state to this decision? What needs to change? What's the rollout plan? -->

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

<!-- GUIDANCE: Use feature flags for risky migrations to allow gradual rollout and instant rollback. -->

| Flag | Purpose | Default |
|------|---------|---------|
| `{{FLAG_NAME}}` | {{PURPOSE}} | `{{DEFAULT_VALUE}}` |

---

## 8. Related ADRs

<!-- GUIDANCE: Link to ADRs that influenced this decision or that this decision affects. -->

| ADR | Relationship | Notes |
|-----|-------------|-------|
| [ADR-{{N}}](./ADR-{{N}}.md) | Prerequisite | {{HOW_IT_RELATES}} |
| [ADR-{{N}}](./ADR-{{N}}.md) | Supersedes | This decision replaces {{OLD_DECISION}} |
| [ADR-{{N}}](./ADR-{{N}}.md) | Affected | This decision impacts {{HOW}} |

---

## 9. Review Date

<!-- GUIDANCE: ADRs should be reviewed if the context changes significantly. Set a review date, especially for decisions with high uncertainty. -->

**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 `auth` module (`routes/auth.ts`, `lib/bankid.ts`)
- KYC/AML status — owned by `compliance` module (`routes/user.ts` + Sumsub integration)
- Bank account linking (AISP consent) — owned by `banking` module (`routes/bank-accounts.ts`)
- Merchant registration — owned by `merchants` module (`routes/merchants.ts`)
- Notifications delivery — owned by `notifications` module (`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

```typescript
// 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.ts` functions (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:**
```sql
-- 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:**
```json
{
  "recipientId": "rec_abc123def456gh78",
  "amount": 2000,
  "bankAccountId": "ba_abc123def456gh78",
  "currency": "NOK"
}
```

**Validation:**
- `recipientId`: required, string, must exist in `recipients` WHERE `user_id = currentUser`
- `amount`: required, number, 100 ≤ amount ≤ 50000
- `bankAccountId`: required, string, must exist in `bank_accounts` WHERE `user_id = currentUser`

**Success `201`:**
```json
{
  "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:**
```json
{
  "type": "remittance",
  "amount": 2000,
  "recipientId": "rec_abc123def456gh78"
}
```

**Success `200`:**
```json
{
  "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)

```json
{
  "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

```mermaid
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

```mermaid
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:**
```http
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:**
```http
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`:**
```json
{
  "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:**
```http
Authorization: Bearer {NEONOMICS_ACCESS_TOKEN}
X-Request-ID: {UUID}
X-Consent-ID: {consentId}
Content-Type: application/json
```

**Successful Response `200`:**
```json
{
  "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:**
```http
Authorization: Bearer {NEONOMICS_ACCESS_TOKEN}
Content-Type: application/json
X-Request-ID: {idempotency_key}
X-Consent-ID: {pisConsentId}
```

**Request Body:**
```json
{
  "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`:**
```json
{
  "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:**
```http
Authorization: Bearer {SUMSUB_APP_TOKEN}
Content-Type: application/json
X-Request-ID: {UUID}
```

**Request Body:**
```json
{
  "externalUserId": "usr_abc123def456gh78",
  "email": "usr_abc123@bankid.drop.local",
  "levelName": "basic-kyc-level"
}
```

**Successful Response `201`:**
```json
{
  "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:**
```json
{
  "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`

```json
{
  "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

```mermaid
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:**
- [x] BankID login → JWT issued → dashboard loads
- [x] 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:**
- [x] BankID auth cancelled → 400 `bankid_cancelled`
- [x] User under 18 → 403 `underage`
- [x] Sumsub webhook with invalid HMAC → 401 (rejected)
- [x] 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-id` header 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

<!-- GUIDANCE: Provide a high-level overview of how data moves through the system. What enters, what gets transformed, where it's stored, and where it exits. This is a critical document for GDPR DPIAs and security reviews. -->

**System:** {{SYSTEM_NAME}}
**Data Owner:** {{DATA_OWNER_ROLE}}
**DPO Contact:** {{DPO_EMAIL}}

**Overview:** {{DESCRIBE_WHAT_DATA_FLOWS_THROUGH_SYSTEM}}

### High-Level Data Flow

```mermaid
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

<!-- GUIDANCE: List every source of data entering the system. Include volume estimates and ingestion method. -->

| 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

<!-- GUIDANCE: Document every transformation applied to data between ingestion and storage. ETL = Extract, Transform, Load. ELT = Extract, Load, Transform (in the data warehouse). -->

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

```mermaid
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

<!-- GUIDANCE: Where is data stored at rest? Include all storage systems — primary DB, cache, object storage, backups, analytics. -->

| 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

<!-- GUIDANCE: Who accesses what data, how, and how often? This informs index design, caching, and access control. -->

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

<!-- GUIDANCE: Define retention periods per data type. This is legally required for GDPR compliance. Every data category must have a documented retention period and deletion/archival process. -->

| 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

<!-- GUIDANCE: Define the quality checks that run on data. Poor data quality causes bad decisions and compliance issues. -->

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

<!-- GUIDANCE: Critical for GDPR compliance. Map exactly where PII is stored, processed, and shared. This feeds directly into the DPIA. -->

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

```mermaid
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

<!-- GUIDANCE: GDPR restricts transfer of EU personal data to non-adequate countries. Document every transfer and the legal mechanism. -->

| 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

<!-- GUIDANCE: For compliance and debugging, you need to trace where data came from and where it went. -->

**Lineage tool:** {{LINEAGE_TOOL}} (e.g., Apache Atlas, DataHub, custom)
**Coverage:** Primary DB + DW

### Lineage Events Captured

```json
{
  "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

<!-- GUIDANCE: Define backup strategy per storage system. Include RTO and RPO targets. -->

| 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

<!-- GUIDANCE: Describe what this API does, who the consumers are, and the core design philosophy. -->

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

<!-- GUIDANCE: Define how breaking changes are handled. Consumers must be notified before breaking changes. -->

**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 `Deprecation` and `Sunset` headers
- Minimum {{DEPRECATION_PERIOD}} notice before removing deprecated endpoints
- Deprecation notices sent to: {{NOTIFICATION_CHANNEL}}

**Sunset Header Example:**
```http
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

<!-- GUIDANCE: Define every auth mechanism supported. Include token lifetime, refresh strategy, and scope definitions. -->

### 3.1 Authentication Methods

**Primary:** Bearer JWT (OAuth2 / OIDC)

```http
Authorization: Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...
```

**JWT Claims:**
```json
{
  "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:**
```http
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)

```http
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

<!-- GUIDANCE: Define standard headers that apply to all requests and responses. -->

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

<!-- GUIDANCE: All errors MUST use the RFC 7807 Problem Details format. Consistent error format makes client integration dramatically easier. -->

```json
{
  "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

<!-- GUIDANCE: Consistent pagination across all list endpoints. Cursor-based pagination preferred for large datasets. -->

**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:**
```json
{
  "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:**
```json
{
  "data": [],
  "pagination": {
    "page": 1,
    "limit": 20,
    "total": 500,
    "totalPages": 25
  }
}
```

**Limits:** Minimum 1, Maximum 100 items per request.

---

## 7. Rate Limiting

<!-- GUIDANCE: Define rate limits per tier. Include the headers used and the action on exceeding limits. -->

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

<!-- GUIDANCE: Document every endpoint. Use this format consistently. Add one section per endpoint group (resource). -->

### Resource: {{Resource Name}}

#### `POST /{{resource}}`

**Summary:** Create a new {{entity}}
**Auth:** Required | Scope: `{{resource}}:write`
**Idempotency:** Required — provide `X-Idempotency-Key`

**Request:**
```http
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`:**
```json
{
  "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`:**
```json
{
  "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`:**
```json
{
  "data": [
    { "id": "...", "{{field1}}": "..." }
  ],
  "pagination": {
    "hasNextPage": true,
    "endCursor": "{{CURSOR}}"
  }
}
```

---

## 9. Webhook Documentation

<!-- GUIDANCE: If the API sends webhooks, document the format, events, and verification mechanism. -->

### 9.1 Webhook Configuration

**Register endpoint:** `POST /webhooks`
**Test endpoint:** `POST /webhooks/{id}/test`
**Signature verification:** HMAC-SHA256

### 9.2 Signature Verification

```javascript
// 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

<!-- GUIDANCE: Embed the machine-readable OpenAPI spec. Keep in sync with the human-readable documentation above. Tools like Swagger UI, Redoc, and Stoplight can render this. -->

```yaml
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

<!-- GUIDANCE: If SDKs are generated from the OpenAPI spec, document the process and any customizations. -->

**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:**
```bash
openapi-generator-cli generate \
  -i ./api-specification.yaml \
  -g typescript-fetch \
  -o ./sdk/typescript \
  --additional-properties=npmName=@{{org}}/{{sdk-name}}
```

---

## 12. API Changelog

<!-- GUIDANCE: Log every API change here. Consumers rely on this to understand what changed and when. -->

| 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/alai-db-standards.md) | DATABASE/ | Master naming conventions, type standards, column defaults |
| [indexing-strategy.md](../DATABASE/indexing-strategy.md) | DATABASE/ | Index design guide — B-tree, GIN, partial, composite |
| [migration-strategy.md](../DATABASE/migration-strategy.md) | DATABASE/ | Migration tooling, zero-downtime patterns, rollback procedures |
| [rls-policy-guide.md](../DATABASE/rls-policy-guide.md) | DATABASE/ | Row Level Security for multi-tenant schemas |
| [connection-pooling.md](../DATABASE/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

<!-- GUIDANCE: Specify the exact database technology and version. This affects available data types, functions, and features. -->

| 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

<!-- GUIDANCE: Show all entities and their relationships. Use mermaid erDiagram notation. Update this diagram whenever schema changes. -->

```mermaid
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

<!-- GUIDANCE: Establish naming conventions before defining tables. Consistency prevents confusion and enables tooling. -->

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

<!-- GUIDANCE: Group tables by bounded context / domain. One section per domain. Include all columns, constraints, and indexes. -->

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

```sql
-- 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` | | |

```sql
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

<!-- GUIDANCE: List all enum types and reference/lookup tables. Enums are best for stable values; lookup tables for values that may change or require metadata. -->

### 5.1 Enum Types

```sql
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

<!-- GUIDANCE: Use lookup tables when enum values need metadata (labels, descriptions, ordering) or when they change frequently. -->

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

<!-- GUIDANCE: Document all views. Views simplify complex queries but can hide performance issues. Materialized views trade freshness for performance. -->

### 6.1 Views

#### View: `active_{{entities}}`

**Purpose:** Filter out soft-deleted records for common queries
**Refreshed:** N/A (standard view)

```sql
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}}

```sql
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

<!-- GUIDANCE: Minimize stored procedures — prefer application-layer logic. Document any that exist. -->

### `updated_at_trigger()`

**Purpose:** Auto-update `updated_at` column on any row update

```sql
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

<!-- GUIDANCE: Database migrations must be safe to run in production without downtime. Always test on staging first. -->

**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 `CONCURRENTLY` for 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

<!-- GUIDANCE: What data must exist for the application to function? Separate from test fixtures. -->

### 9.1 Required Seed Data (production)

```sql
-- 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

<!-- GUIDANCE: Document performance-sensitive design choices. These decisions are hard to change later. -->

### 10.1 Partitioning

<!-- GUIDANCE: Partition large tables by range (time-series data) or list (tenant isolation). -->

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

```sql
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

<!-- GUIDANCE: Database backups are critical. Document what, when, where, and how to restore. -->

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

```bash
# 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

<!-- GUIDANCE: 2-4 paragraphs. What is this system? What problem does it solve? Who are the users? What are the primary business outcomes? Keep technical jargon minimal — this section is for stakeholders and decision-makers. -->

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

<!-- GUIDANCE: Show the system as a black box. What external users/systems interact with it? Use C4 Model notation. Update the Mermaid diagram below. -->

```mermaid
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)

<!-- GUIDANCE: Break the system into deployable units (containers). Each container is a separately deployable/runnable thing. Include: web apps, APIs, databases, message queues, cache layers. -->

```mermaid
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

<!-- GUIDANCE: List the major logical components/services. For microservices, each service is a component. For monoliths, list major modules. One row per component. -->

| 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}}
<!-- GUIDANCE: 3-5 sentences on what this component does, its key interfaces, and why it exists as a separate component. -->

**Responsibility:** {{DETAILED_RESPONSIBILITY}}
**Key Interfaces:** {{INTERFACE_DESCRIPTION}}
**Rationale:** {{WHY_SEPARATE}}

---

## 5. Technology Stack

<!-- GUIDANCE: List every technology decision. Include version and rationale for each choice. This is a reference for developers and reviewers. -->

| 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

<!-- GUIDANCE: Show primary data flows through the system. Focus on the "happy path" of the most important user journeys. Use separate diagrams for read path and write path if complex. -->

### 6.1 Primary Write Flow

```mermaid
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

```mermaid
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

<!-- GUIDANCE: List all external system integrations. For each: who initiates, protocol, authentication, data exchanged, and SLA expectations. -->

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

<!-- GUIDANCE: Fill in if this is a service in a larger ecosystem. -->

| Service | Integration Type | Protocol | Notes |
|---------|-----------------|----------|-------|
| {{INTERNAL_SERVICE_1}} | Synchronous | REST | {{NOTES}} |
| {{INTERNAL_SERVICE_2}} | Asynchronous | Events | {{NOTES}} |

---

## 8. Deployment Overview

<!-- GUIDANCE: Show how containers map to infrastructure. Include environments (dev, staging, prod). Show load balancers, CDN, cloud regions. -->

```mermaid
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

<!-- GUIDANCE: These concerns apply system-wide. Be specific about implementation choices. -->

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

<!-- GUIDANCE: List the quality attributes (non-functional requirements) and the architectural decisions made to achieve them. Be honest about 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

<!-- GUIDANCE: Brief summary of major decisions. Link to full ADRs in the ARCHITECTURE/adr/ directory. -->

| ADR | Decision | Status | Date |
|-----|---------|--------|------|
| [ADR-001](./adr/ADR-001-{{SLUG}}.md) | {{DECISION_SUMMARY_1}} | Accepted | {{DATE}} |
| [ADR-002](./adr/ADR-002-{{SLUG}}.md) | {{DECISION_SUMMARY_2}} | Accepted | {{DATE}} |
| [ADR-003](./adr/ADR-003-{{SLUG}}.md) | {{DECISION_SUMMARY_3}} | Proposed | {{DATE}} |

---

## 12. Constraints & Assumptions

<!-- GUIDANCE: Constraints are things you CANNOT change (regulations, existing systems, budget). Assumptions are things you believe to be true but haven't verified. Both affect architectural decisions. -->

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

<!-- GUIDANCE: Architectural risks that could undermine the system. Focus on systemic risks, not feature bugs. Rate likelihood and impact 1-5. -->

| 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

<!-- GUIDANCE: Clearly define the module's single responsibility. A module should do one thing well. If you need "and" to describe it, consider splitting. State what it owns, what it doesn't own, and why it exists as a separate module. -->

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

<!-- GUIDANCE: This is the contract that other modules/services depend on. Changes here require coordination. Be precise. -->

### 2.1 Exported Service Interface

```typescript
// 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

<!-- GUIDANCE: Events are part of the public interface. Once published, they're consumed by others — treat event schema changes as breaking changes. -->

| 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

<!-- GUIDANCE: Show how the module is organized internally. Use the layer diagram to clarify separation of concerns. -->

```
{{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

<!-- GUIDANCE: Tables owned by this module only. Cross-module tables should be avoided — use events for cross-module data sync. -->

### 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:**
```sql
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):**
<!-- GUIDANCE: Enable RLS if multi-tenant or if different users should see different rows at DB level. -->
```sql
-- 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)

<!-- GUIDANCE: For each endpoint, document the complete request and response contract. -->

### `POST /api/v{{V}}/{{resource}}`

**Request:**
```json
{
  "{{field1}}": "string (required, max 255 chars)",
  "{{field2}}": "number (required, > 0)",
  "{{field3}}": "string (optional, enum: [A, B, C])"
}
```

**Success `201`:**
```json
{
  "id": "uuid",
  "{{field1}}": "value",
  "{{field2}}": 0,
  "createdAt": "ISO8601"
}
```

**Event published:** `{{entity}}.created`
```json
{
  "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

<!-- GUIDANCE: Document the business rules that live in the service layer. These should be independent of HTTP or database concerns. -->

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

<!-- GUIDANCE: Events are the module's async API. Document both published and consumed events. -->

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

<!-- GUIDANCE: Upstream = this module depends on them. Downstream = they depend on this module. Circular dependencies are forbidden. -->

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

<!-- GUIDANCE: Define how each error type is handled. Include database errors, external API failures, validation errors. -->

| 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

<!-- GUIDANCE: All configuration this module reads. Feature flags allow gradual rollout without deployments. -->

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

<!-- GUIDANCE: How do we know this module is healthy? What metrics indicate problems? -->

### Health Check Endpoint

`GET /health/{{module-name}}`

```json
{
  "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

<!-- GUIDANCE: Show the create flow end-to-end through this module's layers. -->

```mermaid
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

<!-- GUIDANCE: Explain the business need for this integration. What business process does it enable? Who are the owners on both sides? What would break if this integration failed? -->

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

<!-- GUIDANCE: Show all services/systems involved. For complex integrations, show the message flow between all parties. -->

```mermaid
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

<!-- GUIDANCE: Define the exact contract for each integration point. This is a binding agreement between teams. -->

### 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 | API Key | OAuth2 Client Credentials | mTLS |
| **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:**
```http
Authorization: Bearer {{JWT_OR_API_KEY}}
Content-Type: application/json
Accept: application/json
X-Request-ID: {{UUID}}
X-Idempotency-Key: {{IDEMPOTENCY_KEY}}
```

**Request Body:**
```json
{
  "{{field1}}": "{{type}} — {{description}}",
  "{{field2}}": "{{type}} — {{description}}",
  "metadata": {
    "sourceSystem": "{{CONSUMER_SYSTEM_ID}}",
    "timestamp": "ISO8601"
  }
}
```

**Successful Response `200 / 201`:**
```json
{
  "{{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)

<!-- GUIDANCE: Repeat section 3.1 pattern for additional integration points. -->

**Protocol:** gRPC
**Service definition:**
```protobuf
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

<!-- GUIDANCE: For async integrations via message queues or event streaming. Follow CloudEvents spec for event envelopes. -->

### 4.1 Event Schemas (CloudEvents 1.0)

#### Event: `{{entity}}.{{ACTION}}`

**Published by:** `{{PUBLISHER_SYSTEM}}`
**Consumed by:** `{{CONSUMER_SYSTEM_1}}`, `{{CONSUMER_SYSTEM_2}}`

```json
{
  "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

<!-- GUIDANCE: Event consumers MUST be idempotent — events can be delivered more than once. -->

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

<!-- GUIDANCE: Distributed systems cannot guarantee ACID across services. Document the consistency model and the pattern used to achieve it. -->

### 5.1 Consistency Model

**Model:** Strong | Eventual | Causal
**Acceptable lag:** {{MAX_LAG_SECONDS}}s

### 5.2 Saga Pattern (if used for distributed transactions)

```mermaid
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

<!-- GUIDANCE: How do we verify the integration works? What tests run in CI? What runs in staging? -->

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

<!-- GUIDANCE: Define what observability is in place for this integration. -->

### 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-ID` and `traceparent` headers 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 | | | |