Architecture

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

Overview

Architecture documentation index

Overview

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) -- Drop and all external actors (banks, BankID, regulators)
  2. Security Architecture -- Security controls and threat model
  3. ADR-003: PSD2 Pass-through Model -- Why Drop never holds money

Architect Track

For a complete understanding of system design and key decisions.

  1. High-Level Design (C4)
  2. Cross-Cutting Concerns
  3. Architecture Decision Records
  4. Integration Specifications

Developer Track

For implementing features and understanding code flows.

  1. Low-Level Designs (User Flows)
  2. Database
  3. Key ADRs for Developers

Compliance Track

For regulatory compliance review and audit preparation.

  1. Security Architecture -- Authentication, authorization, data protection
  2. KYC/AML Flow -- Customer due diligence, transaction monitoring, STR filing
  3. Data Lifecycle -- GDPR retention policies, right to erasure
  4. Audit Architecture -- Audit trail for regulatory reporting
  5. Registration & Onboarding -- Consent collection, age verification
  6. ADR-003: PSD2 Pass-through -- Regulatory model decision
  7. ADR-007: BankID OIDC Auth -- SCA compliance

Document Index

High-Level Design (HLD)

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

Architecture Decision Records (ADR)

ADR Title Category Status
ADR-001 Consolidate to Single Backend Architecture Accepted
ADR-002 Separate FontelePay from Drop Architecture Accepted
ADR-003 PSD2 Pass-through Model (No Wallet) Architecture Accepted
ADR-004 JWT in httpOnly Cookies Security Accepted
ADR-005 Monolith-First Architecture Architecture Accepted
ADR-006 SQLite Dev, PostgreSQL Prod Database Superseded by ADR-014
ADR-007 BankID as Sole Auth Provider Security Accepted
ADR-008 Hono v4 for Mobile API Backend Accepted
ADR-009 Custom Feature Flag System Backend Accepted
ADR-010 Dual Database Driver Abstraction Database Superseded by ADR-014
ADR-011 Expo SDK 54 for Mobile Mobile Accepted
ADR-012 AWS App Runner Deployment Infrastructure Accepted
ADR-014 PostgreSQL-Only Architecture (Drizzle ORM) Database Accepted

Integration Specifications

Document Description External System
Open Banking AISP/PISP PSD2 account access and payment initiation Nordic banks (DNB, SpareBank1, Nordea)
BankID OIDC Integration Norwegian eID authentication and SCA BankID Norge
Sumsub KYC Integration Document verification and screening Sumsub
Payment Processing Remittance and QR payment orchestration Banks, SEPA, SWIFT
Sentry Observability Error tracking and performance monitoring Sentry

Low-Level Design (LLD) -- User Flows

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

Database Architecture

Document Description
Database Design Schema rationale, entity relationships, 19-table overview
Migration Strategy Historical SQLite to PostgreSQL migration record (completed; see ADR-014)
Data Lifecycle GDPR retention, AML record keeping, deletion policies
Audit Architecture audit_log table design and compliance trail
Indexing Strategy Performance indexes for queries, compliance, and reporting

Document Location Description
Main Architecture Document project/architecture/ Original architecture overview (v1.1)
API Reference docs/backend/ All 24+ API endpoints
Database Schema docs/backend/ Full table definitions
Authentication System docs/backend/ BankID OIDC implementation
Middleware docs/backend/ Auth, rate limiting, validation
Feature Flags docs/backend/ Runtime feature toggles
Security Architecture docs/security/ Security controls reference
Compliance Status docs/security/ Regulatory compliance tracking
Roadmap Root Product roadmap and phases

Architecture Principles

  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

High-Level Design (HLD)

System Context

System Context Diagram (C4 Level 1)

Document: HLD-001 Status: Approved Last updated: 2026-02-21 Author: Standards Architect Applies to: Drop v1.0 (PSD2 pass-through model)


Overview

This document describes the C4 Level 1 system context for Drop, showing Drop as the central system and all external actors, systems, and regulatory bodies it interacts with. Drop operates as a PSD2 pass-through payment application -- it never holds customer funds. User money remains in their bank account at all times.


System Context Diagram

graph TB
    subgraph actors["External Actors"]
        sender["Sender<br/>(Norwegian Resident, 18+)<br/>Sends money abroad via PISP"]
        receiver["Receiver<br/>(30+ countries)<br/>Receives remittance"]
        merchant["Merchant<br/>(Norwegian Business)<br/>Accepts QR payments"]
    end

    subgraph drop_system["Drop Payment System"]
        drop["Drop<br/>Next.js 15 + Hono v4<br/>PSD2 Pass-through App<br/>(AISP + PISP)"]
    end

    subgraph banking["Banking & Open Banking"]
        bankid["BankID Norway<br/>OIDC Identity Provider<br/>Strong Customer Authentication"]
        nordic_banks["Nordic Banks<br/>(DNB, SpareBank1, Nordea)<br/>Open Banking APIs<br/>AISP: Read balance<br/>PISP: Initiate payment"]
        payment_rails["Payment Rails<br/>SEPA (EEA)<br/>SWIFT (non-EEA)<br/>Remittance corridors"]
    end

    subgraph compliance["Compliance & KYC"]
        sumsub["Sumsub<br/>KYC/AML Provider<br/>Document verification<br/>PEP/sanctions screening"]
        finanstilsynet["Finanstilsynet<br/>Norwegian FSA<br/>PISP/AISP registration<br/>Regulatory oversight"]
        okokrim["Okokrim / EFE<br/>Financial Intelligence Unit<br/>STR/SAR filing"]
    end

    subgraph infrastructure["Infrastructure"]
        aws["AWS App Runner<br/>Container hosting<br/>Auto-scaling"]
        cloudflare["Cloudflare<br/>CDN, DDoS protection<br/>DNS, TLS termination"]
        sentry["Sentry<br/>Error tracking<br/>Performance monitoring"]
    end

    %% Actor interactions
    sender -->|"BankID login\nView balance (AISP)\nSend money (PISP)\nQR payments"| drop
    receiver -.->|"Receives funds\n(via bank transfer)"| payment_rails
    merchant -->|"Register business\nView dashboard\nGenerate QR code"| drop

    %% Banking integrations
    drop -->|"OIDC authorize\nID token verification\nAge/identity check"| bankid
    drop -->|"AISP: GET /accounts\nAISP: GET /balances\nPISP: POST /payments"| nordic_banks
    drop -->|"PISP payment routing\nSEPA for EEA\nSWIFT for non-EEA"| payment_rails

    %% Compliance integrations
    drop -->|"Applicant creation\nDocument upload\nWebhook results"| sumsub
    drop -.->|"License registration\nRegulatory reporting\nCompliance audits"| finanstilsynet
    drop -.->|"STR filing\n(hvitvaskingsloven)"| okokrim

    %% Infrastructure
    drop -->|"Deploy containers\nAuto-scale"| aws
    drop -->|"DNS routing\nTLS, WAF\nDDoS protection"| cloudflare
    drop -->|"Error events\nPerformance traces"| sentry

    %% Bank to payment rails
    nordic_banks -->|"Execute transfers"| payment_rails

    classDef actorStyle fill:#E3F2FD,stroke:#1565C0,stroke-width:2px,color:#0D47A1
    classDef systemStyle fill:#0B6E35,stroke:#064E25,stroke-width:3px,color:#FFFFFF
    classDef bankingStyle fill:#FFF3E0,stroke:#E65100,stroke-width:2px,color:#BF360C
    classDef complianceStyle fill:#FCE4EC,stroke:#C62828,stroke-width:2px,color:#B71C1C
    classDef infraStyle fill:#F3E5F5,stroke:#6A1B9A,stroke-width:2px,color:#4A148C

    class sender,receiver,merchant actorStyle
    class drop systemStyle
    class bankid,nordic_banks,payment_rails bankingStyle
    class sumsub,finanstilsynet,okokrim complianceStyle
    class aws,cloudflare,sentry infraStyle

Trust Boundaries

graph TB
    subgraph tb_user["TRUST BOUNDARY: User Device (Untrusted)"]
        browser["Web Browser<br/>(Next.js SSR + CSR)"]
        mobile["Mobile App<br/>(Expo SDK 54)"]
    end

    subgraph tb_drop["TRUST BOUNDARY: Drop Application (Controlled)"]
        subgraph dmz["DMZ — Edge"]
            cf["Cloudflare<br/>WAF + CDN + DDoS"]
        end
        subgraph app["Application Layer"]
            nextjs["Next.js BFF<br/>Web auth, SSR"]
            hono["Hono API<br/>Mobile auth, REST"]
        end
        subgraph data["Data Layer"]
            pg["PostgreSQL<br/>(production)"]
            sqlite["SQLite<br/>(development)"]
        end
    end

    subgraph tb_banking["TRUST BOUNDARY: Banking Partners (External Trusted)"]
        bankid_tb["BankID OIDC"]
        openbanking["Open Banking APIs"]
    end

    subgraph tb_compliance["TRUST BOUNDARY: Compliance Partners (External Trusted)"]
        sumsub_tb["Sumsub KYC"]
    end

    subgraph tb_regulator["TRUST BOUNDARY: Regulatory (Government)"]
        fsa["Finanstilsynet"]
        efe["Okokrim / EFE"]
    end

    browser --> cf
    mobile --> cf
    cf --> nextjs
    cf --> hono
    nextjs --> pg
    nextjs --> sqlite
    hono --> pg
    hono --> sqlite
    nextjs --> bankid_tb
    hono --> bankid_tb
    nextjs --> openbanking
    hono --> openbanking
    nextjs --> sumsub_tb
    hono --> sumsub_tb
    nextjs -.-> fsa
    nextjs -.-> efe

    classDef untrusted fill:#FFCDD2,stroke:#C62828,stroke-width:2px
    classDef controlled fill:#C8E6C9,stroke:#2E7D32,stroke-width:2px
    classDef external fill:#FFF9C4,stroke:#F9A825,stroke-width:2px
    classDef regulator fill:#E1BEE7,stroke:#6A1B9A,stroke-width:2px

    class browser,mobile untrusted
    class cf,nextjs,hono,pg,sqlite controlled
    class bankid_tb,openbanking,sumsub_tb external
    class fsa,efe regulator

External Actors

End Users

Actor Description Authentication Data Exchanged
Sender Norwegian resident (18+) who sends money abroad or pays merchants via QR BankID OIDC (SCA) Personal data, bank account info (AISP), payment instructions (PISP)
Receiver Person in 30+ countries who receives remittance None (indirect) Receives bank transfer via payment rails
Merchant Norwegian business accepting QR payments BankID OIDC + merchant registration Business details, org number, transaction data, payout info

Banking & Payment Systems

System Protocol Data Flow Trust Level
BankID Norway OIDC 2.0 (authorize, token, JWKS endpoints) ID tokens with pid (national ID), name, DOB High -- Norwegian government-backed eID
Nordic Banks (DNB, SpareBank1, Nordea) PSD2 Open Banking REST APIs AISP: account list, balances, transactions; PISP: payment initiation, status High -- regulated financial institutions
SEPA (Single Euro Payments Area) SEPA Credit Transfer (SCT) EEA remittance transfers (1-2 business days) High -- ECB-regulated
SWIFT SWIFT gpi Non-EEA remittance transfers (2-4 business days) High -- SWIFT-regulated

Compliance & Regulatory

System Integration Data Flow Cadence
Sumsub REST API + Webhooks Applicant data, document images, verification results, PEP/sanctions matches On registration + ongoing monitoring
Finanstilsynet Regulatory portal License applications, compliance reports, incident notifications Quarterly + ad hoc
Okokrim / EFE AltInn reporting STR/SAR filings per hvitvaskingsloven As triggered by AML alerts

Infrastructure

System Role Protocol Data Flow
AWS App Runner Container hosting and auto-scaling HTTPS, Docker Application containers, environment variables, logs
Cloudflare Edge security and CDN DNS, HTTPS, WebSocket HTTP traffic, TLS termination, DDoS filtering, WAF rules
Sentry Error tracking and APM HTTPS (SDK) Error events, performance traces, session replays

Compliance Zone Mapping

PSD2 (Betalingstjenesteloven)

Requirement Drop Component External System Status
Strong Customer Authentication (SCA) Auth flow (/api/auth/bankid/) BankID OIDC Implemented
Dynamic linking (amount + payee tied to auth) Payment confirmation screen BankID SCA challenge Phase 2
AISP consent and access Bank account linking flow Nordic bank Open Banking APIs Phase 2
PISP payment initiation Remittance + QR payment flows Nordic bank Open Banking APIs Phase 2
Framework agreement (vilkar) landing/pages/vilkar.html -- Draft exists
Pre-transaction fee disclosure POST /api/transactions/disclosure -- Implemented

GDPR (Personopplysningsloven)

Requirement Drop Component Implementation
Lawful basis for processing consents table Consent tracking with IP + timestamp
Right to access (Art. 15) GET /api/user/data-export Full data export in JSON
Right to erasure (Art. 17) DELETE /api/user/account Soft delete, 5yr AML retention
Data minimization (Art. 5) Schema design Only necessary fields stored
Data portability (Art. 20) GET /api/user/data-export Machine-readable JSON export
Processing register (Art. 30) data_access_requests table Tracks all DSAR requests
DPIA (Art. 35) legal/dpia-vurdering.md Draft completed

AML / KYC (Hvitvaskingsloven)

Requirement Drop Component External System
Customer Due Diligence (CDD) User registration + KYC flow Sumsub (document verification)
Enhanced Due Diligence (EDD) screening_results table Sumsub (PEP/sanctions screening)
Transaction monitoring aml_alerts table Internal rules engine
Suspicious Transaction Reporting str_reports table Okokrim / EFE via AltInn
Record keeping (5 years) All compliance tables PostgreSQL with retention policies
Risk assessment users.risk_level field Sumsub risk scoring

DORA (Digital Operational Resilience Act)

Requirement Drop Component Implementation
ICT risk management legal/ikt-sikkerhetspolicy.md Policy drafted
Incident reporting legal/hendelseshaandtering.md Incident handling procedure
Resilience testing Planned penetration test Phase 3
Third-party risk management legal/utkontraktering-policy.md Outsourcing policy drafted
Business continuity legal/beredskapsplan.md BCP drafted

Data Flow Summary

Flow Source Destination Data Protocol Encryption
User authentication Browser/Mobile BankID OIDC auth request, state, nonce HTTPS TLS 1.3
Identity verification Drop BankID Authorization code exchange HTTPS TLS 1.3
Balance read (AISP) Drop Nordic Bank Account ID, consent token PSD2 Open Banking API TLS 1.3 + OAuth2
Payment initiation (PISP) Drop Nordic Bank Amount, recipient, consent PSD2 Open Banking API TLS 1.3 + OAuth2 + SCA
KYC verification Drop Sumsub Applicant data, documents REST API + Webhooks TLS 1.3 + API key
STR filing Drop Okokrim Suspicious transaction report AltInn portal TLS 1.3 + certificate
Error tracking Drop Sentry Error events, stack traces HTTPS SDK TLS 1.3 + DSN token
Web traffic User Cloudflare -> Drop HTTP requests/responses HTTPS TLS 1.3 (edge + origin)

Cross-References

High-Level Design (HLD)

Container Diagram

C4 Level 2 — Container Diagram

Drop fintech platform container architecture showing all runtime containers, their responsibilities, communication patterns, and the middleware chain that governs every API request.


Container Diagram

C4Container
  title Drop — Container Diagram (C4 Level 2)

  Person(user, "End User", "Norwegian resident 18+, authenticated via BankID")
  Person(merchant, "Merchant", "Business owner receiving QR payments")

  System_Boundary(drop, "Drop Platform") {
    Container(web, "drop-web", "Next.js 15, React 19, Tailwind v4", "Server-side rendered web application. Handles login redirect, dashboard, send money, QR scan, bank accounts, transaction history, notifications, settings, merchant dashboard.")
    Container(api, "drop-api", "Hono v4, Node.js 22", "REST API server. 26+ endpoints under /v1/. BankID OIDC callback, transaction processing, recipient management, merchant registration, GDPR compliance, admin operations.")
    Container(mobile, "drop-mobile", "Expo SDK 54, React Native", "Native mobile app for iOS and Android. BankID auth via expo-web-browser deep linking. AsyncStorage for token persistence. No offline support.")
    ContainerDb(db, "Database", "PostgreSQL 16 (all environments)", "19 tables: 12 core (users, transactions, bank_accounts, sessions, merchants, recipients, etc.) + 7 compliance (audit_log, aml_alerts, str_reports, screening_results, consents, data_access_requests, complaints). Drizzle ORM.")
  }

  System_Ext(bankid, "BankID OIDC", "Norwegian eID provider. OIDC authorize/token/JWKS endpoints for Strong Customer Authentication.")
  System_Ext(sumsub, "Sumsub", "KYC/AML identity verification. WebSDK (web), React Native SDK (mobile), webhooks for status updates.")
  System_Ext(openbanking, "Open Banking APIs", "PSD2 AISP (read balances) and PISP (initiate payments) via licensed provider.")
  System_Ext(sepa, "SEPA/SWIFT Networks", "International payment rails for remittance settlement to 30+ countries.")

  Rel(user, web, "HTTPS", "Browser")
  Rel(user, mobile, "HTTPS", "Native app")
  Rel(merchant, web, "HTTPS", "Merchant dashboard")

  Rel(web, api, "HTTPS REST", "/v1/* endpoints, JSON, Bearer token or httpOnly cookie")
  Rel(mobile, api, "HTTPS REST", "/v1/* endpoints, JSON, Bearer token")

  Rel(api, db, "SQL", "Type-safe queries via Drizzle ORM (src/shared/db/)")
  Rel(api, bankid, "OIDC", "Authorization code flow, JWKS token verification")
  Rel(api, sumsub, "REST + Webhooks", "Applicant creation, document checks, status webhooks")
  Rel(api, openbanking, "PSD2 API", "AISP balance reads, PISP payment initiation with SCA")
  Rel(api, sepa, "ISO 20022", "Remittance settlement via banking partner")

Container Responsibilities

Container Technology Responsibilities Port
drop-web Next.js 15, React 19, Tailwind v4 SSR web app, BankID redirect initiation, UI rendering for all 10 screens (Login, Onboarding, Dashboard, SendMoney, BankAccounts, TransactionHistory, ScanQR, Profile, Notifications, MerchantDashboard) 3000
drop-api Hono v4, Node.js 22 Alpine REST API, BankID OIDC callback handling, JWT session management, transaction processing, GDPR endpoints, admin operations, audit logging 3001
drop-mobile Expo SDK 54, React Native iOS/Android native app, BankID via expo-web-browser + deep link (drop://auth/callback), AsyncStorage for token persistence, push notifications N/A
Database PostgreSQL 16 (all environments) 19 tables, foreign keys enforced. Drizzle ORM schema in src/shared/db/schema.ts. Local: Docker port 5433. Production: AWS RDS. 5432

Request Lifecycle

sequenceDiagram
    participant Client as Client (Web/Mobile)
    participant CORS as CORS Middleware
    participant ReqID as Request ID Middleware
    participant IP as Client IP Middleware
    participant RL as Rate Limiter
    participant Auth as Auth Middleware
    participant Route as Route Handler
    participant DB as Database
    participant ErrH as Error Handler

    Client->>+CORS: HTTPS Request
    CORS->>CORS: Validate Origin against allowlist
    CORS->>+ReqID: Pass if origin allowed
    ReqID->>ReqID: Extract x-request-id or generate UUID
    ReqID->>ReqID: Set x-request-id response header
    ReqID->>+IP: Forward request
    IP->>IP: Extract IP from x-real-ip / x-forwarded-for
    IP->>IP: Set clientIp context variable

    alt Rate-limited endpoint
        IP->>+RL: Forward to rate limiter
        RL->>DB: SELECT count, reset_at FROM rate_limits WHERE key = ?
        DB-->>RL: Current count
        alt Under limit
            RL->>RL: UPDATE count + 1
            RL->>+Auth: Forward request
        else Over limit
            RL-->>Client: 429 Too Many Requests
        end
    else Non-rate-limited endpoint
        IP->>+Auth: Forward request
    end

    alt Authenticated endpoint
        Auth->>Auth: Extract token (Bearer header or drop_token cookie)
        Auth->>Auth: Verify JWT (jose, HS256/RS256)
        Auth->>DB: SELECT session (check revoked = 0, expires_at > now)
        DB-->>Auth: Session record
        Auth->>DB: SELECT user WHERE id = ? AND deleted_at IS NULL
        DB-->>Auth: User record
        Auth->>Auth: Set user context variable
        Auth->>+Route: Forward authenticated request
    else Public endpoint
        IP->>+Route: Forward directly
    end

    Route->>DB: Business logic queries (parameterized)
    DB-->>Route: Query results
    Route-->>Client: JSON response { data: {...} }

    Note over ErrH: On any unhandled error
    Route-->>ErrH: Error thrown
    ErrH->>ErrH: Log error, capture in Sentry
    ErrH-->>Client: { error: "internal_error", message: "..." }

Middleware Chain

The Hono v4 API (drop-api) applies middleware in the following order for every request:

Order Middleware Source Purpose
1 CORS hono/cors in app.ts:23-30 Validates Origin header against allowlist (localhost:3000, localhost:3001, APP_URL). Sets credentials: true for cookie transport.
2 Request ID app.ts:33-38 Reads x-request-id header or generates crypto.randomUUID(). Sets on context and response header for distributed tracing.
3 Client IP app.ts:41-47 Extracts IP from x-real-ip then x-forwarded-for (first in chain), falls back to 127.0.0.1. Stored in context for rate limiting and audit.
4 Rate Limiter middleware/rate-limit.ts Per-IP rate limiting backed by rate_limits DB table. Configurable limit and window per route. Cleans expired entries every 100 calls.
5 Auth middleware/auth.ts Extracts JWT from Authorization: Bearer header or drop_token cookie. Verifies signature (jose HS256/RS256), checks session not revoked, loads user record.
6 Merchant middleware/auth.ts:21-29 Standalone middleware that independently verifies auth (calls extractToken and verifyAndGetUser) and checks user.role === 'merchant'. Does NOT extend or chain authMiddleware. Returns 403 if not merchant.
7 Global Error Handler middleware/error-handler.ts Catches all unhandled errors. HTTPException returns structured JSON with status. Other errors return 500, log to stdout, and capture in Sentry.

Rate Limit Configuration

Endpoint Group Limit Window Source
BankID initiate 10 req 60s routes/auth.ts:19
BankID callback 10 req 60s routes/auth.ts:43
Remittance 10 req/60s per-IP + 3 req/60s per-user 60s routes/transactions.ts
QR Payment 10 req/60s per-IP + 3 req/60s per-user 60s routes/transactions.ts
Exchange rates 120 req 60s routes/rates.ts

Communication Patterns

Web Client to API

The Next.js web app communicates with the Hono API over HTTPS REST:

Mobile Client to API

The Expo mobile app uses Bearer token authentication:

API to Database

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

High-Level Design (HLD)

Component Overview

Component Overview (C4 Level 3)

Document: HLD-002 Version: 1.0 Date: 2026-02-21 Author: Frontend Architect (AI Agent) Status: Draft Scope: Frontend component architecture for web and mobile applications


1. Purpose

This document provides a C4 Level 3 component view of the Drop frontend, covering both the Next.js web application and the Expo mobile application. It maps the component tree, shared component library, page composition patterns, and design system integration.


2. Web Application Component Architecture

The web application is built with Next.js 15 (App Router) and React 19, using Tailwind CSS v4 for styling and shadcn/ui (Radix UI primitives) for the component library.

2.1 Component Diagram — Web App Structure

graph TD
    subgraph "Next.js 15 App Router"
        RootLayout["RootLayout<br/>(app/layout.tsx)"]
        RootLayout --> CookieConsent["CookieConsent"]
        RootLayout --> PWARegister["PWARegister"]

        subgraph "Public Pages (No Auth)"
            Landing["/ Landing<br/>(Server Component)"]
            LoginPage["/login LoginPage"]
            RegisterPage["/register RegisterPage"]
            TermsPage["/terms TermsPage"]
            PrivacyPage["/privacy PrivacyPage"]
            FeesPage["/fees FeesPage"]
            WithdrawalPage["/withdrawal WithdrawalPage"]
        end

        subgraph "Authenticated Pages (useAuth)"
            Dashboard["/dashboard Dashboard"]
            SendMoney["/send SendMoney"]
            ScanQR["/scan ScanQR"]
            Accounts["/accounts BankAccounts"]
            Transactions["/transactions TransactionHistory"]
            Profile["/profile ProfileHub"]
            Notifications["/notifications NotificationCenter"]
            Complaints["/complaints ComplaintForm"]
        end

        subgraph "Profile Sub-Pages"
            ProfilePersonal["/profile/personal"]
            ProfileSecurity["/profile/security"]
            ProfileNotifications["/profile/notifications"]
            ProfileLanguage["/profile/language"]
        end

        subgraph "Feature-Flagged Pages"
            Cards["/cards CardManagement<br/>(FUTURE)"]
        end

        Profile --> ProfilePersonal
        Profile --> ProfileSecurity
        Profile --> ProfileNotifications
        Profile --> ProfileLanguage
    end

    subgraph "Shared Components"
        BottomNav["BottomNav<br/>(5 tabs)"]
        DropLogo["DropLogo / DropWordmark /<br/>DropLogoFull / DropAppIcon"]
        PrePaymentDisclosure["PrePaymentDisclosure<br/>(PSD2 modal)"]
    end

    subgraph "shadcn/ui Primitives"
        Button["Button"]
        Card["Card"]
        Dialog["Dialog"]
        Tabs["Tabs"]
        ScrollArea["ScrollArea"]
        Input["Input"]
        Select["Select"]
        Badge["Badge"]
        Skeleton["Skeleton"]
        Sheet["Sheet"]
        Separator["Separator"]
        Avatar["Avatar"]
        Alert["Alert"]
        Sonner["Sonner (Toast)"]
    end

    Dashboard --> BottomNav
    Dashboard --> DropLogo
    Dashboard --> ScrollArea
    Transactions --> BottomNav
    Transactions --> Tabs
    ScanQR --> BottomNav
    Accounts --> BottomNav
    Accounts --> Card
    Profile --> BottomNav
    Notifications --> BottomNav
    SendMoney --> PrePaymentDisclosure
    Landing --> DropLogoFull["DropLogoFull"]

2.2 Page Composition Pattern

Every authenticated page follows a consistent composition:

+----------------------------------+
|  Header (back nav + title)       |
+----------------------------------+
|                                  |
|  Page Content                    |
|  (scrollable area)               |
|                                  |
|                                  |
+----------------------------------+
|  BottomNav (fixed, 5 tabs)       |
+----------------------------------+

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

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

High-Level Design (HLD)

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.

graph TB
    subgraph Internet
        User[End Users]
        Mobile[Mobile App]
    end

    subgraph Cloudflare["Cloudflare Edge"]
        DNS[DNS<br/>getdrop.no]
        CDN[CDN<br/>Static assets cache]
        WAF[WAF Rules<br/>Rate limiting, bot protection,<br/>geo-blocking, OWASP rules]
        DDoS[DDoS Protection<br/>L3/L4/L7 mitigation]
    end

    subgraph AWS["AWS eu-north-1 (Stockholm)"]
        subgraph AppRunner["AWS App Runner (PLANNED)"]
            WebContainer[drop-web<br/>Next.js 15 standalone<br/>Node.js 22 Alpine<br/>Port 3000]
            APIContainer[drop-api<br/>Hono v4<br/>Node.js 22 Alpine<br/>Port 3001]
        end

        subgraph DataLayer["Data Layer"]
            RDS[(RDS PostgreSQL 16<br/>db.t3.micro → db.r6g.large<br/>Multi-AZ failover<br/>Automated backups)]
        end

        subgraph Supporting["Supporting Services"]
            ECR[ECR<br/>Container Registry<br/>Image scanning enabled]
            SM[Secrets Manager<br/>JWT_SECRET<br/>BANKID_CLIENT_SECRET<br/>DATABASE_URL<br/>SENTRY_DSN]
            CW[CloudWatch<br/>Logs + Metrics<br/>Alarm triggers]
        end
    end

    User -->|HTTPS| DNS
    Mobile -->|HTTPS| DNS
    DNS --> CDN
    CDN --> WAF
    WAF --> DDoS
    DDoS -->|Origin pull| WebContainer
    DDoS -->|Origin pull| APIContainer
    WebContainer --> RDS
    APIContainer --> RDS
    AppRunner --> ECR
    AppRunner --> SM
    AppRunner --> CW

CI/CD Pipeline

flowchart LR
    subgraph Trigger
        Push[git push]
        PR[Pull Request]
    end

    subgraph Build["Build Stage"]
        Checkout[Checkout code]
        Deps[npm ci]
        TypeCheck[tsc --noEmit]
        Lint[eslint]
        Test[vitest run]
    end

    subgraph Package["Package Stage"]
        DockerBuild[Docker multi-stage build]
        ImageScan[ECR image scan]
        PushECR[Push to ECR]
    end

    subgraph DeployStaging["Deploy: Staging"]
        DeployStagingEnv[Deploy to App Runner<br/>staging service]
        SmokeTest[Smoke test<br/>GET /v1/health]
        E2ETest[E2E test suite]
    end

    subgraph DeployProd["Deploy: Production"]
        Approval[Manual approval gate]
        BlueGreen[Blue/green swap<br/>App Runner traffic shift]
        HealthVerify[Health check verification<br/>3 consecutive passes]
        Rollback{Healthy?}
    end

    Push --> Checkout
    PR --> Checkout
    Checkout --> Deps --> TypeCheck --> Lint --> Test
    Test --> DockerBuild --> ImageScan --> PushECR
    PushECR --> DeployStagingEnv --> SmokeTest --> E2ETest
    E2ETest --> Approval --> BlueGreen --> HealthVerify --> Rollback
    Rollback -->|Yes| Done[Production Live]
    Rollback -->|No| RollbackAction[Revert to previous revision]

Pipeline Stages Detail

Stage Tool Timeout Failure Action
Checkout actions/checkout@v4 1m Fail pipeline
Install deps npm ci 5m Fail pipeline
TypeScript check tsc --noEmit 3m Fail pipeline
Lint eslint . 2m Fail pipeline
Unit tests vitest run 5m Fail pipeline
Docker build Multi-stage (4 stages: deps, test, builder, runner) 10m Fail pipeline
Image scan ECR vulnerability scan 5m Warn on HIGH, block on CRITICAL
Push to ECR docker push 3m Fail pipeline
Deploy staging App Runner update 10m Fail pipeline
Smoke test curl /v1/health 1m Rollback staging
Manual approval GitHub environment protection 24h Pipeline expires
Production deploy App Runner traffic shift 10m Auto-rollback
Health verification 3x GET /v1/health at 10s intervals 1m Auto-rollback

Docker Multi-Stage Build

Source: src/drop-app/Dockerfile

┌─────────────────────────────────────────────┐
│ Stage 1: deps (node:22-alpine)              │
│                                             │
│ • Install python3, make, g++ (native deps)  │
│ • COPY package*.json                        │
│ • npm ci (production + dev deps)            │
│ • Output: /app/node_modules                 │
├─────────────────────────────────────────────┤
│ Stage 2: test (node:22-alpine)              │
│                                             │
│ • COPY node_modules from deps               │
│ • COPY source code                          │
│ • Run vitest + coverage checks              │
│ • Mandatory test gate — blocks build on     │
│   failure                                   │
├─────────────────────────────────────────────┤
│ Stage 3: builder (node:22-alpine)           │
│                                             │
│ • COPY node_modules from deps               │
│ • COPY source code                          │
│ • npm run build (Next.js standalone output) │
│ • Output: .next/standalone, .next/static    │
├─────────────────────────────────────────────┤
│ Stage 4: runner (node:22-alpine)            │
│                                             │
│ • Non-root user: nextjs (UID 1001)          │
│ • Install python3, make, g++ (native deps)  │
│ • COPY public/ from builder                 │
│ • COPY .next/standalone from builder        │
│ • COPY .next/static from builder            │
│ • Data dir: /app/data (owned by nextjs)     │
│ • No source code                            │
│ • CMD: node server.js                       │
└─────────────────────────────────────────────┘

Security features in runner stage:


Environment Configuration

Variable Dev Staging Production Source
NODE_ENV development production production Dockerfile ENV
JWT_SECRET Dev fallback (static string 'dev-secret-change-in-production') Secrets Manager Secrets Manager auth.ts:8
DATABASE_URL Not set (SQLite) RDS connection string RDS connection string Secrets Manager
BANKID_CLIENT_ID Not set BankID test env BankID prod env Secrets Manager
BANKID_CLIENT_SECRET Not set BankID test env BankID prod env Secrets Manager
BANKID_MOCK true false false App Runner env
BANKID_CALLBACK_URL http://localhost:3000/api/auth/bankid/callback https://staging.getdrop.no/... https://getdrop.no/... App Runner env
NEXT_PUBLIC_SERVICE_MODE demo mock or live live Build-time env
SEED_DEMO implicit (non-prod) true Not set App Runner env
SENTRY_DSN Not set Sentry staging project Sentry prod project Secrets Manager
APP_URL http://localhost:3000 https://staging.getdrop.no https://getdrop.no App Runner env
PORT 3000 3000 3000 App Runner default

Environment Strategy

Environment Purpose Database BankID Data
Development Local development, docker compose up SQLite at ./data/drop.db Mock (BANKID_MOCK=true) Demo seed data
Staging Pre-release validation, QA, E2E tests RDS PostgreSQL (separate instance) BankID test environment Demo seed data (SEED_DEMO=true)
Production Live service RDS PostgreSQL (Multi-AZ, automated backups) BankID production Real user data only

Scaling Configuration

App Runner Auto-Scaling

Parameter Web Container API Container
Min instances 1 1
Max instances 5 10
Concurrency target 50 req/instance 100 req/instance
Scale-up cooldown 30s 30s
Scale-down cooldown 300s 300s
CPU 1 vCPU 1 vCPU
Memory 2 GB 2 GB

Scaling Triggers

Metric Threshold Action
Concurrent requests per instance > 80% of target Scale up
Concurrent requests per instance < 25% of target for 5m Scale down
Response time p95 > 500ms for 3m Scale up + CloudWatch alarm
Error rate (5xx) > 5% for 2m CloudWatch alarm, no auto-scale
CPU utilization > 80% for 3m Scale up

RDS PostgreSQL Scaling

Parameter Staging Production
Instance class db.t3.micro db.t3.medium (initial) → db.r6g.large
Storage 20 GB gp3 100 GB gp3, auto-scaling to 500 GB
Multi-AZ No Yes
Read replicas 0 0 (add when needed)
Backup retention 7 days 30 days
Maintenance window Sunday 03:00 UTC Sunday 03:00 UTC

Health Check Endpoints

API Health Check

Endpoint: GET /v1/health (Hono API) Source: routes/health.ts

// Success (200)
{
  "status": "ok",
  "version": "0.1.0",
  "uptime": 3600,
  "db": "connected",
  "dbLatencyMs": 1,
  "timestamp": "2026-02-21T12:00:00.000Z"
}

// Failure (503)
{
  "status": "error",
  "db": "disconnected",
  "timestamp": "2026-02-21T12:00:00.000Z"
}

Health check performs: SELECT 1 as ok query to verify database connectivity and measure latency.

Health Check Configuration

Component Interval Timeout Retries Grace Period
App Runner (web) 10s 5s 3 30s
App Runner (API) 10s 5s 3 30s
Docker Compose (dev) 30s 10s 3 10s
Cloudflare origin health 60s 10s 2 N/A

Blue/Green Deployment (Aspirational)

Note: App Runner does NOT have built-in blue/green deployment (see ADR-012). The following describes an aspirational traffic-shifting strategy that would need custom implementation. App Runner performs rolling updates by default.

1. New revision deployed alongside current (blue)
2. New revision (green) starts and passes health checks
3. Traffic gradually shifted: 0% → 10% → 50% → 100%
4. If health checks pass for 60s at 100% → old revision drained
5. If health checks fail → immediate rollback to blue

Deployment Checklist

  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:

Manual Rollback

# List recent revisions
aws apprunner list-operations --service-arn $SERVICE_ARN

# Rollback to previous revision
aws apprunner update-service \
  --service-arn $SERVICE_ARN \
  --source-configuration '{"ImageRepository":{"ImageIdentifier":"<previous-ecr-image>"}}'

# Verify rollback
curl https://getdrop.no/v1/health

Database Rollback

For database schema changes, migrations are forward-only. In case of issues:

  1. SQLite (dev/staging): Restore from backup (see DEPLOYMENT.md backup section)
  2. PostgreSQL (prod): RDS point-in-time recovery to any second within retention window (30 days)
# RDS point-in-time restore
aws rds restore-db-instance-to-point-in-time \
  --source-db-instance-identifier drop-prod \
  --target-db-instance-identifier drop-prod-restored \
  --restore-time "2026-02-21T11:00:00Z"

Cloudflare Configuration

Feature Configuration Purpose
DNS getdrop.no → App Runner CNAME (proxied) Domain routing
SSL/TLS Full (strict) End-to-end encryption
CDN Cache static assets (/_next/static/*, /public/*) Performance
WAF OWASP Core Rule Set, rate limiting rules Security
DDoS L3/L4/L7 auto-mitigation Availability
Bot management Challenge mode for suspicious traffic Security
Geo-blocking Allow: NO, SE, DK, FI (Scandinavia) + test regions Compliance
Page rules /* → SSL always, HSTS Security

Cloudflare WAF Rules

Rule Action Purpose
OWASP Core Rule Set Block SQL injection, XSS, path traversal
Rate limit: /v1/auth/* Challenge at 20 req/10s Auth endpoint abuse prevention
Rate limit: /v1/transactions/* Block at 30 req/10s Transaction abuse prevention
Country block: Sanctioned countries Block OFAC/UN sanctions compliance
Bot score < 30 Challenge Bot traffic mitigation

Cross-References

High-Level Design (HLD)

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:


2. Trust Boundaries

graph TB
    subgraph Internet["Internet (Untrusted)"]
        Browser["Web Browser"]
        Mobile["Mobile App (Expo)"]
        Attacker["Potential Attacker"]
    end

    subgraph CDN["CDN / Edge (Cloudflare)"]
        WAF["WAF + DDoS Protection"]
        TLS["TLS Termination"]
    end

    subgraph AppTier["Application Tier (AWS App Runner)"]
        subgraph NextJS["Next.js BFF"]
            WebRoutes["Web API Routes<br/>/api/auth/*, /api/transactions/*"]
            Middleware["Auth Middleware<br/>Rate Limiter<br/>CSRF Validator<br/>Input Sanitizer"]
        end
        subgraph Hono["Hono API"]
            MobileRoutes["Mobile API Routes<br/>/v1/auth/*, /v1/transactions/*"]
            HonoMiddleware["Auth Middleware<br/>Rate Limiter"]
        end
    end

    subgraph DataTier["Data Tier (Private Subnet)"]
        SQLite["SQLite / PostgreSQL<br/>19 tables (12 core + 7 compliance)"]
    end

    subgraph ExternalServices["External Services (Trusted Partners)"]
        BankID["BankID OIDC<br/>(auth.bankid.no)"]
        ASPSP["ASPSPs<br/>(DNB, SpareBank 1, Nordea)"]
        FX["FX Rate Provider"]
        KYC["KYC Provider<br/>(Sumsub - future)"]
    end

    Browser -->|"HTTPS<br/>TB1: Internet→Edge"| WAF
    Mobile -->|"HTTPS<br/>TB1: Internet→Edge"| WAF
    Attacker -.->|"Blocked by WAF"| WAF
    WAF -->|"TB2: Edge→App"| Middleware
    WAF -->|"TB2: Edge→App"| HonoMiddleware
    Middleware --> WebRoutes
    HonoMiddleware --> MobileRoutes
    WebRoutes -->|"TB3: App→Data"| SQLite
    MobileRoutes -->|"TB3: App→Data"| SQLite
    WebRoutes -->|"TB4: App→External<br/>mTLS"| BankID
    WebRoutes -->|"TB4: App→External<br/>eIDAS cert"| ASPSP
    MobileRoutes -->|"TB4: App→External"| BankID

    style Internet fill:#ff6b6b,stroke:#333,color:#fff
    style CDN fill:#ffd93d,stroke:#333
    style AppTier fill:#6bcb77,stroke:#333
    style DataTier fill:#4d96ff,stroke:#333,color:#fff
    style ExternalServices fill:#845ec2,stroke:#333,color:#fff

Trust Boundary Definitions

Boundary From To Protection
TB1: Internet to Edge Browser/Mobile Cloudflare TLS 1.3, WAF rules, DDoS mitigation
TB2: Edge to Application Cloudflare Next.js/Hono HTTPS, auth middleware, rate limiting
TB3: Application to Data API layer SQLite/PostgreSQL Parameterized queries, file permissions
TB4: Application to External API layer BankID/ASPSP mTLS (eIDAS QWAC), JWKS verification

3. STRIDE Threat Model

3.1 Threat Matrix

Component Spoofing Tampering Repudiation Info Disclosure DoS Elevation
BankID Auth L: BankID handles identity L: JWKS signature verification L: Audit log + session tracking M: pid hash exposure risk M: Rate limit 10/min L: Role check on every request
JWT Tokens M: Token theft via XSS L: HS256 signature L: Session table tracks all JWTs M: Payload contains userId L: 7d expiry M: Role claim in JWT
PISP Payments L: SCA required per payment M: Amount/payee tampering L: Audit log + idempotency_key L: Disclosure before payment M: Rate limit 10/min L: KYC check before remittance
AISP Balance L: Consent required L: Read-only from ASPSP L: balance_synced_at tracking M: Cached balance visible L: Max 4 reads/day N/A
Database L: No direct access M: SQL injection risk L: audit_log table H: PII in users table L: Rate limiting L: User-scoped queries
API Endpoints M: CSRF on web M: Input manipulation L: Audit logging M: Error message leakage H: Unthrottled endpoints M: IDOR if user_id not checked

Risk levels: L = Low (mitigated), M = Medium (partial mitigation), H = High (needs attention), N/A = Not applicable

3.2 Detailed Threat Analysis

S — Spoofing

Threat Attack Vector Mitigation Status
Identity spoofing Stolen credentials BankID OIDC (SCA: possession + knowledge) Implemented
Session hijacking Token theft httpOnly + secure + sameSite=Lax cookies Implemented
CSRF Forged cross-origin request State parameter (OIDC), Origin header validation Implemented
Replay attack Reuse old auth code Nonce in OIDC flow, one-time code exchange Implemented

T — Tampering

Threat Attack Vector Mitigation Status
SQL injection Malicious input in queries Parameterized queries (all 24 endpoints) Implemented
XSS Script injection in fields React auto-escaping, CSP headers, sanitizeText() Implemented
Payment amount tampering Modified request body Server-side validation, SCA dynamic linking Implemented
JWT modification Altered token claims HS256 signature verification Implemented

R — Repudiation

Threat Attack Vector Mitigation Status
Deny transaction User claims they didn't authorize BankID SCA log + audit_log table Partial (audit_log exists, SCA tracking needed)
Deny consent User claims no consent given consents table with IP address + timestamp Implemented
Admin action denial Unauthorized changes audit_log with user_agent and ip_address Implemented

I — Information Disclosure

Threat Attack Vector Mitigation Status
PII exposure Database breach Encryption at rest (planned), PID hashed with SHA-256 Partial
Card data exposure API response leakage Masked to last 4 digits, CVV hidden Implemented
Bank account exposure API response leakage Masked to last 4 digits in recipient list Implemented
Error message leakage Verbose error responses Centralized error handler, generic messages Implemented

D — Denial of Service

Threat Attack Vector Mitigation Status
API flooding High request volume Rate limiting (10-120/min per endpoint) Implemented
Auth brute force Repeated login attempts BankID handles (locks after failures) Implemented
Database exhaustion Large data queries Pagination (max 50/page), query limits Implemented
Resource exhaustion Large payloads Input length limits (sanitizeText) Implemented

E — Elevation of Privilege

Threat Attack Vector Mitigation Status
IDOR Access other user's data AND user_id = ? on all queries Implemented
Role escalation Modify role claim Server-side role check, role in DB not just JWT Implemented
Merchant impersonation Access merchant dashboard role = 'merchant' check on merchant routes. Note: merchant role currently grants admin access (audit, screening, STR) via isAdmin(role) === role === 'merchant' in admin.ts Implemented
KYC bypass Skip verification kyc_status = 'approved' check before remittance Implemented

4. SCA Implementation

4.1 Two-Level SCA

Drop implements SCA at two levels:

Level Purpose Provider Method
App Authentication Login to Drop BankID OIDC BankID app (possession) + code/biometrics (knowledge/inherence)
Payment Authorization Approve PISP payment ASPSP via BankID BankID at bank (dynamic linking: amount + payee)

4.2 SCA Factors

Factor Type BankID Implementation
Knowledge Personal code / PIN
Possession Mobile device with BankID app / code generator
Inherence Biometrics (fingerprint/face on mobile BankID)

PSD2 RTS Art. 4: At least 2 of 3 factors required. BankID provides 2 by default (possession + knowledge or inherence).

4.3 Dynamic Linking (PISP)

For every PISP payment, PSD2 RTS Art. 97(2) requires:

  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

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

High-Level Design (HLD)

Data Architecture

Data Architecture

Version: 1.0 Date: 2026-02-21 Status: Approved Owner: Database Architect


Overview

Drop's data architecture supports a PSD2 pass-through fintech application with two core functions: international remittances and QR merchant payments. The system manages 19 tables across 5 domains, backed by PostgreSQL 16 (all environments: development, CI, staging, production) via Drizzle ORM. See ADR-014.

Drop never holds customer funds. The bank_accounts.balance field is a cached AISP read from the user's real bank account -- not a Drop-held balance.


Domain Model

The 19 tables are organized into 5 logical domains:

erDiagram
    %% User Domain
    users ||--o{ bank_accounts : "links"
    users ||--o{ cards : "owns"
    users ||--o{ recipients : "saves"
    users ||--o{ transactions : "initiates"
    users ||--o{ sessions : "authenticates"
    users ||--o{ notifications : "receives"
    users ||--|| settings : "configures"
    users ||--o{ spending_limits : "sets"

    %% Financial Domain
    transactions }o--o| recipients : "sends to"
    transactions }o--o| merchants : "pays"
    users ||--o{ merchants : "registers as"
    cards ||--o{ spending_limits : "limited by"

    %% KYC/AML Domain
    users ||--o{ screening_results : "screened"
    users ||--o{ aml_alerts : "flagged"
    aml_alerts ||--o{ str_reports : "escalated to"
    transactions ||--o{ aml_alerts : "triggers"

    %% GDPR Domain
    users ||--o{ consents : "grants"
    users ||--o{ data_access_requests : "submits"
    users ||--o{ complaints : "files"

    %% System Domain
    users ||--o{ audit_log : "generates"

    users {
        text id PK "usr_ prefix"
        text email UK
        text password_hash
        text first_name
        text last_name
        text phone
        text date_of_birth
        text kyc_status "pending|approved|rejected"
        text role "user|merchant"
        text risk_level "low|medium|high"
        text pep_status
        text national_id_hash
        text deleted_at
        text created_at
    }

    bank_accounts {
        text id PK
        text user_id FK
        text bank_name
        text account_number
        text iban
        integer balance "cached AISP read"
        text currency
        integer is_primary
    }

    transactions {
        text id PK
        text user_id FK
        text type "remittance|qr_payment"
        text status "processing|completed|failed"
        integer amount
        text currency
        integer fee
        text recipient_id FK
        text merchant_id FK
        real exchange_rate
        text idempotency_key UK
    }

    recipients {
        text id PK
        text user_id FK
        text name
        text country
        text currency
        text bank_account
    }

    merchants {
        text id PK
        text user_id FK
        text business_name
        text org_number UK
        text bank_account
        real fee_rate
        text qr_hmac_key
    }

    sessions {
        text id PK
        text user_id FK
        text token_hash
        text expires_at
        integer revoked
    }

    notifications {
        text id PK
        text user_id FK
        text type
        text title
        text body
        integer read
    }

    settings {
        text user_id PK
        text currency
        text language
        integer push_enabled
        integer email_enabled
    }

    exchange_rates {
        integer id PK
        text from_currency
        text to_currency
        real rate
    }

    cards {
        text id PK
        text user_id FK
        text type "virtual|physical"
        text last_four
        text status "active|frozen|cancelled"
    }

    spending_limits {
        text id PK
        text user_id FK
        text card_id FK
        text limit_type
        integer amount
    }

    audit_log {
        text id PK
        text user_id FK
        text action
        text resource_type
        text resource_id
        text ip_address
    }

    aml_alerts {
        text id PK
        text user_id FK
        text alert_type
        text severity "low|medium|high|critical"
        text transaction_id FK
        text status "open|investigating|resolved|escalated|filed"
    }

    str_reports {
        text id PK
        text user_id FK
        text alert_id FK
        text report_type
        text status "draft|submitted|acknowledged"
    }

    screening_results {
        text id PK
        text user_id FK
        text screening_type "pep|sanctions|adverse_media"
        text result "clear|match|potential_match|error"
    }

    consents {
        text id PK
        text user_id FK
        text consent_type
        integer granted
        text ip_address
    }

    data_access_requests {
        text id PK
        text user_id FK
        text request_type "export|erasure|rectification|restriction"
        text status "pending|processing|completed|rejected"
    }

    complaints {
        text id PK
        text user_id FK
        text category
        text subject
        text status "received|investigating|resolved|escalated"
    }

    rate_limits {
        text key PK
        integer count
        integer reset_at
    }

Domain Groupings

1. User Domain (4 tables)

Core identity, authentication, and preferences.

Table Purpose Record Growth
users User accounts with KYC/AML fields, BankID identity 1 per registered user
settings Per-user preferences (currency, language, notifications) 1 per user (1:1)
sessions JWT session tracking with revocation support Multiple per user, prunable
notifications In-app notification delivery High volume, prunable

Key relationships: users is the central entity. Every other user-scoped table references users(id) via foreign key. settings has a 1:1 relationship using user_id as its primary key.

2. Financial Domain (7 tables)

Transaction processing, bank account linkage, exchange rates, and payment cards.

Table Purpose Record Growth
transactions All financial operations (remittance + QR payment) High volume, append-only
bank_accounts Linked bank accounts with cached AISP balance Few per user
recipients Saved remittance recipients Few per user
merchants Registered merchant profiles 1 per merchant user
exchange_rates NOK-to-foreign currency rates 6 corridor records, updated periodically
cards Virtual/physical payment cards (FUTURE, feature-flagged) Few per user
spending_limits Card spending limits (FUTURE) Few per card

Key relationships: transactions polymorphically references either recipients (for remittances) or merchants (for QR payments) -- never both simultaneously. bank_accounts.balance is a cached read-only value from AISP, not a Drop-held balance.

3. KYC/AML Domain (3 tables)

Anti-money laundering monitoring and regulatory screening.

Table Purpose Record Growth
aml_alerts Flagged suspicious transaction patterns Event-driven, low volume
str_reports Suspicious Transaction Reports filed with Økokrim Rare, legally retained
screening_results PEP/sanctions/adverse media screening results Per user, periodic rescreens

Key relationships: aml_alerts links to a triggering transaction. str_reports escalates from an aml_alert. Both reference the user under investigation.

4. GDPR/Compliance Domain (3 tables)

Data subject rights, consent management, and complaint handling.

Table Purpose Record Growth
consents GDPR consent records (terms, privacy, marketing, cookies) Few per user
data_access_requests DSAR tracking (export, erasure, rectification, restriction) Rare
complaints Customer complaints per Finansavtaleloven section 3-53 Low volume

Key relationships: All reference users(id). Consent withdrawal triggers downstream processing (e.g., marketing opt-out).

5. System Domain (2 tables)

Operational infrastructure for audit trails and rate limiting.

Table Purpose Record Growth
audit_log User action audit trail for compliance Very high volume
rate_limits IP-based rate limiting counters Ephemeral, auto-cleaned

Key relationships: audit_log optionally references users(id) (some system events are unauthenticated). rate_limits is standalone with no foreign keys.


Data Classification

Each table is classified by sensitivity level for security controls, encryption, and access policies. Classification uses the 5-level taxonomy defined in security-architecture.md: CRITICAL, RESTRICTED, CONFIDENTIAL, INTERNAL, PUBLIC.

Table Classification PII Financial Compliance Rationale
users RESTRICTED Yes No Yes Contains name, email, phone, DOB, national ID hash
bank_accounts RESTRICTED Yes Yes Yes Bank account numbers, IBAN, cached balance
transactions CONFIDENTIAL No Yes Yes Financial records, amounts, exchange rates
recipients RESTRICTED Yes Yes No Names and foreign bank account numbers
merchants CONFIDENTIAL No Yes No Business details, org numbers, bank accounts
sessions INTERNAL No No No Token hashes enabling authentication bypass if leaked
cards RESTRICTED Yes Yes Yes Card last-four, token refs, PINs (FUTURE)
aml_alerts CONFIDENTIAL No No Yes Regulatory investigation data
str_reports CONFIDENTIAL No No Yes Filed with Økokrim, legally protected
screening_results CONFIDENTIAL No No Yes PEP/sanctions match data
audit_log INTERNAL Partial No Yes IP addresses, user agents, action descriptions
consents RESTRICTED Partial No Yes IP addresses, consent timestamps
data_access_requests INTERNAL No No Yes DSAR metadata and download URLs
complaints INTERNAL No No Yes User-submitted text content
notifications INTERNAL No No No Display text, no sensitive content
settings INTERNAL No No No UI preferences only
exchange_rates PUBLIC No No No Public market data
spending_limits INTERNAL No No No User-configured limits
rate_limits INTERNAL No No No Ephemeral IP counters

Data Flow

Remittance Flow

sequenceDiagram
    participant U as User (Mobile/Web)
    participant API as Hono API / Next.js
    participant Auth as Auth Middleware
    participant DB as Database (SQLite/PostgreSQL)
    participant AISP as Open Banking AISP
    participant PISP as Open Banking PISP

    U->>API: POST /transactions/remittance
    API->>Auth: Verify JWT + session
    Auth->>DB: SELECT sessions WHERE token_hash = ? AND revoked = 0
    Auth-->>API: userId, role

    API->>DB: SELECT * FROM recipients WHERE id = ? AND user_id = ?
    DB-->>API: Recipient (country, currency, bank_account)

    API->>DB: SELECT rate FROM exchange_rates WHERE to_currency = ?
    DB-->>API: Exchange rate

    API->>DB: SELECT * FROM bank_accounts WHERE user_id = ? AND is_primary = 1
    DB-->>API: Bank account (balance check)

    Note over API,DB: Atomic transaction begins
    API->>DB: UPDATE bank_accounts SET balance = balance - ? WHERE balance >= ?
    API->>DB: INSERT INTO transactions (type='remittance', status='processing', ...)
    API->>DB: INSERT INTO audit_log (action='transaction.create', ...)
    API->>DB: INSERT INTO notifications (type='transaction', ...)
    Note over API,DB: Atomic transaction commits

    API->>PISP: Initiate payment from user's bank (production)
    API-->>U: 201 { transaction details, ETA }

QR Payment Flow

sequenceDiagram
    participant U as User (Mobile)
    participant API as Hono API / Next.js
    participant DB as Database
    participant M as Merchant

    U->>U: Scan QR code (drop://pay/{merchantId})
    U->>API: POST /transactions/qr-payment { merchantId, amount }
    API->>DB: Verify JWT session
    API->>DB: SELECT * FROM merchants WHERE id = ?
    DB-->>API: Merchant details (fee_rate, bank_account)

    API->>DB: SELECT * FROM bank_accounts WHERE user_id = ? AND is_primary = 1
    DB-->>API: Primary bank account

    Note over API,DB: Atomic transaction
    API->>DB: UPDATE bank_accounts SET balance = balance - (amount + fee)
    API->>DB: INSERT INTO transactions (type='qr_payment', status='completed')
    API->>DB: INSERT INTO audit_log (action='qr_payment.create')
    API->>DB: INSERT INTO notifications (title='Betaling registrert')
    Note over API,DB: Commit

    API-->>U: 201 { payment confirmation }

Caching Strategy

Data Cache Location TTL Invalidation Rationale
Exchange rates exchange_rates table Updated periodically (external feed in production) Table update replaces rows Rates change infrequently; per-request DB lookup is sufficient
Bank account balance bank_accounts.balance column balance_synced_at tracks freshness Re-synced via AISP on dashboard load Cached AISP read; Drop never modifies this value except through sync
User session validity sessions table lookup Until expires_at Set revoked = 1 on logout Every authenticated request checks session table
Rate limit counters rate_limits table reset_at Unix timestamp (60s window) Auto-cleaned every 100 rate limit checks Expired entries deleted in middleware/rate-limit.ts
JWT payload In-cookie (client-side) 7d (all clients) Cookie cleared on logout, session revoked server-side Stateless token; server validates against sessions table
Feature flags In-memory (process) Process lifetime Restart or env var change Read from environment variables at startup

No external cache layer (Redis/Memcached): At current scale, PostgreSQL 16 with Drizzle ORM handles the expected query volume without an external cache. A caching layer will be evaluated when query volume exceeds PostgreSQL connection pool capacity (max 20 connections per App Runner instance).


Data Access Layer (Drizzle ORM)

NOTE: The dual-driver abstraction (db.ts, USE_PG) was removed per ADR-014 (2026-03-03). The data access layer is now Drizzle ORM exclusively.

The database access layer (src/shared/db/schema.ts + Drizzle ORM) provides type-safe access to PostgreSQL 16:

Pattern How
SELECT queries db.select().from(table).where(...)
Single row SELECT db.select().from(table).limit(1)
INSERT/UPDATE/DELETE db.insert(table).values(...), db.update(), db.delete()
Upsert db.insert(table).values(...).onConflictDoUpdate(...)
Atomic operations db.transaction(async (tx) => { ... })
Row locking db.select().from(table).for('update')
Raw SQL escape hatch db.execute(sql\SELECT ...`)`

Connection string: DATABASE_URL=postgresql://... (required in all environments).


Cross-References

Low-Level Design (LLD)

Detailed flow diagrams and implementation details

Low-Level Design (LLD)

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:


2. Web Login Flow (BankID OIDC)

2.1 Sequence Diagram — Web BankID Login

sequenceDiagram
    actor User
    participant Browser as Browser<br/>(Next.js Client)
    participant BFF as Next.js BFF<br/>(/api/auth/bankid)
    participant BankID as BankID OIDC<br/>Provider
    participant DB as SQLite/PostgreSQL

    User->>Browser: Navigate to /login
    Browser->>Browser: Render login page<br/>(BankID + Vipps buttons)
    User->>Browser: Click "BankID" button

    Browser->>BFF: GET /api/auth/bankid
    BFF->>BFF: Rate limit check (10/min per IP)
    BFF->>BFF: Generate state + nonce
    BFF->>BFF: Set bankid_state httpOnly cookie
    BFF-->>Browser: { redirectUrl }

    Browser->>BankID: Redirect to BankID authorize URL<br/>(client_id, redirect_uri, state, nonce, scope=openid)
    BankID->>User: BankID authentication UI<br/>(code device, app, or biometric)
    User->>BankID: Authenticate with BankID

    BankID-->>Browser: 302 → /api/auth/bankid/callback?code=XXX&state=YYY

    Browser->>BFF: GET /api/auth/bankid/callback?code&state
    BFF->>BFF: Verify state matches bankid_state cookie
    BFF->>BankID: POST /token (exchange code for tokens)
    BankID-->>BFF: { id_token, access_token }
    BFF->>BFF: Verify id_token signature (JWKS)
    BFF->>BFF: Parse pid (national ID, 11 digits)
    BFF->>BFF: Verify age >= 18 from pid birthdate
    BFF->>DB: SELECT user WHERE national_id_hash = SHA-256(pid)

    alt New user
        BFF->>DB: INSERT user (kyc_status=approved, auth_provider=bankid)
        BFF->>DB: INSERT default settings (NOK, nb)
    end

    BFF->>DB: INSERT session (token_hash, expires_at)
    BFF->>BFF: Sign JWT (userId, email, role)
    BFF->>BFF: Set drop_token httpOnly cookie (7d, secure, sameSite=Lax)
    BFF-->>Browser: 302 → /dashboard

    Browser->>Browser: Router navigates to /dashboard
    Browser->>BFF: GET /api/auth/me (with cookie)
    BFF->>DB: Verify session not revoked
    BFF-->>Browser: { user, bankAccounts, totalBalance }
    Browser->>Browser: Render dashboard with user data

2.2 Demo Mode Login (Development Only)

In development (isDemoMode() returns true), a demo login endpoint is available. It loads a fixed demo user (usr_demo1, email: demo@example.test) without requiring credentials:

sequenceDiagram
    actor User
    participant Browser as Browser<br/>(/login page)
    participant API as Next.js API<br/>(/api/auth/login)
    participant DB as SQLite

    User->>Browser: Click "Demo Login"

    Browser->>API: POST /v1/auth/demo-login (no credentials)

    API->>API: Check isDemoMode()
    API->>DB: SELECT user WHERE id = 'usr_demo1'
    Note over API: Fixed demo user (demo@example.test)

    alt Demo mode active
        API->>DB: INSERT session
        API->>API: Sign JWT, set httpOnly cookie
        API-->>Browser: 200 { user, token }
        Browser->>Browser: router.push("/dashboard")
    else Demo mode disabled
        API-->>Browser: 404 { error: "not_found" }
    end

3. Mobile Login Flow (BankID OIDC)

3.1 Sequence Diagram — Mobile BankID Login

sequenceDiagram
    actor User
    participant App as Expo App
    participant WebBrowser as expo-web-browser
    participant API as Hono API<br/>(/v1/auth)
    participant BankID as BankID OIDC
    participant DB as Database

    User->>App: Open app, tap "Logg inn"
    App->>API: GET /v1/auth/bankid/initiate?platform=mobile
    API->>API: Rate limit check
    API->>API: Generate state + nonce
    API-->>App: { redirectUrl, state }

    App->>WebBrowser: Open BankID URL<br/>(expo-web-browser)
    WebBrowser->>BankID: BankID authorize URL
    BankID->>User: BankID authentication
    User->>BankID: Authenticate

    BankID-->>WebBrowser: Redirect to drop://auth/callback?code&state
    WebBrowser-->>App: Deep link intercept

    App->>API: POST /v1/auth/bankid/callback<br/>{ code, state, platform: "mobile" }
    API->>BankID: Exchange code for tokens
    BankID-->>API: { id_token }
    API->>API: Verify id_token (JWKS)
    API->>API: Parse pid, verify age >= 18
    API->>DB: Find or create user

    alt New user
        API->>DB: INSERT user (kyc_status=approved)
    end

    API->>DB: INSERT session
    API->>API: Sign JWT (7d expiry)
    API-->>App: { token, data: { user } }

    App->>App: Store token in AsyncStorage
    App->>App: Navigate to (tabs) dashboard

    Note over App: Future: biometric unlock<br/>(Face ID / Touch ID)

4. Authentication State Diagram

stateDiagram-v2
    [*] --> Unauthenticated: App launch

    Unauthenticated --> BankIDRedirect: Click "BankID"
    Unauthenticated --> DemoLogin: Enter credentials (dev mode)

    BankIDRedirect --> BankIDAuthenticating: Browser opens BankID
    BankIDAuthenticating --> CallbackProcessing: BankID returns code
    BankIDAuthenticating --> BankIDError: Auth failed/cancelled/timeout

    CallbackProcessing --> Authenticated: Valid token + session created
    CallbackProcessing --> AgeRejected: User under 18
    CallbackProcessing --> BankIDError: Token verification failed

    DemoLogin --> Authenticated: Valid credentials
    DemoLogin --> LoginError: Invalid credentials

    Authenticated --> SessionActive: JWT valid + session not revoked
    SessionActive --> TokenExpired: JWT expired (7d all platforms)
    SessionActive --> SessionRevoked: Logout or admin revoke
    SessionActive --> Authenticated: Token refresh

    TokenExpired --> Unauthenticated: Redirect to /login
    SessionRevoked --> Unauthenticated: Clear cookie/token
    AgeRejected --> Unauthenticated: Show age error
    BankIDError --> Unauthenticated: Show error + retry
    LoginError --> Unauthenticated: Show error message

5. Error States

5.1 Error State Table

Error Cause User-Facing Message (Norwegian) Recovery Action
BankID Unavailable BankID service down "BankID er midlertidig utilgjengelig. Prøv igjen senere." Retry button, show status page link
BankID Timeout User took too long (>5min) "BankID-sesjonen utløp. Vennligst prøv igjen." Auto-redirect back to login page
BankID Cancelled User cancelled authentication "Innlogging avbrutt. Trykk 'BankID' for å prøve igjen." Show login page with BankID button
State Mismatch CSRF attack or stale session "Noe gikk galt. Vennligst prøv å logge inn på nytt." Clear state cookie, redirect to /login
Token Verification Failed Invalid/tampered id_token "Autentisering mislyktes. Prøv igjen." Redirect to /login
Age Under 18 User is younger than 18 "Du må være minst 18 år for å bruke Drop." No retry — age requirement is firm
Rate Limited Too many login attempts "For mange forsøk. Vent litt og prøv igjen." Wait and retry (10/min limit)
Invalid Credentials (Demo) Wrong email or password "Feil e-post eller passord." Re-enter credentials
Session Expired JWT expired "Sesjonen din har utløpt. Logg inn igjen." Redirect to /login
Session Revoked Logout from another device "Du har blitt logget ut." Re-login via BankID
Network Error No connectivity "Ingen nettverkstilkobling. Sjekk internett." Retry when connectivity restored

5.2 Error Handling by Platform

Platform Error Display Navigation
Web Inline error message on login page, red text below form router.push("/login") on session errors
Mobile Alert dialog or inline error text router.replace("/") (welcome screen) on session errors

6. Session Management

6.1 Token Storage

Platform Storage Token Name Flags
Web httpOnly cookie drop_token httpOnly, secure, sameSite=Lax
Mobile AsyncStorage Bearer token In-memory variable + persistent storage

6.2 Session Lifecycle

Event Action Database
Login success Create session record INSERT INTO sessions (id, user_id, token_hash, expires_at)
Each request Verify session valid SELECT * FROM sessions WHERE token_hash = ? AND revoked = 0 AND expires_at > now()
Token refresh New session, revoke old INSERT new session, UPDATE old session SET revoked = 1
Logout Revoke all sessions UPDATE sessions SET revoked = 1 WHERE user_id = ?
Admin action Revoke specific session UPDATE sessions SET revoked = 1 WHERE id = ?

6.3 Token Refresh

POST /v1/auth/refresh refreshes the user's session:

  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

Low-Level Design (LLD)

Login & Authentication (Backend)

Login & Authentication — Backend Architecture

Backend-specific authentication details for the Drop fintech platform. Covers JWT token structure, token refresh mechanism, session revocation, rate limiting on auth endpoints, audit logging, demo mode implementation, and cookie security settings.


JWT Token Structure

Token Generation

Source: src/drop-api/src/lib/auth.ts:42-48 Library: jose (HS256 default, RS256 opt-in)

new jose.SignJWT({ userId, email, role })
  .setProtectedHeader({ alg: "HS256" })
  .setIssuedAt()
  .setIssuer("drop-api")
  .setAudience("drop")
  .setExpirationTime("7d")
  .sign(key);

JWT Claims

Claim Type Value Source
userId string usr_<16 hex chars> (e.g., usr_a1b2c3d4e5f6g7h8) auth.ts:44 — from user record
email string usr_xxx@bankid.drop.local (BankID placeholder) or real email auth.ts:44 — from user record
role string user or merchant auth.ts:44 — from users.role column
iat number Unix timestamp of issuance auth.ts:45setIssuedAt()
exp number iat + 7 days (604800 seconds) auth.ts:45setExpirationTime("7d")
iss string drop-api auth.ts:45setIssuer()
aud string drop auth.ts:45setAudience()

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-34getAlgorithm() 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

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

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

{
  "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:

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

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

SameSite=Lax Behavior

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

Low-Level Design (LLD)

Registration & Onboarding Flow

Registration & Onboarding Flow -- Low-Level Design

Document: LLD-REGISTRATION Status: Approved Last updated: 2026-02-21 Author: Standards Architect Applies to: Drop v1.0 (PSD2 pass-through model) User requirements: Minimum age 18, Norwegian residency, valid BankID (from vilkar.html)


Overview

Drop's registration is simplified by using BankID as the sole authentication provider. There is no separate registration form -- user accounts are created automatically on first BankID login. The onboarding flow then guides the user through consent collection, KYC verification, and first bank account linking.

Key principle: Progressive disclosure. Users see only what they need at each step. Heavy verification happens in the background while the user explores the app.


Complete Registration Flow

sequenceDiagram
    participant User
    participant App as Drop App
    participant BFF as Next.js BFF / Hono API
    participant BankID as BankID OIDC
    participant Sumsub
    participant Bank as Nordic Bank (Open Banking)
    participant DB as PostgreSQL

    Note over User,DB: Step 1 -- BankID Authentication + Auto-Registration
    User->>App: Tap "Logg inn med BankID"
    App->>BFF: GET /api/auth/bankid (or /v1/auth/bankid/initiate)
    BFF->>BFF: Generate state + nonce
    BFF->>BFF: Rate limit check (10/min per IP)
    BFF->>App: { redirectUrl }
    App->>BankID: Open BankID authorize URL

    User->>BankID: Authenticate (BankID app/code device)
    Note over User,BankID: SCA: possession (device) + knowledge (PIN)

    BankID->>App: Redirect with ?code=&state=
    App->>BFF: GET /callback?code=&state= (or POST /callback)
    BFF->>BFF: Verify state vs cookie/session
    BFF->>BankID: POST /token (exchange code)
    BankID->>BFF: { id_token, access_token }
    BFF->>BFF: Verify ID token signature (JWKS)
    BFF->>BFF: Extract pid (fodselsnummer, 11 digits)
    BFF->>BFF: Parse DOB from pid
    BFF->>BFF: Verify age >= 18

    alt Age < 18
        BFF->>App: Error: "Du ma vaere minst 18 ar"
        App->>User: Show age restriction message
    else Age >= 18
        BFF->>BFF: SHA-256 hash pid -> national_id_hash
        BFF->>DB: SELECT user WHERE national_id_hash = ?

        alt Existing user
            BFF->>DB: Create session + JWT
            BFF->>App: Set cookie / return Bearer token
            App->>User: Redirect to /dashboard
        else New user (first login)
            BFF->>DB: INSERT user (kyc_status='approved', kyc_method='bankid', auth_provider='bankid', password_hash='EIDONLY')
            BFF->>DB: Create session + JWT
            BFF->>App: Set cookie / return Bearer token
            App->>User: Redirect to /onboarding
        end
    end

    Note over User,DB: Step 2 -- Consent Collection (Onboarding Screen 1)
    App->>User: Show consent checkboxes
    User->>App: Accept terms + privacy + data processing
    App->>BFF: POST /api/consents (type: 'terms', granted: true)
    BFF->>DB: INSERT consents (terms, ip_address, granted_at)
    App->>BFF: POST /api/consents (type: 'privacy', granted: true)
    BFF->>DB: INSERT consents (privacy, ip_address, granted_at)
    App->>User: Optional: marketing consent checkbox
    Note over User,App: Marketing consent is OPTIONAL per GDPR

    Note over User,DB: Step 3 -- KYC Trigger (Background)
    BFF->>Sumsub: Create applicant (name, DOB from BankID)
    Sumsub->>BFF: applicant_id
    BFF->>DB: Store applicant_id
    Sumsub->>Sumsub: PEP + sanctions screening
    Sumsub->>BFF: Webhook: screening results
    BFF->>DB: INSERT screening_results

    Note over User,DB: Step 4 -- Bank Account Linking (Onboarding Screen 2)
    App->>User: "Koble til bankkonto"
    User->>App: Select bank (DNB, SpareBank1, Nordea...)
    App->>BFF: POST /api/bank-accounts/link
    BFF->>Bank: Open Banking: AISP consent request
    Bank->>User: Authorize AISP access (SCA)
    User->>Bank: Approve
    Bank->>BFF: AISP access token + account list
    BFF->>DB: INSERT bank_accounts (from AISP response)
    BFF->>Bank: GET /accounts/{id}/balances
    Bank->>BFF: { balance, currency }
    BFF->>DB: UPDATE bank_accounts SET balance = ?, is_primary = 1
    BFF->>App: { bankAccounts: [...] }
    App->>User: Show linked account with balance

    Note over User,DB: Step 5 -- Onboarding Complete
    App->>User: "Velkommen til Drop!"
    App->>User: Redirect to /dashboard

Onboarding States

stateDiagram-v2
    [*] --> bankid_redirect : User taps "Logg inn med BankID"

    bankid_redirect --> bankid_auth : BankID authorize page
    bankid_auth --> age_check : BankID callback received

    age_check --> rejected_underage : Age < 18
    age_check --> user_lookup : Age >= 18

    rejected_underage --> [*]

    user_lookup --> existing_user : national_id_hash found
    user_lookup --> new_user : national_id_hash not found

    existing_user --> dashboard : Session created, redirect

    new_user --> user_created : Auto-register from BankID data
    user_created --> consent_collection : Redirect to /onboarding

    consent_collection --> consents_granted : Terms + privacy accepted
    consents_granted --> kyc_background : Sumsub screening starts

    kyc_background --> bank_linking : Screening runs in background
    bank_linking --> bank_consent : User selects bank
    bank_consent --> bank_authorized : AISP consent granted
    bank_authorized --> account_linked : Balance fetched

    account_linked --> onboarding_complete : First bank account linked
    onboarding_complete --> dashboard : Redirect to /dashboard

    state kyc_background {
        [*] --> sumsub_pending
        sumsub_pending --> screening
        screening --> kyc_approved : All clear
        screening --> kyc_review : Match found
        kyc_review --> kyc_approved : Cleared by compliance
        kyc_review --> kyc_rejected : Confirmed risk
    }

    dashboard --> [*]

Age Verification (18+)

Norwegian fodselsnummer (11-digit personal identification number) encodes the date of birth:

Digits Meaning Example
1-2 Day of birth (DD) 15
3-4 Month of birth (MM) 03
5-6 Year of birth (YY) 95
7-9 Individual number 123
10-11 Check digits 45

Century determination (from individual number, digits 7-9):

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:

Additional signals:

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)

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:

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))
Endpoint Method Purpose
GET /api/consents GET List all user consents
POST /api/consents POST Grant or withdraw consent
  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

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


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

Low-Level Design (LLD)

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

sequenceDiagram
    participant User
    participant Drop as Drop API
    participant BankID
    participant Sumsub
    participant DB as PostgreSQL

    Note over User,DB: Phase 1 -- BankID Identity Verification
    User->>Drop: Login via BankID OIDC
    Drop->>BankID: Exchange auth code for ID token
    BankID->>Drop: ID token (pid, name, DOB)
    Drop->>Drop: Parse pid (fodselsnummer)
    Drop->>Drop: Verify age >= 18
    Drop->>Drop: Hash pid with SHA-256
    Drop->>DB: Find/create user (national_id_hash)
    Drop->>DB: Set kyc_status = 'approved', kyc_method = 'bankid'

    Note over User,DB: Phase 2 -- Sumsub Enhanced Verification
    Drop->>Sumsub: Create applicant (user_id, name, DOB)
    Sumsub->>Drop: applicant_id
    Drop->>DB: Store applicant_id in users table
    Drop->>User: Request document upload (if EDD triggered)

    alt Standard CDD (low risk)
        Note over User,Sumsub: BankID sufficient -- no document upload
        Sumsub->>Sumsub: Auto-approve based on BankID data
    else Enhanced CDD (high risk)
        User->>Sumsub: Upload ID document + selfie
        Sumsub->>Sumsub: Document verification + liveness check
    end

    Note over User,DB: Phase 3 -- PEP/Sanctions Screening
    Sumsub->>Sumsub: Screen against PEP lists
    Sumsub->>Sumsub: Screen against sanctions (OFAC, UN, EU, Norway)
    Sumsub->>Sumsub: Screen adverse media

    Sumsub->>Drop: Webhook: verification result
    Drop->>DB: Update kyc_status (approved/rejected)
    Drop->>DB: Insert screening_results (pep, sanctions, adverse_media)
    Drop->>DB: Update users.risk_level, pep_status, sanctions_cleared

    alt Screening match found
        Drop->>DB: Create aml_alert (severity based on match type)
        Drop->>Drop: Block user from transactions
    end

KYC Applicant States

stateDiagram-v2
    [*] --> bankid_verified : BankID login successful
    bankid_verified --> sumsub_pending : Sumsub applicant created
    sumsub_pending --> document_requested : EDD required (high risk)
    sumsub_pending --> screening : CDD sufficient (low risk)
    document_requested --> document_uploaded : User uploads ID + selfie
    document_uploaded --> document_review : Sumsub processes documents
    document_review --> screening : Documents verified
    document_review --> document_rejected : Documents invalid
    document_rejected --> document_requested : User retries
    screening --> approved : All clear (PEP, sanctions, media)
    screening --> manual_review : Potential match found
    manual_review --> approved : Compliance officer clears
    manual_review --> rejected : Confirmed match or fraud
    approved --> ongoing_monitoring : Periodic re-screening
    ongoing_monitoring --> manual_review : Re-screening match
    ongoing_monitoring --> approved : Re-screening clear
    rejected --> [*]

Document Verification Steps

When Enhanced Due Diligence (EDD) is triggered, Sumsub performs multi-step document verification:

Step Check Provider Pass Criteria
1. Document quality Image clarity, glare, blur Sumsub AI Readable text, clear photo
2. Document authenticity Hologram detection, font analysis, template matching Sumsub AI Matches known document templates
3. Data extraction OCR: name, DOB, document number, expiry Sumsub OCR All fields extracted successfully
4. Cross-reference Extracted data vs BankID data (name, DOB) Drop API Name and DOB match within tolerance
5. Liveness check Selfie vs document photo, anti-spoofing Sumsub AI Face match > 80%, liveness confirmed
6. Expiry check Document expiration date Sumsub Document not expired

Accepted documents (Norway-specific):


PEP/Sanctions Screening

Screening Sources

List Source Update Frequency Scope
Norwegian PEP list Finanstilsynet Real-time Norwegian politically exposed persons
OFAC SDN US Treasury Daily Global sanctions
UN Consolidated UN Security Council Real-time Global sanctions
EU Consolidated European Commission Daily EU sanctions
Norwegian sanctions Utenriksdepartementet As published Norway-specific restrictions
Adverse media Sumsub media monitoring Continuous Negative news, legal proceedings

Screening Triggers

Trigger Type Screening Scope
New user registration Initial CDD Full: PEP + sanctions + adverse media
Transaction > 10,000 NOK Transaction monitoring Sanctions only (real-time)
Cumulative 30-day > 50,000 NOK Periodic monitoring Full re-screening
Quarterly schedule Ongoing due diligence Full re-screening of all active users
User data change Event-driven Full re-screening

Database: screening_results table

Column Type Values
screening_type TEXT pep, sanctions, adverse_media
provider TEXT sumsub (or future: refinitiv, dow_jones)
result TEXT clear, match, potential_match, error
match_details TEXT (JSON) Match metadata (name similarity, list source, entry details)

Risk Scoring Algorithm

Drop assigns a risk level to each user based on multiple factors. The risk score determines the level of due diligence applied.

Risk Factor Matrix

Factor Low Risk (1 pt) Medium Risk (3 pts) High Risk (5 pts)
Country of origin Norway, Sweden, Denmark, Finland EU/EEA countries Non-EEA, high-risk jurisdictions (FATF grey/black list)
Remittance corridor SEPA (intra-EEA) Non-EEA low-risk Pakistan, Turkey, non-EEA high-risk
Transaction volume (30-day) < 10,000 NOK 10,000-50,000 NOK > 50,000 NOK
Transaction frequency (30-day) < 5 transactions 5-20 transactions > 20 transactions
PEP status Not PEP PEP family member PEP (direct)
Sanctions screening Clear Potential match (resolved) Active match
Account age > 12 months 3-12 months < 3 months
Adverse media None Resolved/historical Active negative coverage

Risk Level Classification

Total Score Risk Level Due Diligence Monitoring Transaction Limits
8-12 Low Standard CDD (BankID only) Quarterly re-screening 50,000 NOK/month
13-20 Medium Enhanced CDD (BankID + document) Monthly re-screening 25,000 NOK/month
21-30 High Enhanced CDD + source of funds Weekly re-screening 10,000 NOK/month
31+ Prohibited Account blocked Continuous 0 (blocked)

Database: users risk fields

Column Type Purpose
risk_level TEXT low, medium, high, prohibited
pep_status TEXT none, pep_family, pep_direct
sanctions_cleared INTEGER 0 = not cleared, 1 = cleared

AML Transaction Monitoring

Alert Rules

Rule ID Alert Type Trigger Severity Description
AML-001 structuring Multiple transactions just below 10,000 NOK threshold high Potential structuring to avoid reporting
AML-002 velocity > 5 transactions in 1 hour medium Unusual transaction velocity
AML-003 high_value Single transaction > 25,000 NOK medium Large transaction review
AML-004 cumulative 30-day total > 50,000 NOK high Cumulative volume threshold
AML-005 corridor_risk Transfer to FATF grey/black list country high High-risk corridor
AML-006 new_account_high_value Account < 30 days + transaction > 5,000 NOK medium New account with large transfer
AML-007 round_amounts Multiple transactions with round amounts (1000, 5000, 10000) low Potential structuring pattern
AML-008 rapid_recipient_add > 3 new recipients in 24 hours medium Unusual recipient creation pattern

Database: aml_alerts table

Column Type Values
alert_type TEXT structuring, velocity, high_value, cumulative, corridor_risk, etc.
severity TEXT low, medium, high, critical
status TEXT open, investigating, resolved, escalated, filed
reviewed_by TEXT Compliance officer identifier

Alert Lifecycle

stateDiagram-v2
    [*] --> open : Rule triggered
    open --> investigating : Compliance officer reviews
    investigating --> resolved : False positive confirmed
    investigating --> escalated : Suspicious activity confirmed
    escalated --> filed : STR filed with Okokrim
    resolved --> [*]
    filed --> [*]

    note right of escalated
        Auto-block user transactions
        until STR processed
    end note

SAR/STR Filing

When an AML alert is escalated, a Suspicious Transaction Report (STR) is filed with Okokrim/EFE (Norwegian Financial Intelligence Unit) per hvitvaskingsloven section 4-26.

STR Filing Trigger Conditions

Condition Action Timeline
AML alert severity = critical Automatic STR draft + compliance notification Immediately
AML alert escalated to filed STR submitted to Okokrim Within 24 hours of escalation
Compliance officer judgment Manual STR creation As determined
User account matches sanctions list Immediate freeze + STR Immediately

Database: str_reports table

Column Type Values
report_type TEXT suspicious_transaction, sanctions_match, terrorism_financing
status TEXT draft, submitted, acknowledged
filed_at TEXT Timestamp of submission to Okokrim
reference_number TEXT Okokrim reference (returned on submission)

STR Content (per hvitvaskingsloven section 4-26)

Field Source Description
Reporter details Drop company info ALAI Holding AS, org number, contact
Subject details users table Name, DOB, national_id_hash, address
Transaction details transactions table Amount, currency, date, recipient, corridor
Suspicious indicators aml_alerts table Alert type, pattern description, severity
Supporting evidence audit_log table Login history, transaction history, behavioral anomalies
Reporter assessment Compliance officer Narrative summary of suspicion basis

Ongoing Monitoring Schedule

Activity Frequency Scope Automated
PEP/sanctions re-screening Quarterly (low risk), Monthly (medium), Weekly (high) All active users at applicable risk level Yes (Sumsub batch API)
Transaction pattern analysis Real-time All transactions Yes (AML rule engine)
Cumulative volume check Daily All users with transactions in last 30 days Yes (scheduled job)
High-risk corridor review Weekly Users with transfers to FATF grey/black list countries Yes (automated report)
Dormant account review Monthly Accounts with no activity for 6+ months then sudden activity Yes (scheduled job)
Full compliance audit Annually All users, all transactions, all alerts Manual + automated

GDPR Data Minimization for KYC Data

Per GDPR Article 5(1)(c) and Article 25 (data protection by design), KYC data collection and retention must be minimized.

Data Retention Table

Data Category Data Elements Retention Period Legal Basis Deletion Method
Identity (BankID) national_id_hash, name, DOB 5 years after account closure Hvitvaskingsloven s. 4-18 (AML record keeping) Hard delete after retention
KYC documents Passport/ID images, selfie 5 years after account closure Hvitvaskingsloven s. 4-18 Sumsub retention + local reference deletion
Screening results PEP/sanctions/media results 5 years after last screening Hvitvaskingsloven s. 4-18 Hard delete after retention
AML alerts Alert details, investigation notes 5 years after alert closure Hvitvaskingsloven s. 4-18 Hard delete after retention
STR reports Filed reports, evidence packages 10 years after filing Hvitvaskingsloven s. 4-19 (STR records) Hard delete after retention
Transaction records Amount, currency, parties, timestamps 5 years after transaction Bokforingsloven (accounting law) Hard delete after retention
Consent records Consent type, granted/withdrawn timestamps Duration of relationship + 3 years GDPR Art. 7(1) (proof of consent) Hard delete after retention
Audit logs User actions, IP addresses, user agents 2 years Legitimate interest (security) Hard delete after retention

Data Minimization Controls

Control Implementation GDPR Article
Collect only necessary data BankID provides verified name + DOB; no separate address collection until needed Art. 5(1)(c)
Purpose limitation KYC data used only for AML compliance, not marketing Art. 5(1)(b)
Storage limitation Automated retention policies with scheduled deletion jobs Art. 5(1)(e)
Pseudonymization National ID stored as SHA-256 hash, not plaintext Art. 25
Access control KYC data accessible only to compliance role Art. 25
Right to erasure Soft delete (set deleted_at) but retain AML-required data for legal period Art. 17(3)(b)
Data portability GET /api/user/data-export exports all personal data as JSON Art. 20

Conflict: GDPR Erasure vs AML Retention

When a user requests account deletion (DELETE /api/user/account):

  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

Low-Level Design (LLD)

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:

The linking flow requires AISP consent from the user's bank (ASPSP), authenticated via BankID SCA at the bank. Drop stores the consent reference and caches the balance in the bank_accounts table.

Key principle: Drop never holds money. The bank_accounts.balance column is a cached read from the user's real bank account via AISP.


2. Complete Bank Linking Flow

sequenceDiagram
    participant U as User
    participant UI as Drop UI<br/>(/accounts)
    participant API as Drop API
    participant DB as Drop DB
    participant ASPSP as User's Bank<br/>(e.g., DNB)

    Note over U,ASPSP: Step 1: Bank Selection
    U->>UI: Tap "Koble til bank" (Link bank)
    UI->>UI: Show bank selection list<br/>(DNB, SpareBank 1, Nordea, ...)
    U->>UI: Select "DNB"

    Note over U,ASPSP: Step 2: AISP Consent Request
    UI->>API: POST /api/accounts/link<br/>{bankId: "dnb"}
    API->>API: Verify user authenticated<br/>(JWT from drop_token cookie)
    API->>ASPSP: POST /v1/consents<br/>{access: {balances: ["allAccounts"],<br/>transactions: ["allAccounts"]},<br/>recurringIndicator: true,<br/>validUntil: "2026-05-22",<br/>frequencyPerDay: 4,<br/>combinedServiceIndicator: false}
    ASPSP-->>API: 201 Created<br/>{consentId: "cons_abc123",<br/>consentStatus: "received",<br/>_links: {scaRedirect:<br/>"https://dnb.no/psd2/consent/authorize?id=..."}}
    API->>DB: INSERT INTO consents<br/>(consent_type: 'psd2_aisp',<br/>granted: 0, aspsp_consent_id: cons_abc123)
    API-->>UI: {redirectUrl: "https://dnb.no/psd2/consent/authorize?id=..."}

    Note over U,ASPSP: Step 3: SCA at Bank
    UI->>U: Redirect to bank consent page
    U->>ASPSP: View consent details<br/>"Drop requests access to your<br/>account balances and transactions"
    U->>ASPSP: Authenticate with BankID<br/>(possession + knowledge/inherence)
    ASPSP-->>U: Consent granted<br/>Redirect to Drop callback

    Note over U,ASPSP: Step 4: Callback & Account Retrieval
    U->>API: GET /api/accounts/link/callback<br/>?consentId=cons_abc123&state=xyz
    API->>API: Verify state parameter
    API->>ASPSP: GET /v1/consents/cons_abc123/status
    ASPSP-->>API: {consentStatus: "valid"}
    API->>DB: UPDATE consents SET granted = 1,<br/>granted_at = now

    API->>ASPSP: GET /v1/accounts<br/>(with consentId header)
    ASPSP-->>API: {accounts: [<br/>{resourceId: "acc_1", iban: "NO1234567890123",<br/>currency: "NOK", name: "Brukskonto"},<br/>{resourceId: "acc_2", iban: "NO9876543210987",<br/>currency: "NOK", name: "Sparekonto"}]}

    Note over U,ASPSP: Step 5: Balance Fetch & Storage
    loop For each account
        API->>ASPSP: GET /v1/accounts/{resourceId}/balances
        ASPSP-->>API: {balances: [{balanceType: "expected",<br/>balanceAmount: {currency: "NOK",<br/>amount: "45230.00"}}]}
        API->>DB: INSERT INTO bank_accounts<br/>(bank_name: "DNB",<br/>account_number: "NO12...0123",<br/>iban: "NO1234567890123",<br/>balance: 4523000,<br/>balance_synced_at: now,<br/>is_primary: first ? 1 : 0)
    end

    API-->>UI: {success: true,<br/>accounts: [{bankName: "DNB",<br/>balance: 45230.00, currency: "NOK"}]}
    UI-->>U: "DNB koblet til!" (DNB linked!)<br/>Show accounts with balances

3. Linked Account States

stateDiagram-v2
    [*] --> Unlinked: User has no linked bank accounts

    Unlinked --> ConsentRequested: User taps "Koble til bank"<br/>POST /v1/consents to ASPSP
    ConsentRequested --> ScaPending: ASPSP returns scaRedirect
    ScaPending --> Active: User completes BankID SCA<br/>consentStatus = "valid"
    ScaPending --> Failed: SCA timeout (5 min)
    ScaPending --> Failed: User cancels BankID
    ScaPending --> Failed: Bank rejects consent

    Active --> Active: Balance refresh<br/>(on-demand or scheduled,<br/>max 4x/day TPP-initiated)
    Active --> SyncError: ASPSP returns error on balance read<br/>Show last cached balance
    SyncError --> Active: Next successful balance read
    Active --> ConsentExpiring: 30 days before consent expiry<br/>Notify user to renew
    ConsentExpiring --> RenewalPending: User taps "Forny tilgang"<br/>(Renew access)
    RenewalPending --> ScaPending: New consent + SCA
    ConsentExpiring --> Expired: User ignores renewal

    Active --> Unlinked: User taps "Fjern konto"<br/>(Remove account)<br/>DELETE /v1/consents/{id}
    Active --> Suspended: ASPSP revokes consent
    Expired --> Unlinked: Consent expired,<br/>balance zeroed,<br/>notify user
    Suspended --> Unlinked: User must re-link
    Failed --> Unlinked: User can retry

    Unlinked --> [*]

4. Detailed Steps

4.1 Step 1: Bank Selection

UI: Bank Accounts screen (/accounts) shows a "Link bank account" button. Tapping it displays a list of supported Norwegian banks.

Supported banks (initial):

Bank Berlin Group API Logo
DNB https://api.dnb.no/psd2/ DNB logo asset
SpareBank 1 https://api.sparebank1.no/open-banking/ SB1 logo asset
Nordea https://api.nordeaopenbanking.com/ Nordea logo asset
Sbanken Via SpareBank 1 API Sbanken logo asset

4.2 Step 2: AISP Consent Creation

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

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
DNB Brukskonto
-- kr
Tilgangen er utlopt. Koble til paa nytt.
[Koble til] button

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

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

Low-Level Design (LLD)

QR Payment Flow

Flow: QR Payment

Document: LLD-002 Version: 1.0 Date: 2026-02-21 Author: Frontend Architect (AI Agent) Status: Draft Scope: End-to-end QR payment flow covering camera handling, merchant resolution, payment confirmation, SCA, and error states for both web and mobile


1. Overview

QR payments allow Drop users to pay in-store by scanning a merchant's QR code. The payment is initiated via PISP (Payment Initiation Service Provider) directly from the user's bank account under the PSD2 pass-through model. Drop never holds customer funds.

QR Code Format: drop://pay/{merchantId}

Payment Fee: 1% of transaction amount (fee is deducted from user's balance: totalOre = amountOre + feeOre. Settlement to merchant is separate.)

Note: Database stores amounts in ore (minor units). API accepts NOK and converts internally using nokToOre(). Example: 129 NOK = 12900 ore in DB.

Flow summary: Camera permission → QR scan → decode merchant ID → fetch merchant details → amount entry → SCA trigger → payment confirmation → receipt display


2. QR Payment Sequence Diagram

sequenceDiagram
    actor User
    participant App as Drop App<br/>(Web/Mobile)
    participant Camera as Camera API<br/>(Browser/Native)
    participant API as Drop API<br/>(/api/transactions)
    participant Bank as User's Bank<br/>(via PISP)
    participant DB as Database

    User->>App: Navigate to /scan
    App->>App: Check auth (useAuth / Bearer token)

    alt Camera available
        App->>Camera: Request camera permission
        Camera-->>App: Permission granted
        App->>App: Show camera viewfinder<br/>with scan frame brackets
    else Camera denied / unavailable
        App->>App: Show "Simuler skanning" button<br/>(demo mode fallback)
    end

    User->>Camera: Point at merchant QR code
    Camera->>App: Decode QR: "drop://pay/{merchantId}"

    App->>App: Parse merchantId from QR URI
    App->>API: GET /api/merchants/{merchantId}
    API->>DB: SELECT merchant WHERE id = ?
    API-->>App: { merchantId, businessName, category }

    App->>App: Show merchant info + amount input
    User->>App: Enter amount (e.g., 129 NOK)

    App->>App: Calculate fee (1% = 1.29 NOK)
    App->>App: Show payment summary<br/>(amount, fee, total, source account)

    User->>App: Tap "Betal nå" (confirm)

    App->>API: POST /api/transactions/qr-payment<br/>{ merchantId, amount }
    API->>API: Rate limit check (10/min)
    API->>DB: Verify merchant exists
    API->>DB: Get user's primary bank account
    API->>API: Calculate fee (1% of amount)

    Note over API,Bank: Production: PISP initiates<br/>payment from user's bank.<br/>Demo: Direct DB debit.

    API->>DB: BEGIN TRANSACTION
    API->>DB: UPDATE bank_accounts SET balance = balance - (amount + fee)
    API->>DB: INSERT transaction (type=qr_payment, status=completed)
    API->>DB: COMMIT

    API-->>App: 201 { transaction }

    App->>App: Show success screen<br/>(checkmark, merchant, amount, fee)

    User->>App: Tap "Tilbake til hjem"
    App->>App: Navigate to /dashboard

3. Payment Flow State Diagram

stateDiagram-v2
    [*] --> Idle: Navigate to /scan

    Idle --> RequestingPermission: Camera API available
    Idle --> SimulationMode: No camera / demo mode

    RequestingPermission --> Scanning: Permission granted
    RequestingPermission --> PermissionDenied: Permission denied

    PermissionDenied --> Scanning: User grants in settings
    PermissionDenied --> SimulationMode: Use simulation

    SimulationMode --> MerchantResolved: Click "Simuler skanning"

    Scanning --> Decoding: QR code detected
    Decoding --> MerchantResolved: Valid drop:// URI
    Decoding --> InvalidQR: Not a Drop QR code

    InvalidQR --> Scanning: Dismiss error, retry scan

    MerchantResolved --> AmountEntry: Merchant details loaded
    MerchantResolved --> MerchantNotFound: Merchant lookup failed

    MerchantNotFound --> Scanning: Go back, scan again

    AmountEntry --> PaymentReview: Amount entered + confirmed
    AmountEntry --> Scanning: Cancel / go back

    PaymentReview --> Processing: Tap "Betal nå"
    PaymentReview --> AmountEntry: Edit amount

    Processing --> Success: Payment completed (201)
    Processing --> InsufficientFunds: Balance too low
    Processing --> PaymentFailed: API error

    InsufficientFunds --> AmountEntry: Adjust amount
    PaymentFailed --> PaymentReview: Retry

    Success --> [*]: Navigate to dashboard

4. Camera Permission Handling

4.1 Camera Permission Table (iOS / Android)

Platform Permission API First Request After Denial Settings Redirect
iOS (Safari) navigator.mediaDevices.getUserMedia() System prompt: "getdrop.no would like to access the camera" Blocked silently; must reset in Safari Settings → getdrop.no → Camera Link to Settings not programmatically available
iOS (Expo) expo-camera Camera.requestCameraPermissionsAsync() System prompt: "Drop would like to access the camera" Returns { status: 'denied' }; use Linking.openSettings() Linking.openSettings() → iOS Settings → Drop → Camera
Android (Chrome) navigator.mediaDevices.getUserMedia() System prompt: "Allow getdrop.no to use your camera?" Blocked; user must tap lock icon → Site settings → Camera → Allow Site settings accessible via address bar
Android (Expo) expo-camera Camera.requestCameraPermissionsAsync() System prompt: "Allow Drop to take pictures and record video?" Returns { status: 'denied' }; { canAskAgain: false } after permanent deny Linking.openSettings() → App Info → Permissions → Camera

4.2 Fallback Behavior

When camera is unavailable or denied:


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:


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


8. Data Flow

8.1 Request: POST /api/transactions/qr-payment

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

8.2 Response: 201 Created

{
  "data": {
    "id": "tx_qr_a1b2c3d4e5f6g7h8",
    "type": "qr_payment",
    "status": "completed",
    "amount": 129,
    "currency": "NOK",
    "fee": 1.29,
    "feePercent": 1,
    "merchantName": "Ahmetov Kebab",
    "merchantId": "mer_1",
    "fromAccount": "DNB",
    "createdAt": "2026-02-21T14:30:00.000Z"
  }
}

8.3 Database Operations (Atomic Transaction)

BEGIN;
UPDATE bank_accounts SET balance = balance - 130.29 WHERE id = ? AND user_id = ?;
INSERT INTO transactions (id, user_id, type, status, amount, currency, fee, merchant_id, created_at, completed_at)
  VALUES (?, ?, 'qr_payment', 'completed', 129, 'NOK', 1.29, ?, datetime('now'), datetime('now'));
COMMIT;

9. Production vs Demo Differences

Aspect Demo (Current) Production (Phase 2+)
Camera Simulated scan button Real camera scanning via expo-camera or getUserMedia()
Payment execution Direct DB balance debit PISP initiation via Open Banking API
SCA Not implemented BankID SCA required for each payment
Merchant verification Static seed data (Ahmetov Kebab) Live Brønnøysund org number verification
Fee handling Fee deducted from user's balance (totalOre = amountOre + feeOre) Merchant settlement is separate from user debit
Settlement Instant (DB update) T+1 or T+2 settlement to merchant bank account

10. Accessibility Considerations (WCAG 2.1 AA)

Requirement Implementation
Camera alternative "Simuler skanning" button provides non-camera path
Amount input Labeled with "Beløp" and suffixed with "NOK"
Confirmation "Betal nå" button clearly labeled; "Avbryt" provides escape
Success feedback Visual checkmark + text confirmation of payment
Color contrast Gold (#D4A017) on dark (#0F172A) = 5.2:1 ratio (passes AA)
Screen reader Merchant name and amount announced on payment confirmation

11. Cross-References

Low-Level Design (LLD)

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

sequenceDiagram
    participant U as User
    participant UI as Drop App<br/>(/send)
    participant API as Drop API
    participant DB as Drop DB
    participant ASPSP as User's Bank
    participant RB as Recipient Bank

    Note over U,RB: Step 1 — Select Recipient
    U->>UI: Navigate to Send Money
    UI->>API: GET /api/recipients?page=1&limit=20
    API->>DB: SELECT * FROM recipients<br/>WHERE user_id = ? ORDER BY created_at DESC
    DB-->>API: [{id: "rec_1", name: "Marko Petrovic",<br/>country: "Serbia", currency: "RSD"}]
    API-->>UI: Recipient list (bank accounts masked)
    U->>UI: Select "Marko Petrovic"

    Note over U,RB: Step 2 — Enter Amount
    UI->>API: GET /api/rates/RSD
    API->>DB: SELECT rate FROM exchange_rates<br/>WHERE to_currency = 'RSD'
    DB-->>API: {rate: 10.17}
    API-->>UI: {rate: 10.17, fee: 0.005}
    U->>UI: Enter 2000 NOK
    UI->>UI: Live calculation:<br/>Send: 2,000 NOK<br/>Fee: 10 NOK (0.5%)<br/>Rate: 1 NOK = 10.17 RSD<br/>Receives: 20,340 RSD

    Note over U,RB: Step 3 — Review (PSD2 Art. 45 Disclosure)
    U->>UI: Tap "Neste" (Next)
    UI->>API: POST /api/transactions/disclosure<br/>{type: "remittance", amount: 2000,<br/>recipientId: "rec_1"}
    API->>DB: Lookup recipient currency, exchange rate
    API->>API: Calculate fee (2000 * 0.005 = 10)<br/>Calculate receive (2000 * 10.17 = 20340)<br/>Determine delivery (non-EEA: 2-4 days)
    API-->>UI: {amount: 2000, fee: 10, feePercentage: 0.5,<br/>exchangeRate: 10.17, receiveAmount: 20340,<br/>receiveCurrency: "RSD",<br/>estimatedDelivery: "2-4 business days",<br/>totalCost: 2010}
    UI->>UI: Display disclosure screen:<br/>"Du sender 2 000 kr til Marko Petrovic<br/>Gebyr: 10 kr (0,5%)<br/>Vekslingskurs: 1 NOK = 10,17 RSD<br/>Marko mottar: 20 340 RSD<br/>Total kostnad: 2 010 kr<br/>Estimert levering: 2-4 virkedager"

    Note over U,RB: Step 4 — Confirm & Payment Initiation
    U->>UI: Tap "Bekreft og send" (Confirm and send)
    UI->>API: POST /api/transactions/remittance<br/>{recipientId: "rec_1", amount: 2000,<br/>bankAccountId: "ba_1"}
    API->>DB: Verify KYC: kyc_status = 'approved'
    API->>DB: Verify recipient belongs to user
    API->>DB: Verify bank account exists + balance >= 2010
    API->>DB: Lookup exchange rate for RSD
    API->>DB: Generate idempotency_key<br/>BEGIN TRANSACTION<br/>UPDATE bank_accounts SET balance = balance - 201000<br/>INSERT INTO transactions (status: 'processing')
    API->>DB: COMMIT

    API->>ASPSP: POST /v1/payments/cross-border-credit-transfers<br/>{debtorAccount: {iban: user_iban},<br/>instructedAmount: {currency: "NOK", amount: "2010.00"},<br/>creditorName: "Marko Petrovic",<br/>creditorAccount: {bban: "265-1234567-89"},<br/>remittanceInformationUnstructured: "Drop remittance tx_rem_xxx"}
    ASPSP-->>API: {paymentId: "pay_xyz",<br/>transactionStatus: "RCVD",<br/>scaRedirect: "https://dnb.no/sca/pay/..."}
    API-->>UI: {transactionId: "tx_rem_xxx",<br/>scaRedirect: "https://dnb.no/sca/pay/..."}

    Note over U,RB: SCA at Bank (Dynamic Linking)
    UI->>U: Redirect to bank SCA page
    U->>ASPSP: BankID authentication<br/>(sees: "2 010 NOK to Marko Petrovic")
    ASPSP-->>U: Redirect to Drop callback

    U->>API: GET /api/payments/callback?paymentId=pay_xyz
    API->>ASPSP: GET /v1/payments/pay_xyz/status
    ASPSP-->>API: {transactionStatus: "ACCP"}
    API->>DB: UPDATE transactions<br/>SET status = 'completed',<br/>completed_at = now<br/>WHERE id = 'tx_rem_xxx'
    API->>DB: INSERT INTO audit_log<br/>(action: 'payment.completed')
    API->>DB: INSERT INTO notifications<br/>(title: 'Overfoering sendt',<br/>body: '2 000 kr sendt til Marko Petrovic')
    API-->>UI: {status: "completed"}
    UI-->>U: Success screen:<br/>"2 000 kr sendt til Marko Petrovic!<br/>Estimert levering: 2-4 virkedager"

    Note over ASPSP,RB: Settlement (Drop is not involved)
    ASPSP->>RB: SWIFT gpi / correspondent banking<br/>NOK converted to RSD
    RB->>RB: Credit Marko's account: 20,340 RSD

3. Transaction States

stateDiagram-v2
    [*] --> Draft: User on review screen<br/>(disclosure shown, not yet confirmed)

    Draft --> Initiated: User taps "Bekreft og send"<br/>Transaction record created<br/>(status: processing)
    Draft --> Abandoned: User navigates away<br/>(no record created)

    Initiated --> ScaPending: ASPSP returns scaRedirect<br/>User redirected to bank
    Initiated --> Failed: ASPSP rejects initiation<br/>(invalid IBAN, bank error)

    ScaPending --> Completed: User completes BankID SCA<br/>ASPSP status: ACCP/ACSC
    ScaPending --> Failed: SCA timeout (5 min)
    ScaPending --> Failed: User cancels SCA
    ScaPending --> Failed: Bank rejects payment<br/>(insufficient funds at bank)

    Completed --> Settled: Funds credited to recipient<br/>(tracked via ASPSP status polling)
    Completed --> RefundPending: Settlement failed<br/>(correspondent bank error)

    Failed --> [*]: User sees error,<br/>can retry from Step 1

    RefundPending --> Refunded: Refund processed<br/>Balance restored
    Refunded --> [*]
    Settled --> [*]
    Abandoned --> [*]

Database status values (transactions.status CHECK constraint):


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:

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

Low-Level Design (LLD)

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:


2. Merchant Registration Flow

2.1 Sequence Diagram — Registration to Active Merchant

sequenceDiagram
    actor User
    participant App as Drop App
    participant API as Drop API<br/>(/api/merchants)
    participant Brreg as Brønnøysund<br/>Register (prod)
    participant DB as Database

    User->>App: Navigate to merchant registration
    App->>App: useAuth() — verify authenticated

    User->>App: Fill registration form<br/>(businessName, orgNumber, address, bankAccount)

    App->>App: Client-side validation<br/>(name: validateName, orgNumber: 9 digits)

    App->>API: POST /api/merchants/register<br/>{ businessName, orgNumber, address, bankAccount }

    API->>API: JWT verification
    API->>API: Validate input fields

    alt Production
        API->>Brreg: GET /enhetsregisteret/api/enheter/{orgNumber}
        Brreg-->>API: { organisasjonsnummer, navn, organisasjonsform, ... }
        API->>API: Verify business exists and is active
    else Demo
        API->>API: Format validation only (9 digits, unique)
    end

    API->>DB: Check org_number uniqueness
    API->>DB: INSERT merchant (user_id, business_name, org_number, bank_account, fee_rate=0.01)
    API->>DB: UPDATE users SET role = 'merchant' WHERE id = ?

    API->>API: Generate QR URI: drop://pay/{merchantId}

    API-->>App: 201 { merchant: { id, businessName, orgNumber, qrUri } }

    App->>App: Show success screen<br/>(QR code display, "Vis min QR-kode" button)
    App->>App: Navigate to merchant dashboard

3. Merchant Dashboard Components

3.1 Component Diagram

graph TD
    subgraph "Merchant Dashboard"
        Header["Header<br/>'VELKOMMEN' + business name<br/>+ Settings button"]
        PeriodFilter["PeriodFilter<br/>(I dag / Uke / Maaned)"]
        RevenueCard["RevenueCard<br/>(green gradient)"]
        QRButton["QRButton<br/>'Vis min QR-kode'"]
        TransactionList["TransactionList<br/>'Dagens transaksjoner'"]
        BottomNav["BottomNav"]
    end

    subgraph "Revenue Card"
        TotalRevenue["Total omsetning<br/>(4xl Fraunces font)"]
        StatsGrid["Stats Grid (2 cols)"]
        TxCount["Transaksjoner<br/>(count)"]
        FeesInfo["Gebyrer betalt<br/>(NOK amount)"]
    end

    subgraph "Transaction Item"
        CustomerIcon["CheckCircle2<br/>(green)"]
        CustomerName["Customer Name<br/>(partially anonymized)"]
        TxTime["Timestamp"]
        TxAmount["Amount<br/>(+NOK, green)"]
    end

    RevenueCard --> TotalRevenue
    RevenueCard --> StatsGrid
    StatsGrid --> TxCount
    StatsGrid --> FeesInfo

    TransactionList --> CustomerIcon
    TransactionList --> CustomerName
    TransactionList --> TxTime
    TransactionList --> TxAmount

4. Business Verification Checklist

4.1 Registration Requirements

Requirement Field Validation Demo Production
Business name businessName validateName() — 1-100 chars, at least one letter, no HTML/script Format check only Format check
Organization number orgNumber Exactly 9 digits, unique in DB Format + uniqueness Brønnøysund API lookup
Business address address Optional, sanitized to 300 chars Optional Required for settlement
Payout bank account bankAccount Required, non-empty Format check IBAN/account validation
User authentication JWT Valid BankID session Required Required
KYC status user.kycStatus Must be approved Auto-approved via BankID BankID verification

4.2 Brønnøysundregistrene Verification (Production)

Check API Response Field Pass Criteria
Business exists GET /enhetsregisteret/api/enheter/{orgNr} organisasjonsnummer Matches input
Business is active Same registreringsdatoEnhetsregisteret Not null
Business type Same organisasjonsform.kode AS, ENK, NUF, DA, ANS
Business name match Same navn Approximate match to submitted name

5. Settlement Schedule

5.1 Settlement Schedule Table

Period Settlement Day Payout Time Details
Daily transactions T+1 08:00 CET Next business day after transaction
Weekend transactions Monday 08:00 CET Batched for Monday payout
Holiday transactions Next business day 08:00 CET Following Norwegian business day

5.2 Settlement Calculation

Field Formula Example
Gross revenue Sum of all QR payment amounts 4 350 NOK
Merchant fee Gross x 1% (fee_rate) 43.50 NOK
Net payout Gross - fee 4 306.50 NOK
Payout account merchant.bankAccount IBAN or Norwegian account

5.3 Merchant Dashboard API

Endpoint: GET /api/merchants/dashboard?period={today|week|month}

Response:

{
  "data": {
    "revenue": 4350,
    "transactionCount": 12,
    "fees": 43.5,
    "netRevenue": 4306.5,
    "nextPayout": "2026-02-22T07:00:00.000Z",
    "payoutTime": "Neste virkedag kl. 08:00"
  }
}

6. QR Code Generation

Property Value
Format URI: drop://pay/{merchantId}
Encoding Standard QR code (alphanumeric)
Generation Client-side (from returned qrUri)
Display "Vis min QR-kode" button on merchant dashboard
Printing Merchant can screenshot or print for in-store display

QR endpoint: GET /api/merchants/qr

{
  "data": {
    "merchantId": "mer_a1b2c3d4e5f6g7h8",
    "businessName": "Ahmetov Kebab",
    "qrValue": "drop://pay/mer_a1b2c3d4e5f6g7h8",
    "address": "Gronlandsleiret 44, 0190 Oslo"
  }
}

7. Merchant Transaction Monitoring

7.1 Transaction List

Endpoint: GET /api/merchants/transactions?page=1&limit=20

Field Value Privacy
Customer name First name + last initial Partially anonymized (e.g., "Ola N.")
Amount Positive NOK value Full amount shown
Status "Vellykket" (green) Color-coded
Timestamp HH:MM format Time only for today's transactions

7.2 Period Filtering

Period API Value Dashboard Label Aggregation
Today today I dag Sum of today's transactions
This week week Uke Mon-Sun aggregation
This month month Maaned Calendar month aggregation

8. UI Components (Web)

8.1 Merchant Dashboard Layout

Section Component Description
Header Business name (Fraunces) + Settings icon Welcome greeting + gear icon
Period tabs Button group (I dag, Uke, Maaned) Green active, gray inactive
Revenue card Green gradient card (#0B6E35 to #095a2b) Total omsetning (4xl), stats grid
QR button Full-width green button with QrCode icon "Vis min QR-kode"
Transaction list Card list with CheckCircle2 icons Customer name, time, +amount (green)
Navigation BottomNav (5 tabs) Standard bottom navigation

8.2 Figma Reference

Source of truth: mockups/figma-make-export/src/app/screens/MerchantDashboard.tsx


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:


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

Low-Level Design (LLD)

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:


2. Transaction List Load + Filter + Detail View

2.1 Sequence Diagram

sequenceDiagram
    actor User
    participant App as Drop App<br/>(/transactions)
    participant API as Drop API
    participant DB as Database

    User->>App: Navigate to /transactions
    App->>App: useAuth() — verify authenticated

    App->>API: GET /api/transactions?page=1&limit=20
    API->>API: JWT verification (cookie/Bearer)
    API->>DB: SELECT transactions WHERE user_id = ?<br/>ORDER BY created_at DESC LIMIT 20
    API-->>App: { data: [...], pagination: { page: 1, limit: 20, total: N } }

    App->>App: groupByDate(transactions)<br/>→ "I dag", "I gar", "Denne uken", "Eldre"
    App->>App: Render grouped transaction list

    User->>App: Tap filter "Overforinger"
    App->>API: GET /api/transactions?type=remittance&limit=50
    API->>DB: SELECT WHERE type = 'remittance'
    API-->>App: { data: [...filtered...] }
    App->>App: Re-group and render

    User->>App: Scroll to bottom (infinite scroll)
    App->>API: GET /api/transactions?page=2&limit=20&type=remittance
    API-->>App: { data: [...more...], pagination: { page: 2 } }
    App->>App: Append to list, re-group

    User->>App: Tap transaction row
    App->>API: GET /api/transactions/{id}
    API->>DB: SELECT transaction with exchange_rate info
    API-->>App: { data: { ...fullDetails } }
    App->>App: Show transaction detail modal/page

    User->>App: Tap "Last ned kvittering"
    App->>API: GET /api/transactions/{id}/receipt
    API-->>App: { data: { receipt } }
    App->>App: Download/display receipt

    User->>App: Tap "Klag" (dispute)
    App->>App: Navigate to /complaints<br/>with transaction context

3. Transaction List Components

3.1 Component Diagram

graph TD
    subgraph "Transaction History Page"
        PageHeader["PageHeader<br/>Back button + 'Transaksjonshistorikk' title"]
        FilterTabs["FilterTabs<br/>(Tabs component)"]
        TransactionList["TransactionList<br/>(scrollable area)"]
        LoadMore["LoadMore Trigger<br/>(infinite scroll sentinel)"]
    end

    subgraph "Filter Tabs"
        TabAll["Alle<br/>(all types)"]
        TabRemittance["Overforinger<br/>(type=remittance)"]
        TabQR["QR-betalinger<br/>(type=qr_payment)"]
    end

    subgraph "Transaction List Items"
        DateGroup["DateGroupHeader<br/>('I DAG', 'I GAR', etc.)"]
        TransactionCard["TransactionCard"]
    end

    subgraph "Transaction Card"
        TxIcon["TypeIcon<br/>(ArrowUpRight, ScanLine, Clock)"]
        TxInfo["TxInfo<br/>(name, type label)"]
        TxAmount["TxAmount<br/>(amount + status badge)"]
    end

    subgraph "Transaction Detail"
        DetailHeader["DetailHeader<br/>(back + title)"]
        DetailSummary["DetailSummary<br/>(type, status, date)"]
        AmountBreakdown["AmountBreakdown<br/>(send, receive, rate, fee)"]
        RecipientInfo["RecipientInfo<br/>(name, country, bank)"]
        ReceiptButton["ReceiptButton<br/>('Last ned kvittering')"]
        DisputeButton["DisputeButton<br/>('Klag')"]
    end

    FilterTabs --> TabAll
    FilterTabs --> TabRemittance
    FilterTabs --> TabQR

    TransactionList --> DateGroup
    DateGroup --> TransactionCard
    TransactionCard --> TxIcon
    TransactionCard --> TxInfo
    TransactionCard --> TxAmount

    TransactionCard -->|tap| DetailHeader
    DetailHeader --> DetailSummary
    DetailSummary --> AmountBreakdown
    AmountBreakdown --> RecipientInfo
    RecipientInfo --> ReceiptButton
    RecipientInfo --> DisputeButton

4. Filter Options

4.1 Filter Options Table

Filter API Parameter Value Label (Norwegian) Default
All transactions (none) Alle Yes
Remittances only type remittance Overforinger No
QR payments only type qr_payment QR-betalinger No
Processing status status processing Behandles No
Completed status status completed Fulfort No
Failed status status failed Mislykket No

4.2 Pagination Parameters

Parameter Type Default Min Max Description
page int 1 1 Page number
limit int 20 1 50 Items per page

5. Transaction Status Display Mapping

Status Label (Norwegian) Color Icon Background
completed Fulfort #0B6E35 (green) ArrowUpRight / ScanLine #F8FAFC
processing Behandles #D4A017 (gold) Clock #FEF3C7
failed Mislykket #EF4444 (red) X #FEF2F2

5.1 Transaction Type Icons

Type Icon Color Description
remittance (sent) ArrowUpRight #0B6E35 Outgoing international transfer
qr_payment ScanLine #0B6E35 QR code payment to merchant
remittance (processing) Clock #D4A017 Transfer in progress

5.2 Amount Display

Condition Format Color Example
Outgoing (sent) -{amount} kr #0F172A (dark) -2 000 kr
Incoming (received) +{amount} kr #10B981 (green) +5 000 kr
Processing -{amount} kr #0F172A (dark) -3 000 kr

6. Date Grouping Logic

The groupByDate() function categorizes transactions into temporal groups:

Group Label Condition
Today I DAG createdAt is today
Yesterday I GAR createdAt is yesterday
This Week DENNE UKEN createdAt is within current week (Mon-Sun)
Older Date string (e.g., "12. OKT") All older transactions, grouped by date

7. Transaction Detail View

7.1 Remittance Detail Fields

Field Source Example
Transaction ID data.id tx_rem_a1b2c3d4e5f6g7h8
Type data.type Overforing
Status data.status Fullfort
Send Amount data.sendAmount + data.sendCurrency 2 000 NOK
Receive Amount data.receiveAmount + data.receiveCurrency 23 400 RSD
Exchange Rate data.exchangeRate 1 NOK = 11.70 RSD
Fee data.fee 10.00 NOK (0.5%)
Total Cost data.total 2 010 NOK
Recipient data.recipientName Mama Jasmina
Destination data.recipientCountry Serbia
Created data.createdAt 21. feb 2026 kl. 14:32
Completed data.completedAt 21. feb 2026 kl. 14:35

7.2 QR Payment Detail Fields

Field Source Example
Transaction ID data.id tx_qr_a1b2c3d4e5f6g7h8
Type data.type QR-betaling
Status data.status Fullfort
Amount data.amount 129 NOK
Fee data.fee 1.29 NOK (1%)
Merchant data.merchantName Ahmetov Kebab
Source Account data.fromAccount DNB
Created data.createdAt 21. feb 2026 kl. 12:15

7.3 Receipt Endpoint

GET /api/transactions/{id}/receipt returns a structured receipt:

{
  "data": {
    "transactionId": "tx_rem_1",
    "date": "2026-02-21T14:32:00.000Z",
    "type": "remittance",
    "amount": 2000,
    "currency": "NOK",
    "fee": 10,
    "exchangeRate": 11.7,
    "receiveAmount": 23400,
    "receiveCurrency": "RSD",
    "recipient": { "name": "Mama Jasmina", "country": "RS" },
    "reference": "tx_rem_1",
    "status": "completed",
    "completedAt": "2026-02-21T14:35:00.000Z"
  }
}

8. Dispute Initiation

Users can initiate a dispute from the transaction detail view by navigating to the complaints page:

Step Action
1 User taps "Klag" on transaction detail
2 Navigate to /complaints with pre-filled category = "transaction"
3 User fills subject and description
4 POST /api/complaints { category: "transaction", subject, description }
5 Confirmation: "Vi behandler klagen din innen 15 virkedager" (Finansavtaleloven 3-53)

9. Platform Differences

Feature Web (/transactions) Mobile (history.js)
Filter tabs Tabs component (Alle, Overforinger, QR-betalinger) Custom buttons (Alle, Sendinger, QR)
Pagination Infinite scroll with limit=50 FlatList with pull-to-refresh
Transaction detail Inline expansion or modal Separate screen (not yet implemented)
Receipt download API call + browser download Not implemented
Dispute link Navigate to /complaints Not implemented
Date grouping groupByDate() utility Same pattern
Refresh Re-fetch on filter change Pull-to-refresh via RefreshControl

10. Accessibility Considerations (WCAG 2.1 AA)

Requirement Implementation
List semantics Transaction list uses semantic list markup
Filter announcement Active filter tab announced to screen readers
Amount polarity Positive/negative amounts use text prefix (+/-) in addition to color
Status indication Status uses both color AND text label (not color alone)
Tap targets Transaction cards are full-width, min 48px height
Loading state Skeleton placeholders shown during initial load
Empty state "Ingen transaksjoner" message when list is empty
Keyboard nav Tab cycles through filters, then transaction items

11. Cross-References

Low-Level Design (LLD)

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


2. Profile Load + Settings Update

2.1 Sequence Diagram

sequenceDiagram
    actor User
    participant App as Drop App<br/>(/profile)
    participant API as Drop API
    participant DB as Database

    User->>App: Navigate to /profile
    App->>App: useAuth() — verify authenticated
    App->>API: GET /api/auth/me
    API->>DB: SELECT user + bank_accounts
    API-->>App: { user: { id, firstName, lastName, email, kycStatus, ... } }
    App->>App: Render profile hub<br/>(avatar initials, name, email, menu items)

    User->>App: Tap "Personlig informasjon"
    App->>App: Navigate to /profile/personal
    App->>App: Display BankID-verified fields (read-only)

    User->>App: Tap "Varsler"
    App->>App: Navigate to /profile/notifications
    App->>API: GET /api/settings
    API->>DB: SELECT settings WHERE user_id = ?

    alt Settings exist
        API-->>App: { data: { currency, language, pushEnabled, emailEnabled } }
    else No settings
        API->>DB: INSERT default settings (NOK, nb, push=true, email=true)
        API-->>App: { data: { default settings } }
    end

    App->>App: Render notification toggles

    User->>App: Toggle push notifications OFF
    App->>App: Update local state immediately
    App->>API: PATCH /api/settings { pushEnabled: false }
    API->>DB: UPDATE settings SET push_enabled = 0
    API-->>App: 200 { updated settings }

    alt API failure
        App->>App: Revert toggle to previous state
    end

    User->>App: Tap "Sprak" (Language)
    App->>App: Navigate to /profile/language
    App->>API: GET /api/settings
    API-->>App: { language: "nb" }
    App->>App: Show language list with current selection

    User->>App: Select "English", tap "Lagre"
    App->>API: PATCH /api/settings { language: "en" }
    API-->>App: 200 { updated }

3. Account Deletion Flow

3.1 Sequence Diagram

sequenceDiagram
    actor User
    participant App as Drop App
    participant API as Drop API
    participant DB as Database

    User->>App: Navigate to /profile then "Slett konto"
    App->>App: Show deletion warning dialog<br/>"Er du sikker? Dette kan ikke angres."
    App->>App: Show AML retention notice<br/>"Data beholdes i 5 aar iht. hvitvaskingsloven"

    User->>App: Confirm "Ja, slett kontoen min"
    App->>API: DELETE /api/user/account

    API->>DB: BEGIN TRANSACTION
    API->>DB: UPDATE users SET deleted_at = datetime('now')
    API->>DB: UPDATE sessions SET revoked = 1 WHERE user_id = ?
    API->>DB: INSERT data_access_requests<br/>(type=erasure, status=completed)
    API->>DB: COMMIT

    API-->>App: 200 { message: "Account scheduled for deletion",<br/>retentionNote: "Data retained for 5 years per AML requirements" }

    App->>App: Clear auth cookie/token
    App->>App: Show confirmation screen
    App->>App: Navigate to /login after 5 seconds

3.2 Account Deletion Process Flow

flowchart TD
    A[User requests account deletion] --> B{Confirmation dialog}
    B -->|Cancel| C[Return to profile]
    B -->|Confirm| D[Show AML retention notice]
    D --> E{User acknowledges retention}
    E -->|Cancel| C
    E -->|Proceed| F[DELETE /api/user/account]
    F --> G[Soft-delete user record<br/>deleted_at = now]
    G --> H[Revoke all active sessions]
    H --> I[Create erasure request record]
    I --> J[Clear auth cookie/token]
    J --> K[Show confirmation screen]
    K --> L[Redirect to /login]

    style D fill:#FEF3C7
    style G fill:#FEF2F2

4. Settings Matrix

4.1 User Settings

Setting API Field Type Options Default Persistence
Display currency currency string EUR, USD, GBP, BAM, CHF, PLN, NOK, RSD, TRY, PKR NOK PATCH /api/settings
Language language string nb (Norsk Bokmal), en (English), bs (Bosanski), sq (Shqip) nb PATCH /api/settings
Push notifications pushEnabled boolean true/false true PATCH /api/settings
Email notifications emailEnabled boolean true/false true PATCH /api/settings

4.2 Personal Information (Read-Only from BankID)

Field Source Editable Display Format
First name BankID ID token No Plain text
Last name BankID ID token No Plain text
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


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

Low-Level Design (LLD)

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:


2. Push Notification Delivery (Planned Architecture)

2.1 Sequence Diagram — Push Notification Flow

sequenceDiagram
    actor User
    participant Mobile as Expo App
    participant API as Drop API
    participant DB as Database
    participant Push as Expo Push<br/>Service
    participant APNS as APNs / FCM

    Note over Mobile,APNS: Setup Phase (on login)
    Mobile->>Mobile: Request notification permission
    Mobile->>Push: Register for push token
    Push-->>Mobile: { expoPushToken }
    Mobile->>API: POST /api/push-token { token, platform }
    API->>DB: INSERT push_tokens (user_id, token, platform)

    Note over API,APNS: Trigger Phase (on event)
    API->>API: Transaction completed / security event
    API->>DB: INSERT notification (user_id, type, title, body)
    API->>DB: SELECT push_tokens WHERE user_id = ?
    API->>Push: POST /send { to: expoPushToken, title, body, data }
    Push->>APNS: Forward to APNs (iOS) / FCM (Android)
    APNS-->>Mobile: Push notification delivered

    Note over Mobile: Receive Phase
    Mobile->>Mobile: Display system notification
    User->>Mobile: Tap notification
    Mobile->>Mobile: Deep link to relevant screen
    Mobile->>API: PATCH /api/notifications { notificationIds: [id] }
    API->>DB: UPDATE notifications SET read = 1

2.2 In-App Notification Center (Current Implementation)

sequenceDiagram
    actor User
    participant App as Drop App<br/>(/notifications)
    participant API as Drop API
    participant DB as Database

    User->>App: Tap bell icon (dashboard) or navigate to /notifications
    App->>App: useAuth() — verify authenticated

    App->>API: GET /api/notifications
    API->>DB: SELECT notifications WHERE user_id = ?<br/>ORDER BY created_at DESC
    API-->>App: { data: [ { id, type, title, body, read, createdAt }, ... ] }

    App->>App: Group by date (I DAG, I GAR, older)
    App->>App: Render notification cards with icons

    Note over App: Auto-mark as read on page load
    App->>App: Collect unread notification IDs
    App->>API: PATCH /api/notifications<br/>{ notificationIds: [unread IDs] }
    API->>DB: UPDATE notifications SET read = 1<br/>WHERE id IN (?) AND user_id = ?
    API-->>App: 200 OK (fire-and-forget)

3. Notification Center Components

3.1 Component Diagram

graph TD
    subgraph "Notification Center Page"
        Header["Header<br/>Back button + 'Varsler' title"]
        NotificationList["NotificationList"]
        EmptyState["EmptyState<br/>Bell icon + 'Ingen varsler enna'"]
    end

    subgraph "Notification List"
        DateGroup["DateGroupHeader<br/>('I DAG', 'I GAR', date)"]
        NotificationCard["NotificationCard"]
    end

    subgraph "Notification Card"
        TypeIcon["TypeIcon<br/>(colored circle + icon)"]
        Content["Content<br/>(title, body, timestamp)"]
        UnreadDot["UnreadDot<br/>(blue indicator)"]
    end

    subgraph "Dashboard Integration"
        BellIcon["Bell Icon<br/>(header, with badge count)"]
    end

    NotificationList --> DateGroup
    DateGroup --> NotificationCard
    NotificationCard --> TypeIcon
    NotificationCard --> Content
    NotificationCard --> UnreadDot

    BellIcon -->|navigate| Header

4. Notification Type Table

Type Icon Icon Color Background Title Example Body Example Priority
transaction_complete ArrowUpRight #0B6E35 (green) #F0FDF4 "Overforing til Mama Jasmina fullfort" "2 000 kr sendt til Serbia" Normal
qr_payment ScanLine #D4A017 (gold) #FEF3C7 "QR-betaling hos Ahmetov Kebab" "129 kr betalt" Normal
security Smartphone #3B82F6 (blue) #EFF6FF "Ny palogging fra iPhone" "Oslo, Norge" High
rate_update TrendingUp #D4A017 (gold) #FEF3C7 "Valutakurs oppdatert" "1 NOK = 11.70 RSD" Low
system Bell #6B7280 (gray) #F3F4F6 "Systemoppdatering" "Drop er oppdatert til v0.2.0" Low
promotional #6B7280 (gray) #F3F4F6 "Nytt tilbud" "0% gebyr denne uken!" Low

4.1 Priority Levels

Priority Behavior Push In-App
High Immediate delivery, sound, badge Yes (when implemented) Top of list, bold styling
Normal Standard delivery Yes (when implemented) Normal styling
Low Batched delivery Optional (user preference) Normal styling

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
Platform Format Example
Web URL path https://getdrop.no/transactions?id=tx_rem_1
Mobile (planned) Custom scheme drop://transactions/tx_rem_1
Expo push data JSON payload { "type": "transaction_complete", "targetId": "tx_rem_1" }

6. Permission Handling

6.1 Notification Permission Flow

Platform Permission Request First Time Denied Settings Redirect
iOS (Expo) Notifications.requestPermissionsAsync() System dialog: "Drop would like to send you notifications" Returns { status: 'denied' } Linking.openSettings()
Android (Expo) Notifications.requestPermissionsAsync() System dialog (Android 13+): "Allow Drop to send you notifications?" Returns { status: 'denied' } Linking.openSettings()
Web Notification.requestPermission() Browser prompt: "getdrop.no wants to show notifications" Blocked; user must reset in browser settings Site settings via address bar

6.2 Permission State Machine

stateDiagram-v2
    [*] --> NotDetermined: First launch

    NotDetermined --> Requesting: App requests permission
    Requesting --> Granted: User allows
    Requesting --> Denied: User denies

    Granted --> Active: Push token registered
    Active --> Disabled: User toggles off in Drop settings
    Disabled --> Active: User toggles on in Drop settings

    Denied --> SettingsRedirect: App shows "enable in settings" prompt
    SettingsRedirect --> Granted: User enables in OS settings
    SettingsRedirect --> Denied: User keeps disabled

7. Notification Preferences (Settings Integration)

Users control notification delivery via /profile/notifications:

Setting API Field Effect
Push notifications ON pushEnabled: true Push tokens active, notifications delivered
Push notifications OFF pushEnabled: false Push tokens retained but not used for delivery
Email notifications ON emailEnabled: true Email alerts sent for high-priority events
Email notifications OFF emailEnabled: false No email alerts

API: PATCH /api/settings { pushEnabled: boolean, emailEnabled: boolean }


8. Time Formatting

Condition Format Example
Today "I dag kl. HH:MM" "I dag kl. 14:32"
Yesterday "I gar kl. HH:MM" "I gar kl. 18:45"
Older "DD.MM.YYYY kl. HH:MM" "19.02.2026 kl. 09:00"

9. Platform Differences

Feature Web Mobile
Notification center /notifications page with BottomNav Not implemented
Bell icon badge Dashboard header (unread count) Not implemented
Push notifications Not applicable (web push planned) Not implemented (Expo Push planned)
Auto-read on view Yes (marks all unread as read on page load) N/A
Deep linking URL-based routing Not configured
Notification grouping Date-based (I DAG, I GAR) N/A
Permission handling Browser Notification API Expo Notifications API

10. Data Schema

10.1 Notifications Table

Column Type Description
id TEXT PK Format: noti_ + 16 hex chars
user_id TEXT FK References users(id)
type TEXT transaction_complete, qr_payment, security, rate_update
title TEXT Notification title (Norwegian)
body TEXT Notification body text
read INTEGER 0 = unread, 1 = read
created_at TEXT ISO timestamp

10.2 API Endpoints

Method Endpoint Purpose
GET /api/notifications List all notifications for user
PATCH /api/notifications Mark notifications as read (max 100 IDs)

11. Accessibility Considerations (WCAG 2.1 AA)

Requirement Implementation
Badge count Bell icon badge uses aria-label "X uleste varsler"
Read/unread Unread dot uses both visual indicator (blue dot) and aria attributes
Notification list Semantic list markup with role="list"
Empty state Descriptive text "Ingen varsler enna" with Bell icon
Time formatting Relative time ("I dag kl. 14:32") for recent, absolute for older
Auto-read Fire-and-forget PATCH does not interrupt user reading
Push permission Clear explanation before requesting system permission

12. Cross-References

Low-Level Design (LLD)

Low-Level Design Document

Low-Level Design Document

Project: Drop Module/Component: Transactions Module (Remittance + QR Payment) Version: 1.0 Date: 2026-02-23 Author: Petter Graff, Senior Enterprise Architect Status: Approved Reviewers: Alem Bašić (CEO), John (AI Director) Related HLD: HLD Document

Document History

Version Date Author Changes
0.1 2026-02-21 Banking Architecture Team Initial draft from source code
1.0 2026-02-23 Petter Graff Filled with real Drop data

1. Module Overview

Module: transactions Service/Repo: drop-apisrc/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:

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

classDiagram
    class TransactionsRoute {
        -authMiddleware: Middleware
        -rateLimiter: Middleware
        +POST /v1/transactions/remittance(body: RemittanceDto): Response
        +POST /v1/transactions/qr-payment(body: QRPaymentDto): Response
        +POST /v1/transactions/disclosure(body: DisclosureDto): Response
        +GET /v1/transactions(query: TransactionFilter): Response
        +GET /v1/transactions/:id(): Response
        -validateRemittanceInput(dto: RemittanceDto): void
        -validateQRPaymentInput(dto: QRPaymentDto): void
    }

    class RemittanceDto {
        +recipientId: string
        +amount: number
        +bankAccountId: string
        +currency: string
    }

    class QRPaymentDto {
        +merchantId: string
        +amount: number
        +qrData: string
    }

    class Transaction {
        +id: string
        +user_id: string
        +type: "remittance" | "qr_payment"
        +status: "processing" | "completed" | "failed"
        +amount: number
        +currency: string
        +fee: number
        +recipient_id: string
        +merchant_id: string
        +exchange_rate: number
        +send_amount: number
        +receive_amount: number
        +receive_currency: string
        +idempotency_key: string
        +created_at: string
    }

    class Database {
        <<abstraction>>
        +query(sql, params): T[]
        +getOne(sql, params): T
        +run(sql, params): RunResult
        +transaction(fn): void
    }

    class PISPClient {
        <<external>>
        +initiatePayment(paymentRequest): PaymentResponse
        +getPaymentStatus(paymentId): PaymentStatus
    }

    TransactionsRoute --> RemittanceDto
    TransactionsRoute --> QRPaymentDto
    TransactionsRoute --> Transaction
    TransactionsRoute --> Database
    TransactionsRoute --> PISPClient

3. Database Schema

3.1 Tables

transactions

Purpose: Records all financial operations. Append-only — status updates are the only writes after creation.

Column Type Nullable Default Constraints Description
id TEXT NO PK, format: tx_<hex16> Transaction identifier
user_id TEXT NO FK → users(id) Initiating user
type TEXT NO CHECK('remittance','qr_payment') Transaction type
status TEXT NO 'processing' CHECK('processing','completed','failed') Payment status
amount REAL NO NOT NULL Send amount in NOK (stored in øre equivalent)
currency TEXT YES 'NOK' Source currency (always NOK at MVP)
fee REAL YES 0 Fee in NOK (0.5% remittance, merchant rate for QR)
recipient_id TEXT YES NULL FK → recipients(id) For remittances; NULL for QR
merchant_id TEXT YES NULL FK → merchants(id) For QR payments; NULL for remittance
send_amount REAL YES NULL Amount sent in source currency
receive_amount REAL YES NULL Amount received in destination currency
receive_currency TEXT YES NULL Destination currency (e.g., RSD, EUR)
exchange_rate REAL YES NULL Exchange rate at time of transaction
description TEXT YES NULL Optional user-provided description
idempotency_key TEXT YES NULL UNIQUE Prevents duplicate payments on retry
created_at TEXT NO datetime('now') Transaction timestamp

Indexes:

Index Name Columns Type Rationale
transactions_pkey id B-tree (PK) Primary key lookup
idx_transactions_user user_id B-tree Filter all transactions per user (high frequency)
idx_tx_idempotency idempotency_key Unique B-tree Prevent duplicate payment on API retry
idx_transactions_recipient recipient_id B-tree Lookup by recipient
idx_transactions_merchant merchant_id B-tree Lookup by merchant

exchange_rates

Purpose: Stores current NOK-to-foreign currency exchange rates for the 6 supported remittance corridors.

Column Type Nullable Default Constraints Description
id INTEGER NO auto PK Surrogate key
from_currency TEXT NO NOT NULL Always 'NOK' at MVP
to_currency TEXT NO NOT NULL Target currency (RSD, BAM, PLN, PKR, TRY, EUR)
rate REAL NO NOT NULL Exchange rate: 1 NOK = N target currency units
updated_at TEXT YES Last rate update timestamp

Indexes:

Index Name Columns Type Rationale
idx_rates_currency from_currency, to_currency Composite B-tree Fast rate lookup by currency pair

3.2 Enums (CHECK constraints in SQLite, native ENUMs in PostgreSQL migration)

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

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

3.3 Migration Notes


4. API Contract

Base Path: /v1/transactions

POST /v1/transactions/remittance

Summary: Initiate international money transfer (PISP) from user's bank account to a saved recipient

Authentication: Bearer JWT required (authMiddleware) Rate Limit: 10 req/60s per IP + 3 req/60s per user

Request Body:

{
  "recipientId": "rec_abc123def456gh78",
  "amount": 2000,
  "bankAccountId": "ba_abc123def456gh78",
  "currency": "NOK"
}

Success Response — 201 Created:

{
  "data": {
    "id": "tx_rem_abc123def456gh78",
    "type": "remittance",
    "status": "processing",
    "amount": 2000,
    "fee": 10,
    "receiveAmount": 20340,
    "receiveCurrency": "RSD",
    "exchangeRate": 10.17,
    "estimatedDelivery": "2-4 business days",
    "scaRedirect": "https://dnb.no/sca/pay/abc123",
    "createdAt": "2026-02-23T10:00:00.000Z"
  }
}

Error Responses:

Status Code Description
400 validation_error Missing or invalid fields (amount, recipientId)
401 unauthorized Missing or expired JWT
403 kyc_required User kyc_status is not approved
403 insufficient_balance Cached AISP balance < amount + fee
404 recipient_not_found recipientId does not belong to this user
409 duplicate_transaction Idempotency key collision — returns existing transaction
422 amount_out_of_range Amount < 100 NOK or > 50,000 NOK
429 rate_limited Exceeded 10 req/60s per IP or 3 req/60s per user
502 pisp_unavailable Open Banking PISP API unreachable
500 internal_error Unexpected server error

POST /v1/transactions/qr-payment

Summary: Initiate QR merchant payment (PISP) from user's bank account

Authentication: Bearer JWT required

Request Body:

{
  "merchantId": "mer_abc123def456gh78",
  "amount": 450
}

Success Response — 201 Created:

{
  "data": {
    "id": "tx_qr_abc123def456gh78",
    "type": "qr_payment",
    "status": "completed",
    "amount": 450,
    "fee": 4.5,
    "merchantName": "Café Oslo AS",
    "createdAt": "2026-02-23T10:00:00.000Z"
  }
}

Error Responses:

Status Code Description
400 validation_error Missing or invalid fields
401 unauthorized Missing or expired JWT
403 kyc_required User kyc_status not approved
404 merchant_not_found merchantId not found or inactive
500 internal_error Unexpected error

POST /v1/transactions/disclosure

Summary: Pre-payment disclosure — returns fee, exchange rate, receive amount (PSD2 Art. 45/46 compliance)

Authentication: Bearer JWT required

Request Body:

{
  "type": "remittance",
  "amount": 2000,
  "recipientId": "rec_abc123def456gh78"
}

Success Response — 200 OK:

{
  "data": {
    "sendAmount": 2000,
    "sendCurrency": "NOK",
    "fee": 10,
    "feePercentage": 0.5,
    "exchangeRate": 10.17,
    "receiveAmount": 20340,
    "receiveCurrency": "RSD",
    "totalCost": 2010,
    "estimatedDelivery": "2-4 business days"
  }
}

GET /v1/transactions

Summary: List authenticated user's transactions with pagination

Query Parameters:

Parameter Type Default Description
page integer 1 Page number (1-based)
limit integer 20 Items per page (max 50)
type string Filter: remittance or qr_payment
status string Filter: processing, completed, failed

Success Response — 200 OK:

{
  "data": {
    "transactions": [
      {
        "id": "tx_rem_abc123",
        "type": "remittance",
        "status": "completed",
        "amount": 2000,
        "fee": 10,
        "receiveAmount": 20340,
        "receiveCurrency": "RSD",
        "recipientName": "Marko Petrovic",
        "createdAt": "2026-02-23T10:00:00.000Z"
      }
    ],
    "total": 15,
    "page": 1,
    "limit": 20
  }
}

5. Algorithm Specifications

5.1 Fee Calculation — Remittance

Purpose: Calculate 0.5% fee on remittance, rounded to 2 decimal places Complexity: Time O(1) | Space O(1)

function calculateRemittanceFee(sendAmountNOK: number): number
    FEE_RATE = 0.005  // 0.5%
    fee = sendAmountNOK * FEE_RATE
    return Math.round(fee * 100) / 100  // Round to 2 decimal places

function calculateReceiveAmount(sendAmountNOK: number, exchangeRate: number): number
    netSend = sendAmountNOK  // Fee taken from send amount, not receive
    receive = netSend * exchangeRate
    return Math.round(receive)  // Round to whole units of target currency

Edge Cases:

5.2 Idempotency Key Generation

Purpose: Prevent double-charging on network retry or duplicate form submission Format: {userId}:{amount}:{recipientId}:{minuteTimestamp}

function generateIdempotencyKey(userId, amount, recipientId): string
    minuteTimestamp = Math.floor(Date.now() / 60000)  // Changes every 60s
    key = `${userId}:${amount}:${recipientId}:${minuteTimestamp}`
    return key
    // Unique index on transactions.idempotency_key prevents duplicate
    // If INSERT fails with UNIQUE constraint → return existing transaction

6. Sequence Diagrams

6.1 Remittance Initiation Flow

sequenceDiagram
    autonumber
    actor Client as Client (Web/Mobile)
    participant RL as Rate Limiter
    participant Auth as Auth Middleware
    participant Route as Transactions Route
    participant DB as Database
    participant PISP as Open Banking PISP

    Client->>RL: POST /v1/transactions/remittance
    RL->>RL: Check rate_limits (10/IP, 3/user per 60s)
    alt Rate limit exceeded
        RL-->>Client: 429 Too Many Requests
    end
    RL->>Auth: Forward request
    Auth->>Auth: Extract JWT from Bearer header / cookie
    Auth->>DB: SELECT session WHERE token_hash = ? AND revoked = 0
    Auth->>DB: SELECT user WHERE id = ? AND deleted_at IS NULL
    Auth-->>Route: user context {userId, role, kycStatus}

    Route->>Route: Validate body: recipientId, amount (100-50000), bankAccountId
    alt Validation fails
        Route-->>Client: 400 validation_error
    end
    alt kyc_status != 'approved'
        Route-->>Client: 403 kyc_required
    end

    Route->>DB: SELECT * FROM recipients WHERE id = ? AND user_id = ?
    alt Recipient not found
        Route-->>Client: 404 recipient_not_found
    end
    Route->>DB: SELECT rate FROM exchange_rates WHERE to_currency = ?
    Route->>DB: SELECT * FROM bank_accounts WHERE id = ? AND user_id = ? AND is_primary = 1

    Route->>Route: Calculate fee (0.5%), total cost, receive amount
    alt balance < totalCost
        Route-->>Client: 403 insufficient_balance
    end

    Route->>DB: BEGIN TRANSACTION
    Route->>DB: UPDATE bank_accounts SET balance = balance - totalCostInOere WHERE balance >= ?
    Route->>DB: INSERT INTO transactions (status='processing', idempotency_key=?)
    Route->>DB: INSERT INTO audit_log (action='transaction.create')
    Route->>DB: INSERT INTO notifications (title='Overføring startet')
    Route->>DB: COMMIT

    Route->>PISP: POST /v1/payments/cross-border-credit-transfers
    PISP-->>Route: {paymentId, transactionStatus: "RCVD", scaRedirect}

    Route-->>Client: 201 {transactionId, status: "processing", scaRedirect}

6.2 QR Payment Flow

sequenceDiagram
    autonumber
    actor Client as Client (Mobile)
    participant Route as Transactions Route
    participant DB as Database

    Client->>Route: POST /v1/transactions/qr-payment {merchantId, amount}
    Route->>DB: Verify JWT session
    Route->>DB: SELECT * FROM merchants WHERE id = ? AND status = 'active'
    alt Merchant not found
        Route-->>Client: 404 merchant_not_found
    end
    Route->>DB: SELECT * FROM bank_accounts WHERE user_id = ? AND is_primary = 1

    Route->>Route: Calculate fee = amount * merchant.fee_rate
    Route->>Route: Calculate total = amount + fee

    Route->>DB: BEGIN TRANSACTION
    Route->>DB: UPDATE bank_accounts SET balance = balance - total
    Route->>DB: INSERT INTO transactions (type='qr_payment', status='completed')
    Route->>DB: INSERT INTO audit_log (action='qr_payment.create')
    Route->>DB: INSERT INTO notifications (title='Betaling registrert')
    Route->>DB: COMMIT

    Route-->>Client: 201 {transactionId, status: "completed", merchantName}

7. State Diagrams

stateDiagram-v2
    [*] --> processing: POST /v1/transactions/remittance (PISP initiated)

    processing --> completed: PISP webhook — payment confirmed by bank
    processing --> failed: PISP webhook — payment rejected (insufficient funds, SCA timeout, SCA cancelled)
    processing --> failed: 5-minute SCA timeout — no callback received

    completed --> [*]
    failed --> [*]

Note: QR payments go directly from creation to completed (synchronous in MVP — no PISP webhook for domestic transfers in mock mode).

State Transition Rules:

From To Trigger Guard Condition Side Effect
(none) processing POST /v1/transactions/remittance KYC approved, balance sufficient Deduct cached balance, create audit log, send notification
processing completed PISP webhook or QR sync completion paymentId matches transaction Update status, send completion notification
processing failed PISP webhook rejection or 5-min timeout paymentId matches, status RJCT Restore cached balance (re-sync AISP), send failure notification

8. Error Handling Strategy

8.1 Error Classification

Error Type HTTP Status Retry? Log Level Alert?
ValidationError 400 No INFO No
UnauthorizedError 401 No WARN No
KYCRequired 403 No INFO No
InsufficientBalance 403 No INFO No
RecipientNotFound 404 No INFO No
DuplicateTransaction 409 No INFO No
AmountOutOfRange 422 No INFO No
RateLimited 429 After Retry-After WARN No
PISPUnavailable 502 Yes (3x backoff) ERROR Yes (if sustained > 5 min)
DatabaseError 500 Yes (1x) ERROR Yes
UnexpectedError 500 No ERROR Yes

8.2 Error Response Format

{
  "error": "kyc_required",
  "message": "Du må fullføre identitetsverifisering før du kan sende penger.",
  "details": []
}

8.3 Retry & Fallback Strategy

PISP API call failure:
  → Retry with exponential backoff: [1s, 2s, 4s]
  → Max retries: 3
  → Circuit breaker: Open after 3 failures in 60s window → 60s cooldown
  → Fallback: Return 502 to client — payment cannot proceed without PISP
  → Alert: Sentry alert if circuit remains open > 5 minutes
  → Idempotency: PISP call uses X-Request-ID = idempotency_key to prevent double-payment on retry

9. Concurrency & Thread Safety

Concern Scenario Mitigation
Double payment Client retries POST /v1/transactions/remittance after network timeout Unique index on idempotency_key — second INSERT fails with UNIQUE constraint → return existing transaction
Balance race condition Two simultaneous payments from same account DB transaction with UPDATE bank_accounts SET balance = balance - X WHERE balance >= X — atomic check-and-deduct
Exchange rate staleness Rate changes between disclosure and payment Rate locked at payment initiation time; user sees pre-payment disclosure; rate used is from DB at payment time

10. Performance Considerations

Operation Target (p99) Current Baseline Optimization
POST /v1/transactions/remittance < 500ms (local) + PISP latency ~50ms DB operations DB transaction atomic; PISP call is async from user perspective
GET /v1/transactions < 100ms ~20ms Index idx_transactions_user on user_id; pagination limits result set
POST /v1/transactions/disclosure < 50ms ~10ms Two DB reads (recipient + exchange rate); no external API call
POST /v1/transactions/qr-payment < 200ms ~40ms Synchronous completion in mock mode; PISP async in production

Known bottlenecks:


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:


Approval

Role Name Date Signature
Author Petter Graff 2026-02-23
Module Owner John (AI Director)
Security Review
Tech Lead John (AI Director)
Low-Level Design (LLD)

LLD: Withdrawal Flow

Withdrawal Request Flow (Angrerett)

Purpose

Implements the user's right of withdrawal (angrerett) as required by Norwegian consumer protection law (angrerettloven). Users can submit a withdrawal request to cancel their account or service agreement within the statutory cooling-off period.

Sequence Diagram

sequenceDiagram
    participant U as User (App)
    participant API as Drop API
    participant Auth as Auth Middleware
    participant DB as PostgreSQL
    participant Audit as Audit Log

    U->>API: POST /withdrawal { reason, comment }
    API->>Auth: Validate JWT token
    Auth-->>API: user context

    alt Invalid JSON body
        API-->>U: 400 bad_request
    end

    API->>API: Sanitize reason (max 100 chars)
    API->>API: Sanitize comment (max 1000 chars)
    API->>API: Validate reason against VALID_REASONS

    alt Invalid reason
        API-->>U: 400 validation_error
    end

    API->>DB: INSERT INTO withdrawal_requests (id, user_id, reason, comment)
    DB-->>API: OK

    API->>Audit: Log WITHDRAWAL_REQUEST action
    Audit->>DB: INSERT INTO audit_log

    API-->>U: 201 { success: true, id }

Database Schema

withdrawal_requests table

Column Type Constraints
id TEXT PRIMARY KEY (prefix: wr_)
user_id TEXT NOT NULL, REFERENCES users(id)
reason TEXT Nullable
comment TEXT Nullable
status TEXT DEFAULT 'pending', CHECK IN ('pending','processing','completed','rejected')
created_at TIMESTAMPTZ DEFAULT NOW()

Index: idx_withdrawal_requests_user on user_id.

Valid Withdrawal Reasons

Value Description
not_needed User no longer needs the service
alternative User found an alternative service
not_satisfied User is not satisfied with the service
other Other reason (details in comment field)
"" (empty) No reason provided

Request Processing

  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

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

Cross-References

Low-Level Design (LLD)

LLD: Middleware Lifecycle Flow

Middleware Lifecycle — Low-Level Design

Document: LLD-MIDDLEWARE Status: Approved Last updated: 2026-02-21 Author: Standards Architect Applies to: Drop API (Hono) — src/drop-api/src/app.ts


Overview

The Drop API uses Hono as its HTTP framework. Middleware is organized into two layers: global middleware applied to every request via app.use("*"), and per-route middleware applied within individual route handlers. This document describes the complete execution order.

Source of truth: src/drop-api/src/app.ts


Middleware Execution Order

flowchart TD
    A["Incoming HTTP Request"] --> B["1. CORS Middleware\n(global)"]
    B --> C["2. Request ID Middleware\n(global)"]
    C --> D["3. Client IP Middleware\n(global)"]
    D --> E["4. Route Matching\n(/v1/* or /api/*)"]

    E --> F{Route found?}
    F -->|No| G["404 Not Found"]
    F -->|Yes| H["5. Per-Route Middleware\n(auth / rateLimit / featureGate)"]

    H --> I["6. Route Handler"]
    I --> J["Response"]

    I -.->|Error thrown| K["Global Error Handler\n(app.onError)"]
    K --> J

    style A fill:#f5f5f5,stroke:#333
    style B fill:#ffd93d,stroke:#333
    style C fill:#ffd93d,stroke:#333
    style D fill:#ffd93d,stroke:#333
    style H fill:#6bcb77,stroke:#333
    style I fill:#4d96ff,stroke:#333,color:#fff
    style K fill:#ff6b6b,stroke:#333,color:#fff

Global Middleware (Applied to Every Request)

These are registered in app.ts with app.use("*") and execute in registration order, top-to-bottom.

1. CORS (hono/cors)

Source: app.ts:23-30

Configures Cross-Origin Resource Sharing headers for browser-based clients.

Setting Value
Allowed origins http://localhost:3000, http://localhost:3001, process.env.APP_URL
Credentials true (cookies sent cross-origin)

The credentials: true setting is required because the web app sends JWT tokens in httpOnly cookies. Empty strings from unset env vars are filtered out.

2. Request ID

Source: app.ts:33-38

Generates or propagates a unique request identifier for distributed tracing.

Behavior Detail
Header checked x-request-id
Fallback crypto.randomUUID()
Context variable c.get("requestId")
Response header x-request-id (echoed back)

Downstream middleware and route handlers access the request ID via c.get("requestId") for structured logging and audit trails.

3. Client IP

Source: app.ts:41-47

Extracts the originating client IP address from proxy headers.

Priority Header Processing
1st x-real-ip Trimmed
2nd x-forwarded-for First entry, trimmed
Fallback 127.0.0.1

The extracted IP is stored as c.get("clientIp") and used by rate limiting and audit logging.

Note: The rate-limit.ts module also exports a getClientIp(c) helper that performs the same extraction. Some route handlers use getClientIp(c) directly instead of c.get("clientIp").

4. Global Error Handler

Source: app.ts:50, middleware/error-handler.ts:16-23

Registered via app.onError(globalErrorHandler). This is not middleware in the traditional sense — it is an error boundary that catches any unhandled exceptions thrown during request processing.

Error Type Response
HTTPException (Hono) Returns the exception's status and message
All other errors Logs via logger.error, reports to Sentry via captureError, returns 500 Internal Server Error with generic message

The error handler never leaks stack traces or internal details to the client.


Route Mounting

Source: app.ts:53-72

All API routes are mounted under a versioned prefix:

Mount Point Purpose
/v1/* Primary API path (mobile + new clients)
/api/* Backward compatibility during migration

Both mount points serve the identical route handlers — /api is an alias for /v1.

Mounted Route Groups

Path Route Module Primary Middleware
/v1/auth authRoutes Rate limiting (inline)
/v1/health healthRoutes None (public)
/v1/transactions transactionRoutes authMiddleware + rate limiting
/v1/recipients recipientRoutes authMiddleware
/v1/rates rateRoutes None (public)
/v1/cards cardRoutes authMiddleware + feature gate
/v1/merchants merchantRoutes merchantMiddleware
/v1/settings settingsRoutes authMiddleware
/v1/notifications notificationRoutes authMiddleware
/v1/user userRoutes authMiddleware
/v1/admin adminRoutes adminMiddleware + rate limiting
/v1/consents consentRoutes authMiddleware
/v1/complaints complaintRoutes authMiddleware
/v1/cron cronRoutes Varies
/v1/withdrawal withdrawalRoutes authMiddleware

Per-Route Middleware

Per-route middleware is applied within individual route files, not globally. It executes after the global middleware chain.

Authentication Middleware (middleware/auth.ts)

Three variants, all following the same pattern: extract JWT, verify token + session, set c.set("user", ...).

Middleware Role Check Used By
authMiddleware Any authenticated user Most routes (transactions, recipients, settings, etc.)
merchantMiddleware role === 'merchant' Merchant routes
adminMiddleware role === 'admin' Admin routes (audit, screening, STR)

Flow:

  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.

// Example from transactions.ts
if (!(await rateLimit(ip, 10))) {
  return c.json({ error: "rate_limited", message: "Too many requests" }, 429);
}
Parameter Description
ip Rate limit key (usually client IP, sometimes user:{id})
limit Maximum requests per window
windowMs Window duration in ms (default: 60000 = 1 minute)

Rate limit state is persisted in the rate_limits database table (SQLite/PostgreSQL). Expired entries are cleaned up every 100 checks.

Per-endpoint limits:

Endpoint Key Limit Window
POST /transactions/remittance IP 10/min 60s
POST /transactions/remittance user:{id} 3/min 60s
POST /transactions/qr-payment IP 10/min 60s
POST /transactions/qr-payment user:{id} 3/min 60s
GET /admin/audit IP 30/min 60s
GET /admin/screening IP 30/min 60s
POST /admin/screening IP 10/min 60s
GET /admin/str IP 30/min 60s
POST /admin/str IP 10/min 60s
PATCH /admin/str IP 10/min 60s

Feature Gates (lib/feature-flags.ts)

Feature gates control access to unreleased functionality. Like rate limiting, they are called inline within route handlers, not as Hono middleware.

// Example from cards.ts
if (!isEnabled("virtualCards")) {
  return c.json({ error: "not_found", message: "Feature not available" }, 404);
}
Flag Default Controls
virtualCards false Card creation, listing, detail, cancellation
physicalCards false Physical card ordering
cardDetails false Card detail endpoint
cardFreeze false Card freeze/unfreeze
cardPin false Card PIN management
spendingLimits false Spending limit management
notifications true Notification endpoints
merchantDashboard true Merchant dashboard

Flags are read from environment variables (FF_VIRTUAL_CARDS=true) with fallback to compiled defaults. The featureGate() helper throws an HTTPException(404) for disabled features, which the global error handler catches.


Complete Request Lifecycle (Sequence Diagram)

sequenceDiagram
    participant Client
    participant CORS as CORS Middleware
    participant ReqID as Request ID Middleware
    participant IP as Client IP Middleware
    participant Router as Hono Router
    participant Auth as Auth Middleware
    participant RL as Rate Limiter
    participant FG as Feature Gate
    participant Handler as Route Handler
    participant DB as Database
    participant ErrH as Error Handler

    Client->>CORS: HTTP Request
    CORS->>CORS: Check origin, set CORS headers
    CORS->>ReqID: next()
    ReqID->>ReqID: Extract/generate x-request-id
    ReqID->>IP: next()
    IP->>IP: Extract client IP from headers
    IP->>Router: next()

    Router->>Router: Match route (/v1/* or /api/*)

    alt Public route (health, rates)
        Router->>Handler: Direct execution
    else Authenticated route
        Router->>Auth: authMiddleware / adminMiddleware / merchantMiddleware
        Auth->>DB: Verify JWT + session
        alt Token invalid
            Auth-->>Client: 401 Unauthorized
        else Token valid
            Auth->>Auth: Set c.user
            Auth->>RL: Check rate limit (inline)
            alt Rate exceeded
                RL-->>Client: 429 Too Many Requests
            else Within limit
                RL->>FG: Check feature flag (if applicable)
                alt Feature disabled
                    FG-->>Client: 404 Feature not available
                else Feature enabled
                    FG->>Handler: Execute route logic
                    Handler->>DB: Query/mutation
                    Handler-->>Client: JSON response
                end
            end
        end
    end

    Note over Handler,ErrH: If any error is thrown
    Handler-->>ErrH: Unhandled error
    ErrH->>ErrH: Log + Sentry report
    ErrH-->>Client: 500 Internal Server Error

Input Validation

Input validation is not middleware — it is a collection of utility functions in middleware/validation.ts called directly by route handlers.

Function Purpose Used By
sanitizeText(text, maxLength) Strip HTML tags, control characters, truncate All text input fields
validatePhone(phone) International phone format (+ prefix, 8-15 digits) User profile
validateAmount(amount) Positive number, max 2 decimal places Transactions
validateIBAN(iban) ISO 13616 IBAN checksum validation Bank accounts
validatePIN(pin) Exactly 4 digits Card PIN
validateEmail(email) Basic email format Registration
validateCurrency(currency) Whitelist: EUR, USD, GBP, BAM, CHF, PLN, NOK, RSD, TRY, PKR Transactions
validateName(name) Non-empty, contains letters, no script injection Recipients
validateLanguage(lang) Whitelist: nb, en, bs, sq Settings
auditLog(...) Insert audit trail record All significant actions

Cross-References

Architecture Decision Records (ADR)

All architectural decisions and rationale

Architecture Decision Records (ADR)

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:

Business forces:

Compliance & regulatory:

Existing decisions that constrain this:

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:

Cons:

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:

Cons:

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:

Cons:

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

4.2 Negative Consequences

4.3 Neutral / Secondary Effects

4.4 Technical Debt Created


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

ADR Relationship Notes
ADR-003 Prerequisite PSD2 pass-through model requires AISP/PISP provider
ADR-005 Constrained by Single monolith means single aggregator integration point
ADR-007 Related BankID used for Drop login SCA; ASPSP-side SCA (for PISP) is separate via Neonomics scaRedirect

9. Review Date

Next review: 2026-08-23 (6 months post-decision) or when Neonomics pricing changes by > 50%

Review trigger conditions:

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ć
Architecture Decision Records (ADR)

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.

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

  4. Remove dead code including any orphaned backend variants and unused files.

Consequences

Positive

Negative

Risks

References

Architecture Decision Records (ADR)

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:

This created several problems:

Problem Impact
Project confusion Developers could not distinguish "Drop source" from "FontelePay source"
Git conflicts Nested .git/ directory caused submodule-like behavior without proper configuration
Build interference FontelePay's node_modules/ could interfere with Drop's build process
Size bloat FontelePay's 527 dependency packages inflated the Drop directory
graph TB
    subgraph before["Before: Nested Repository"]
        drop_dir["Drop/"]
        drop_dir --> src["src/"]
        src --> drop_app["drop-app/ (Drop)"]
        src --> fp["fontelepay/ (FontelePay)"]
        fp --> fp_git[".git/ (separate repo)"]
        fp --> fp_nm["node_modules/ (527 pkgs)"]
        fp --> fp_claude[".claude/"]
        fp --> fp_rnd["rnd/ (market research)"]
    end

    subgraph after["After: Clean Separation"]
        products["ALAI/products/"]
        products --> drop_clean["Drop/<br/>Clean codebase"]
        products --> fp_separate["FontelePay/<br/>Preserved independently"]
    end

    before -->|"ADR-002"| after

Decision

Move FontelePay out of the Drop directory to its own location:

  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

Negative

Risks

References

Architecture Decision Records (ADR)

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:

This wallet model had significant regulatory implications under Norwegian law:

Aspect Wallet Model (EMI) Pass-through Model (PISP/AISP)
License type E-money Institution (EMI) PISP/AISP registration
Norwegian law Finansforetaksloven Betalingstjenesteloven
Initial capital 350,000 EUR 20,000-50,000 EUR
Timeline to license 12-18 months 6-12 months
Fund safeguarding Required (segregated accounts or insurance) Not needed
PCI-DSS scope Full (card data stored) Minimal (no card data)

The alternative PSD2 pass-through model positions Drop as a Payment Initiation Service Provider (PISP) and Account Information Service Provider (AISP) where Drop never holds customer funds.

graph LR
    subgraph wallet["Wallet Model (Rejected)"]
        user1["User"] -->|"Top-up"| drop_wallet["Drop Wallet<br/>(holds funds)"]
        drop_wallet -->|"Pay"| merchant1["Merchant"]
        drop_wallet -->|"Send"| receiver1["Receiver"]
    end

    subgraph passthrough["Pass-through Model (Adopted)"]
        user2["User"] -->|"PISP: Initiate payment"| bank["User's Bank<br/>(holds funds)"]
        bank -->|"Execute transfer"| merchant2["Merchant"]
        bank -->|"Execute transfer"| receiver2["Receiver"]
        drop_pt["Drop<br/>(orchestrator)"] -.->|"AISP: Read balance"| bank
        drop_pt -.->|"PISP: Initiate"| bank
    end

    classDef rejected fill:#FFCDD2,stroke:#C62828
    classDef adopted fill:#C8E6C9,stroke:#2E7D32

    class user1,drop_wallet,merchant1,receiver1 rejected
    class user2,bank,merchant2,receiver2,drop_pt adopted

Decision

Drop adopts the PSD2 pass-through model. Specifically:

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

Negative

Risks

References

Architecture Decision Records (ADR)

ADR-004: JWT HTTPOnly Cookies

ADR-004: JWT Storage in httpOnly Cookies

Status: Accepted Date: 2026-02-21 Deciders: John (AI Director) Category: Security


Context

Drop is a financial application handling payment initiation, bank account data, and personal information. Secure token storage is critical -- token theft enables full account takeover including payment initiation from the victim's bank account.

The two primary options for JWT storage in browser-based SPAs are:

Option XSS Risk CSRF Risk Implementation Complexity
localStorage HIGH -- any XSS payload can read tokens None Low
httpOnly cookie None -- JavaScript cannot access Medium -- requires CSRF protection Medium

Given that Drop processes financial data and operates under PSD2, XSS-based token theft would be catastrophic -- an attacker could initiate payments from a user's bank account. CSRF is a more constrained attack vector with well-understood mitigations.

The mobile app (Expo SDK 54) uses Bearer tokens stored in AsyncStorage since cookies are not practical for native apps, but the attack surface is fundamentally different (no XSS in native context).

Decision

Store JWTs in httpOnly cookies for the web application. Use Bearer tokens for the mobile API.

Property Value Rationale
httpOnly true Prevents JavaScript access, eliminates XSS token theft
secure true (production) HTTPS-only transport
sameSite "Lax" CSRF defense (allows BankID redirect back)
maxAge 604,800 (7d) Session lifetime
path "/" Full application scope

Implementation note: The actual implementation uses maxAge=604800 (7d) 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
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

Negative

Risks

References

Architecture Decision Records (ADR)

ADR-005: Monolith First

ADR-005: Monolith-First Architecture

Status: Accepted Date: 2026-02-21 Deciders: John (AI Director), Alem (CEO) Category: Architecture


Context

Drop needs to go from concept to demo-ready product as quickly as possible. The team is small (1 human CEO + AI agents), and the initial scope is well-defined: 10 screens, ~24 API endpoints, single SQLite database.

Three architecture patterns were considered:

Pattern Deployment Complexity Dev Speed Scaling Team Size Fit
Microservices High (orchestration, service mesh, API gateway) Slow (interface contracts, distributed testing) Excellent Large teams
Modular monolith Low (single deploy unit) Fast (shared code, simple debugging) Good (extract later) Small teams
Serverless functions Medium (cold starts, state management) Medium Good (per-function) Any

The current scope (7 core pages + API routes) does not justify the operational overhead of microservices. The codebase is ~24 API route files and ~19 database tables -- well within what a single deployment can handle.

graph TB
    subgraph monolith["Drop Monolith (Current)"]
        nextjs["Next.js 15<br/>App Router"]
        nextjs --> pages["Frontend Pages<br/>(10 screens, React 19)"]
        nextjs --> api["API Routes<br/>(24 endpoints)"]
        api --> db["SQLite / PostgreSQL<br/>(19 tables)"]
    end

    subgraph future["Future Extraction (If Needed)"]
        web["Web Frontend<br/>(Next.js)"]
        mobile_api["Mobile API<br/>(Hono v4)"]
        payment_svc["Payment Service<br/>(PISP orchestration)"]
        banking_svc["Banking Service<br/>(AISP integration)"]
        shared_db["PostgreSQL<br/>(shared or per-service)"]
    end

    monolith -->|"Extract when:<br/>- Team grows to 5+ devs<br/>- 10K+ concurrent users<br/>- Independent deploy needed"| future

    classDef current fill:#C8E6C9,stroke:#2E7D32
    classDef future_style fill:#E3F2FD,stroke:#1565C0

    class nextjs,pages,api,db current
    class web,mobile_api,payment_svc,banking_svc,shared_db future_style

Decision

Start as a modular monolith. Extract microservices only when scaling demands it.

The monolith is structured with clear module boundaries to make future extraction feasible:

Module Responsibility Files
auth/ Authentication (BankID OIDC, JWT, sessions) api/auth/*, lib/auth.ts
transactions/ Remittance and QR payment processing api/transactions/*
merchants/ Merchant registration and dashboard api/merchants/*
cards/ Card management (feature-flagged) api/cards/*
compliance/ GDPR, AML, complaints api/consents/*, api/complaints/*, api/user/*
lib/ Shared: middleware, DB, validation, feature flags lib/*

Extraction triggers (when to consider splitting):

Consequences

Positive

Negative

Risks

References

Architecture Decision Records (ADR)

ADR-006: SQLite to PostgreSQL

ADR-006: SQLite for Development, PostgreSQL for Production

Status: SUPERSEDED by ADR-014: PostgreSQL-Only Architecture (2026-03-03) Date: 2026-02-21 Deciders: John (AI Director) Category: Database


Context

Drop requires a database strategy that supports rapid local development while being production-ready for a financial application. The key tension is between developer velocity (zero-config local setup) and production reliability (concurrent writes, ACID transactions, managed backups).

Database Local Setup Concurrent Writes Managed Services Financial Compliance
SQLite Zero config, file-based Poor (single writer) None Not suitable for production
PostgreSQL Docker required Excellent (MVCC) AWS RDS, Aurora Industry standard for fintech
MySQL Docker required Good AWS RDS, Aurora Common but less feature-rich

Drop's data model includes 19 tables with foreign keys, transactions requiring atomicity (balance deduction + transaction record), and compliance tables needing reliable concurrent access for audit logging. SQLite handles development workloads but cannot support concurrent writes from multiple App Runner instances.

Decision

Use SQLite (better-sqlite3) for development and PostgreSQL for production, with a dual-driver abstraction layer.

Driver detection is automatic based on environment:

const USE_PG = !!process.env.DATABASE_URL;

When DATABASE_URL is set (production), PostgreSQL is used. Otherwise, SQLite with WAL mode is used.

graph LR
    subgraph dev["Development"]
        app_dev["Drop App"] --> dal["Database Access Layer<br/>(db.ts)"]
        dal --> sqlite["SQLite<br/>(better-sqlite3)<br/>./data/drop.db"]
    end

    subgraph prod["Production"]
        app_prod["Drop App (x N)"] --> dal2["Database Access Layer<br/>(db.ts)"]
        dal2 --> pg["PostgreSQL<br/>(AWS RDS)<br/>DATABASE_URL"]
    end

    dal -.->|"Same API:<br/>query(), getOne(),<br/>run(), transaction()"| dal2

See ADR-010: Dual Database Driver for the abstraction layer details.

Consequences

Positive

Negative

Risks

References

Architecture Decision Records (ADR)

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

Consequences

Positive

Negative

Risks

References

Architecture Decision Records (ADR)

ADR-008: Hono API Framework

ADR-008: Hono v4 for Mobile API

Status: Accepted Date: 2026-02-21 Deciders: John (AI Director) Category: Backend


Context

Drop has two client platforms with different API needs:

Platform Auth Pattern Token Storage API Style Deployment
Web (Next.js) Cookie-based JWT via BFF httpOnly cookie Next.js API Routes (collocated) Vercel / App Runner
Mobile (Expo) Bearer token AsyncStorage REST API (separate process) App Runner

Next.js API Routes work well for the web BFF pattern (server-side rendering + API in one deployment), but mobile needs a lightweight, standalone REST API with Bearer token authentication and mobile-specific concerns (deep link callbacks, longer token lifetimes).

Frameworks considered for the mobile API:

Framework Performance TypeScript Edge Compatible Bundle Size Ecosystem
Hono v4 Excellent (minimal overhead) First-class Yes (Workers, Deno, Bun) ~14KB Growing fast
Express 5 Good (mature) Requires @types No (Node-only) ~200KB Massive
Fastify 5 Excellent (schema validation) Good (built-in types) No (Node-only) ~300KB Large
Elysia Excellent (Bun-native) First-class Bun only ~20KB Small

Hono was selected for its TypeScript-first design, minimal overhead, and edge compatibility. The mobile API runs as a separate Hono server on App Runner alongside the Next.js web app.

Decision

Use Hono v4 for the mobile REST API. Keep Next.js API Routes for the web BFF.

graph TB
    subgraph clients["Client Platforms"]
        web["Web Browser<br/>(Next.js SSR + CSR)"]
        mobile["Mobile App<br/>(Expo SDK 54)"]
    end

    subgraph backend["Backend Services"]
        nextjs_api["Next.js BFF<br/>API Routes (/api/*)<br/>Cookie auth, SSR"]
        hono_api["Hono v4 API<br/>REST (/v1/*)<br/>Bearer auth, mobile-optimized"]
    end

    subgraph shared["Shared Layer"]
        db["Database Access (db.ts)"]
        bankid["BankID OIDC (bankid.ts)"]
        validation["Validation (validation.ts)"]
    end

    web --> nextjs_api
    mobile --> hono_api
    nextjs_api --> db
    nextjs_api --> bankid
    hono_api --> db
    hono_api --> bankid
    nextjs_api --> validation
    hono_api --> validation

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

Negative

Risks

References

Architecture Decision Records (ADR)

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:

graph TB
    subgraph env["Environment Variables"]
        vars["NEXT_PUBLIC_FF_VIRTUAL_CARDS=false<br/>NEXT_PUBLIC_FF_PHYSICAL_CARDS=false<br/>NEXT_PUBLIC_FF_NOTIFICATIONS=true<br/>..."]
    end

    subgraph server["Server-Side (API Routes)"]
        isEnabled["isEnabled('virtualCards')"]
        featureGate["featureGate('physicalCards')"]
        getAllFlags["getAllFlags()"]
    end

    subgraph client["Client-Side (React)"]
        useFlag["useFeatureFlag('notifications')"]
        useFlags["useFeatureFlags()"]
    end

    vars -->|"Build-time inline<br/>(NEXT_PUBLIC_ prefix)"| server
    vars -->|"Build-time inline<br/>(NEXT_PUBLIC_ prefix)"| client

    featureGate -->|"Returns 404<br/>if disabled"| api_route["API Route<br/>(e.g., POST /api/cards/[id]/physical)"]

Flag registry (feature-flags.ts:27-36):

Flag Env Var Default Purpose
virtualCards NEXT_PUBLIC_FF_VIRTUAL_CARDS false Virtual card issuance
physicalCards NEXT_PUBLIC_FF_PHYSICAL_CARDS false Physical card ordering
cardDetails NEXT_PUBLIC_FF_CARD_DETAILS false Card detail view
cardFreeze NEXT_PUBLIC_FF_CARD_FREEZE false Card freeze/unfreeze
cardPin NEXT_PUBLIC_FF_CARD_PIN false Card PIN management
spendingLimits NEXT_PUBLIC_FF_SPENDING_LIMITS false Spending limit controls
notifications NEXT_PUBLIC_FF_NOTIFICATIONS true Push notifications
merchantDashboard NEXT_PUBLIC_FF_MERCHANT_DASHBOARD true Merchant dashboard

Server-side API route protection via featureGate():

const gate = featureGate("physicalCards");
if (gate) return gate;  // Returns 404: "Feature not available"

Client-side conditional rendering via useFeatureFlag():

const cardsEnabled = useFeatureFlag("virtualCards");
if (!cardsEnabled) return null;

Consequences

Positive

Negative

Risks

References

Architecture Decision Records (ADR)

ADR-010: Dual Database Driver

ADR-010: Dual Database Driver Abstraction

Status: SUPERSEDED by ADR-014: PostgreSQL-Only Architecture (2026-03-03) Date: 2026-02-21 Deciders: John (AI Director) Category: Database


Context

Per ADR-006, Drop uses SQLite for development and PostgreSQL for production. This creates a challenge: the application code must work correctly against both database engines, which have different SQL dialects, parameter binding, and transaction semantics.

The naive approach -- maintaining two separate codebases or using an ORM -- has drawbacks:

Approach Type Safety SQL Control Performance Complexity
Raw SQL per driver Low (string SQL) Full Optimal High (2x code)
ORM (Prisma/Drizzle) High Limited Good (with overhead) Medium
Thin abstraction layer Medium Full Optimal Low

An ORM would add a dependency, a build step (Prisma generate), and limit SQL flexibility for complex compliance queries. A thin abstraction layer provides the best balance: same SQL syntax where possible, automatic translation where not.

Decision

Implement a thin database abstraction layer (db.ts) that exposes a unified API and transparently converts SQL between SQLite and PostgreSQL dialects.

Driver detection at startup:

const USE_PG = !!process.env.DATABASE_URL;
graph TB
    subgraph app["Application Code"]
        routes["API Routes"]
        routes -->|"query(), getOne(),<br/>run(), transaction()"| dal["Database Access Layer<br/>(db.ts)"]
    end

    subgraph dal_internals["Abstraction Layer Internals"]
        dal --> detect{"DATABASE_URL<br/>set?"}
        detect -->|"Yes"| pg_driver["PostgreSQL Driver<br/>(pg pool)"]
        detect -->|"No"| sqlite_driver["SQLite Driver<br/>(better-sqlite3)"]

        dal --> convert["SQL Converter"]
        convert -->|"? → $1,$2..."| pg_driver
        convert -->|"datetime('now') →<br/>CURRENT_TIMESTAMP"| pg_driver
    end

    subgraph databases["Databases"]
        pg_driver --> pg["PostgreSQL<br/>(production)"]
        sqlite_driver --> sqlite["SQLite<br/>(development)"]
    end

Unified API

Function Signature Purpose
query<T> (sql, params?) -> Promise<T[]> SELECT, returns array of rows
getOne<T> (sql, params?) -> Promise<T | null> SELECT, returns first row or null
run (sql, params?) -> Promise<{changes}> INSERT/UPDATE/DELETE
runIgnore (sql, params?) -> Promise<{changes}> INSERT OR IGNORE / ON CONFLICT DO NOTHING
runUpsert (sql, conflictCol, updateCols, params?) -> Promise<{changes}> INSERT OR REPLACE / ON CONFLICT DO UPDATE
transaction<T> (fn) -> Promise<T> Atomic transaction wrapper
initDb () -> Promise<void> Schema creation + seed data
getDriver () -> "pg" | "sqlite" Current driver type

SQL Translation Rules (db.ts:50-59)

SQLite Syntax PostgreSQL Equivalent Handled By
? placeholders $1, $2, $3, ... Automatic in query()/run()
INSERT OR IGNORE INTO INSERT INTO ... ON CONFLICT DO NOTHING runIgnore()
INSERT OR REPLACE INTO INSERT INTO ... ON CONFLICT (col) DO UPDATE SET runUpsert()
datetime('now') CURRENT_TIMESTAMP Automatic in SQL string
INTEGER AUTOINCREMENT SERIAL Schema initialization
TEXT dates TIMESTAMPTZ Schema initialization

Consequences

Positive

Negative

Risks

References

Architecture Decision Records (ADR)

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.

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

Negative

Risks

References

Architecture Decision Records (ADR)

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

graph TB
    subgraph edge["Edge Layer"]
        cf["Cloudflare<br/>DNS + CDN + WAF + DDoS"]
    end

    subgraph aws["AWS (eu-north-1)"]
        subgraph apprunner["App Runner"]
            nextjs["Next.js BFF<br/>Web app + API routes<br/>(1-10 instances)"]
            hono["Hono API<br/>Mobile REST API<br/>(1-10 instances)"]
        end

        subgraph data["Data Layer"]
            rds["RDS PostgreSQL<br/>(production DB)"]
            secrets["Secrets Manager<br/>(JWT_SECRET, BANKID creds)"]
        end

        subgraph monitoring["Monitoring"]
            cw["CloudWatch<br/>(logs, metrics)"]
        end
    end

    subgraph vercel["Vercel"]
        landing["Landing Page<br/>getdrop.no<br/>(static)"]
    end

    cf --> nextjs
    cf --> hono
    cf --> landing
    nextjs --> rds
    hono --> rds
    nextjs --> secrets
    hono --> secrets
    nextjs --> cw
    hono --> cw

    classDef edge_style fill:#FFF3E0,stroke:#E65100
    classDef aws_style fill:#E3F2FD,stroke:#1565C0
    classDef vercel_style fill:#F3E5F5,stroke:#6A1B9A

    class cf edge_style
    class nextjs,hono,rds,secrets,cw aws_style
    class landing vercel_style

App Runner Configuration

Setting Value Rationale
Region eu-north-1 (Stockholm) Closest AWS region to Norway; GDPR data residency
Source ECR (Docker image) Pushed by GitHub Actions CI/CD
CPU 1 vCPU Sufficient for current load
Memory 2 GB Room for Node.js heap + DB connections
Min instances 1 Eliminates cold start; ~$5/mo baseline
Max instances 10 Auto-scales based on concurrent requests
Port 3000 (Next.js), 3001 (Hono) Default Node.js ports
Health check GET /api/health (also available at /v1/health) Returns DB connectivity status
Auto deploy Yes (on ECR push) CI/CD pushes new image, App Runner deploys

Deployment Pipeline

graph LR
    push["git push"] --> gha["GitHub Actions"]
    gha --> build["Docker build<br/>+ TypeScript check<br/>+ Lint + Test"]
    build --> ecr["Push to ECR"]
    ecr --> apprunner["App Runner<br/>auto-deploy"]
    apprunner --> health["Health check<br/>GET /api/health"]
    health -->|"Healthy"| live["Live traffic"]
    health -->|"Unhealthy"| rollback["Auto-rollback<br/>to previous version"]

Consequences

Positive

Negative

Risks

References

Architecture Decision Records (ADR)

ADR Overview

Architecture Decision Records (ADRs)

Project: Drop -- Fintech Payment App Last updated: 2026-03-03 Maintainer: Standards Architect


What are ADRs?

Architecture Decision Records capture significant technical decisions made during Drop's development. Each ADR documents the context, the decision itself, and its consequences -- providing a historical record of why the system is built the way it is.

ADRs are immutable once accepted. If a decision is reversed, the original ADR is marked Superseded and a new ADR is created referencing it.


ADR Index

ADR Title Status Date Category
ADR-001 Consolidate to Single Backend Accepted 2026-02-12 Architecture
ADR-002 Separate FontelePay from Drop Repository Accepted 2026-02-12 Architecture
ADR-003 Adopt PSD2 Pass-through Model (No Wallet) Accepted 2026-02-12 Architecture
ADR-004 JWT Storage in httpOnly Cookies Accepted 2026-02-21 Security
ADR-005 Monolith-First Architecture Accepted 2026-02-21 Architecture
ADR-006 SQLite for Dev, PostgreSQL for Production Superseded by ADR-014 2026-02-21 Database
ADR-007 BankID as Sole Authentication Provider Accepted 2026-02-21 Security
ADR-008 Hono v4 for Mobile API Accepted 2026-02-21 Backend
ADR-009 Custom Feature Flag System Accepted 2026-02-21 Backend
ADR-010 Dual Database Driver Abstraction Superseded by ADR-014 2026-02-21 Database
ADR-011 Expo SDK 54 for Mobile App Accepted 2026-02-21 Mobile
ADR-012 AWS App Runner for Deployment Accepted 2026-02-21 Infrastructure
ADR-014 PostgreSQL-Only Architecture (Drizzle ORM) Accepted 2026-02-26 Database

ADR Lifecycle

stateDiagram-v2
    [*] --> Proposed : Author drafts ADR
    Proposed --> Accepted : Team reviews and approves
    Proposed --> Rejected : Team rejects proposal
    Accepted --> Deprecated : No longer relevant
    Accepted --> Superseded : New ADR replaces this one
    Rejected --> [*]
    Deprecated --> [*]
    Superseded --> [*]

ADR Template

Use this template when proposing a new ADR. Save as ADR-NNN-short-title.md in this directory.

# ADR-NNN: Title

**Status:** Proposed | Accepted | Deprecated | Superseded by [ADR-XXX](ADR-XXX-title.md)
**Date:** YYYY-MM-DD
**Deciders:** [Names and roles]
**Category:** Architecture | Security | Database | Backend | Frontend | Mobile | Infrastructure

---

## Context

What is the issue that we are seeing that motivates this decision?
Include technical background, constraints, and forces at play.

## Decision

What is the change that we are proposing and/or doing?
State the decision clearly and concisely.

## Consequences

### Positive
- List benefits of this decision

### Negative
- List drawbacks and trade-offs

### Risks
- List risks and their mitigations

## References

- Link to related ADRs, documents, or external resources
- Link to implementation PRs or tasks

Guidelines for Proposing ADRs

  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

Integration Specifications

Third-party integration architecture and flows

Integration Specifications

BankID OIDC Integration

BankID OIDC Integration

Version: 1.0 Date: 2026-02-21 Author: Banking Architecture Team Status: Approved Applies to: Drop — Authentication via Norwegian BankID


1. Overview

Drop uses BankID OIDC (OpenID Connect) as its sole authentication method. BankID provides Strong Customer Authentication (SCA) out of the box — combining possession (mobile device / code generator) with knowledge (personal code) or inherence (biometrics).

Email/password login has been removed. All deprecated auth endpoints return 410 Gone.

Property Value
Identity Provider BankID (prod: auth.bankid.no)
Protocol OpenID Connect 1.0 (Authorization Code Flow)
Auth endpoint https://auth.bankid.no/auth/realms/prod/protocol/openid-connect/auth
Token endpoint https://auth.bankid.no/auth/realms/prod/protocol/openid-connect/token
JWKS endpoint https://auth.bankid.no/auth/realms/prod/protocol/openid-connect/certs
Issuer https://auth.bankid.no/auth/realms/prod
Scopes openid profile
Response type code (authorization code flow)
Source code src/drop-api/src/lib/bankid.ts

2. BankID OIDC Flow

2.1 Web Flow (Next.js BFF)

sequenceDiagram
    participant B as Browser
    participant N as Next.js BFF<br/>/api/auth/bankid/*
    participant BID as BankID OIDC<br/>(auth.bankid.no)

    B->>N: GET /api/auth/bankid
    Note over N: Rate limit check (10/min per IP)
    N->>N: Generate state (crypto.randomUUID)<br/>Generate nonce (crypto.randomUUID)
    N->>N: Set httpOnly cookie: bankid_state={state}
    N-->>B: {redirectUrl: "https://auth.bankid.no/auth/...?<br/>client_id=X&redirect_uri=Y&response_type=code<br/>&scope=openid+profile&state=Z&nonce=N"}

    B->>BID: Browser redirects to BankID authorize URL
    Note over B,BID: User authenticates with BankID<br/>(BankID app / code generator / biometrics)
    BID-->>B: 302 redirect to /api/auth/bankid/callback<br/>?code=AUTH_CODE&state=Z

    B->>N: GET /api/auth/bankid/callback?code=AUTH_CODE&state=Z
    N->>N: Verify state matches bankid_state cookie
    N->>BID: POST /token<br/>{grant_type: authorization_code,<br/>code: AUTH_CODE, redirect_uri: Y,<br/>client_id: X, client_secret: S}
    BID-->>N: {id_token: "eyJ...", access_token: "...",<br/>token_type: "Bearer", expires_in: 300}

    N->>N: Verify id_token signature via JWKS
    N->>N: Validate: issuer, audience, expiry, nonce
    N->>N: Extract pid (fødselsnummer) from claims
    N->>N: Parse birthdate from pid, verify age >= 18
    N->>N: findOrCreateUser(pid, name)
    N->>N: Create session (sessions table)
    N->>N: Issue Drop JWT, set httpOnly cookie (drop_token)
    N-->>B: 302 redirect to /dashboard

2.2 Mobile Flow (Hono API + Expo)

sequenceDiagram
    participant M as Mobile App<br/>(Expo)
    participant H as Hono API<br/>/v1/auth/bankid/*
    participant BID as BankID OIDC

    M->>H: GET /v1/auth/bankid/initiate?platform=mobile
    Note over H: Rate limit check (10/min per IP)
    H->>H: Generate state + nonce
    H-->>M: {redirectUrl: "https://auth.bankid.no/...",<br/>state: "Z"}

    M->>M: Store state in memory
    M->>BID: Open BankID in secure browser<br/>(expo-web-browser)
    Note over M,BID: User authenticates with BankID
    BID-->>M: Deep link: drop://auth/callback<br/>?code=AUTH_CODE&state=Z

    M->>M: Verify state matches stored state
    M->>H: POST /v1/auth/bankid/callback<br/>{code: AUTH_CODE, state: Z, platform: "mobile"}
    H->>BID: POST /token (exchange code)
    BID-->>H: {id_token, access_token}
    H->>H: Verify id_token via JWKS
    H->>H: Extract pid, verify age >= 18
    H->>H: findOrCreateUser(pid, name)
    H->>H: Create session
    H->>H: Issue Drop JWT (7-day expiry)
    H-->>M: {token: "eyJ...", data: {id, name, role}}
    M->>M: Store token in AsyncStorage

3. Token Lifecycle

3.1 Drop JWT (Issued After BankID Auth)

stateDiagram-v2
    [*] --> Issued: BankID auth success<br/>→ createSession() + signJWT()
    Issued --> Active: Token in use<br/>(cookie or Bearer header)
    Active --> Active: API request<br/>→ verifySession() passes
    Active --> Expired: 7d expiry reached<br/>(all clients)
    Active --> Revoked: User logs out<br/>→ revokeAllSessions()
    Active --> Revoked: Session marked revoked<br/>(admin action)
    Expired --> Refreshed: POST /auth/refresh<br/>→ new JWT + new session
    Expired --> [*]: User must re-authenticate
    Revoked --> [*]: User must re-authenticate
    Refreshed --> Active

3.2 JWT Payload Structure

interface DropJwtPayload {
  userId: string;   // "usr_a1b2c3d4e5f6g7h8"
  email: string;    // "usr_xxx@bankid.drop.local" (placeholder)
  role: string;     // "user" | "merchant"
  iat: number;      // Issued at (Unix timestamp)
  exp: number;      // Expiry (iat + 7d, all clients)
  iss?: string;     // "drop-api" (Hono only)
  aud?: string;     // "drop" (Hono only)
}

3.3 Token Lifetimes

Platform Token Location Lifetime Refresh
Web httpOnly cookie (drop_token) 24 hours POST /api/auth/refresh
Mobile Bearer header (AsyncStorage) 7 days POST /v1/auth/refresh

3.4 Session Tracking

Every JWT has a corresponding record in the sessions table:

Column Purpose
id Session ID (ses_<hex16>)
user_id FK to users.id
token_hash SHA-256 hash of the JWT string
expires_at Token expiry timestamp
revoked 0 = active, 1 = revoked

On every authenticated request, the middleware:

  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:

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)

7. Error Handling

7.1 Error Matrix

Error Scenario HTTP Status Error Code User Message (Norwegian) Recovery Action
BankID auth cancelled by user 400 bankid_cancelled "Du avbrøt BankID-innlogging." Retry login
BankID auth timeout 408 bankid_timeout "BankID-sesjonen utløp. Prøv igjen." Retry login
State mismatch (CSRF) 403 state_mismatch "Sikkerhetssjekk feilet. Prøv igjen." Restart flow
Token exchange failed 502 token_exchange_failed "Kunne ikke koble til BankID. Prøv igjen." Retry later
JWKS verification failed 502 jwks_verification_failed "Teknisk feil. Prøv igjen senere." Alert ops, retry
Invalid pid format 422 invalid_pid "Ugyldig identifikasjon fra BankID." Contact support
User under 18 403 underage "Du må være minst 18 år for å bruke Drop." No recovery — legal requirement
BANKID_CLIENT_ID missing 500 config_error "Teknisk feil. Prøv igjen senere." Fix server config
Session revoked 401 session_revoked "Sesjonen din er utløpt. Logg inn på nytt." Re-authenticate
JWT expired 401 token_expired "Sesjonen din er utløpt. Logg inn på nytt." Refresh or re-authenticate
Rate limited 429 rate_limited "For mange forsøk. Vent litt og prøv igjen." Wait and retry

7.2 Error Flow

flowchart TD
    A[BankID auth attempt] --> B{Auth successful?}
    B -->|Yes| C[Exchange code for tokens]
    B -->|No: cancelled| D[Return bankid_cancelled]
    B -->|No: timeout| E[Return bankid_timeout]

    C --> F{Token exchange OK?}
    F -->|Yes| G[Verify ID token via JWKS]
    F -->|No| H[Return token_exchange_failed<br/>Log error, alert ops]

    G --> I{JWKS valid?}
    I -->|Yes| J[Extract pid from claims]
    I -->|No| K[Return jwks_verification_failed<br/>Check JWKS URL, cert rotation]

    J --> L{Valid pid format?}
    L -->|Yes| M{Age >= 18?}
    L -->|No| N[Return invalid_pid]

    M -->|Yes| O[findOrCreateUser]
    M -->|No| P[Return underage<br/>403 Forbidden]

    O --> Q[Create session + JWT]
    Q --> R[Redirect to /dashboard]

8. Mock Mode (Development)

When BANKID_MOCK=true, the OIDC flow is simulated locally without contacting BankID:

Mock Code Prefix Mock User Birthdate Age Check
underage* "Ung Testbruker" (pid: 01011012345) 2010-01-01 Fails (under 18)
(anything else) "Test Bankersen" (pid: 01019012345) 1990-01-01 Passes

Source: bankid.ts:188-195 (getMockUserInfo)

Mock flow:

  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

Integration Specifications

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:

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

AISP access requires explicit user consent. The consent specifies which accounts and data types (balances, transactions) the TPP can access, and has a maximum validity of 90 days (per PSD2 RTS Art. 10).

sequenceDiagram
    participant U as User (Browser/App)
    participant D as Drop API
    participant A as ASPSP (User's Bank)

    U->>D: Link bank account
    D->>A: POST /v1/consents<br/>{access: {balances: [iban], transactions: [iban]},<br/>recurringIndicator: true, validUntil: "2026-08-21",<br/>frequencyPerDay: 4, combinedServiceIndicator: false}
    A-->>D: 201 {consentId, consentStatus: "received",<br/>_links: {scaRedirect: "https://bank.no/sca/..."}}
    D-->>U: Redirect to bank SCA page
    U->>A: Authenticate with BankID (SCA)
    A-->>U: Redirect to Drop callback
    U->>D: GET /api/accounts/callback?consentId=xxx
    D->>A: GET /v1/consents/{consentId}/status
    A-->>D: {consentStatus: "valid"}
    D->>A: GET /v1/accounts?consentId={consentId}
    A-->>D: {accounts: [{iban, currency, name, ...}]}
    D->>A: GET /v1/accounts/{accountId}/balances
    A-->>D: {balances: [{balanceType: "expected", balanceAmount: {currency: "NOK", amount: "45230.00"}}]}
    D->>D: Store in bank_accounts table<br/>(balance cached, balance_synced_at = now)
    D-->>U: Bank account linked successfully

3.2 Balance Retrieval

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:

3.3 Data Storage

AISP Data Drop DB Column Table Notes
Account IBAN bank_accounts.iban bank_accounts Stored on linking
Account name bank_accounts.bank_name bank_accounts ASPSP name (e.g., "DNB")
Account number bank_accounts.account_number bank_accounts Domestic format
Balance amount bank_accounts.balance bank_accounts Cached AISP read (stored in oere)
Balance timestamp bank_accounts.balance_synced_at bank_accounts Last refresh time
Consent ID (new column needed) bank_accounts or consents Links to ASPSP consent
Consent expiry (new column needed) consents Max 90 days from grant

4. PISP — Payment Initiation Service

4.1 Payment Initiation Flow

PISP initiates payments from the user's bank account. Every PISP transaction requires SCA with dynamic linking — the authentication must be tied to the specific amount and payee (PSD2 RTS Art. 97(2)).

sequenceDiagram
    participant U as User (Browser/App)
    participant D as Drop API
    participant A as ASPSP (User's Bank)
    participant R as Recipient Bank

    U->>D: POST /api/transactions/remittance<br/>{recipientId, amount, bankAccountId}
    D->>D: Validate: KYC approved, balance sufficient,<br/>amount 100-50000 NOK, exchange rate lookup
    D->>D: POST /api/transactions/disclosure<br/>Calculate fee (0.5%), FX rate, total
    D-->>U: Show pre-payment disclosure<br/>(PSD2 Art. 45/46 compliance)
    U->>D: Confirm payment
    D->>A: POST /v1/payments/sepa-credit-transfers<br/>{debtorAccount: {iban},<br/>instructedAmount: {currency, amount},<br/>creditorAccount: {iban}, creditorName,<br/>remittanceInformationUnstructured}
    A-->>D: 201 {paymentId, transactionStatus: "RCVD",<br/>_links: {scaRedirect: "https://bank.no/sca/pay/..."}}
    D->>D: Create transaction record<br/>(status: "processing", idempotency_key set)
    D-->>U: Redirect to bank SCA page
    U->>A: Authenticate payment with BankID<br/>(dynamic linking: amount + payee shown)
    A-->>U: Redirect to Drop callback
    U->>D: GET /api/payments/callback?paymentId=xxx
    D->>A: GET /v1/payments/{paymentId}/status
    A-->>D: {transactionStatus: "ACCP"}
    D->>D: Update transaction status to "completed"
    D-->>U: Payment confirmed
    Note over A,R: Bank executes SEPA/SWIFT transfer<br/>Settlement via interbank rails

4.2 Payment Types

Drop Transaction Type Berlin Group Payment Product Use Case Settlement Time
QR Payment (domestic) domestic-credit-transfers Pay merchant in Norway Instant (SEPA Inst) or T+1
Remittance (EEA) sepa-credit-transfers Send to EU/EEA countries 1-2 business days
Remittance (non-EEA) cross-border-credit-transfers Send to Serbia, Pakistan, Turkey, etc. 2-4 business days

4.3 Dynamic Linking (PSD2 RTS Art. 97(2))

For every PISP payment, the SCA procedure must incorporate dynamic linking:

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.1 State Diagram

stateDiagram-v2
    [*] --> Requested: User initiates bank linking
    Requested --> ScaPending: ASPSP returns scaRedirect
    ScaPending --> Valid: User completes SCA
    ScaPending --> Failed: SCA timeout / user cancels
    ScaPending --> Failed: SCA rejected by bank
    Valid --> Valid: Balance refresh (within 90 days)
    Valid --> Expired: 90-day validity exceeded
    Valid --> RevokedByUser: User unlinks bank account
    Valid --> RevokedByASPSP: Bank revokes access
    Valid --> RenewalPending: 90 days before expiry, prompt renewal
    RenewalPending --> ScaPending: User re-authenticates
    RenewalPending --> Expired: User ignores renewal
    Expired --> Requested: User re-links account
    Failed --> Requested: User retries
    RevokedByUser --> [*]
    RevokedByASPSP --> Requested: User re-links
    Expired --> [*]
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

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

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:


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:

Trade-offs:


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

Integration Specifications

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

sequenceDiagram
    participant U as User
    participant D as Drop API
    participant DB as Drop DB
    participant A as User's Bank (ASPSP)
    participant CSM as SEPA CSM<br/>(EBA CLEARING / TARGET2)
    participant RB as Recipient Bank

    U->>D: POST /api/transactions/remittance<br/>{recipientId, amount: 2000, bankAccountId}
    D->>DB: Verify: KYC approved, recipient exists,<br/>bank account exists
    D->>DB: GET exchange_rates WHERE to_currency = 'EUR'
    D->>D: Calculate fee: 2000 * 0.005 = 10 NOK<br/>Calculate receive: 2000 * 0.087 = 174 EUR<br/>Total debit: 2010 NOK

    D->>D: POST /api/transactions/disclosure<br/>(PSD2 Art. 45/46 pre-payment info)
    D-->>U: Disclosure: send 2000 NOK, fee 10 NOK,<br/>rate 0.087, receive 174 EUR,<br/>ETA 1-2 business days

    U->>D: Confirm payment
    D->>DB: Generate idempotency_key<br/>INSERT transaction (status: processing)
    D->>A: POST /v1/payments/sepa-credit-transfers<br/>{debtorAccount: {iban: user_iban},<br/>instructedAmount: {currency: NOK, amount: 2010},<br/>creditorAccount: {iban: recipient_iban},<br/>creditorName: "Recipient Name"}
    A-->>D: {paymentId, transactionStatus: RCVD,<br/>scaRedirect: "https://bank.no/sca/..."}
    D-->>U: Redirect to bank SCA

    U->>A: BankID authentication (dynamic linking:<br/>amount 2010 NOK to "Recipient Name")
    A-->>U: Redirect to Drop callback
    U->>D: Payment callback
    D->>A: GET /v1/payments/{paymentId}/status
    A-->>D: {transactionStatus: ACCP}
    D->>DB: UPDATE transaction SET status = 'completed'

    Note over A,CSM: Bank submits to SEPA CSM<br/>within cutoff time
    A->>CSM: SEPA SCT message (pacs.008)
    CSM->>RB: Route to recipient bank
    RB->>RB: Credit recipient account
    Note over CSM,RB: Settlement: T+1 business day<br/>(SCT Inst: < 10 seconds)

2.2 SEPA Specifications

Property SEPA SCT SEPA SCT Inst
Standard ISO 20022 pacs.008 ISO 20022 pacs.008
Max amount 999,999,999.99 EUR 100,000 EUR
Settlement time T+1 business day < 10 seconds (24/7/365)
Availability Business days only 24/7/365
Coverage 36 SEPA countries Participating banks only
Drop usage Default for EEA remittance Preferred when available
Cut-off time Bank-specific (typically 14:00-16:00 CET) No cut-off

3. Cross-Border Remittance with FX

For non-EEA corridors (Serbia, Pakistan, Turkey, etc.), Drop uses SWIFT gpi (Global Payments Innovation) or correspondent banking networks.

3.1 Cross-Border Flow with FX Conversion

sequenceDiagram
    participant U as User
    participant D as Drop API
    participant FX as FX Rate Provider
    participant A as User's Bank (ASPSP)
    participant CB as Correspondent Bank
    participant RB as Recipient Bank<br/>(e.g., Banca Intesa, Serbia)

    U->>D: POST /api/transactions/remittance<br/>{recipientId: rec_1, amount: 2000}
    D->>D: Lookup recipient: Marko Petrovic,<br/>Serbia, RSD, Banca Intesa

    D->>FX: GET current NOK/RSD rate
    FX-->>D: Rate: 10.17 (1 NOK = 10.17 RSD)

    D->>D: Calculate:<br/>Send: 2000 NOK<br/>Fee: 2000 * 0.005 = 10 NOK<br/>Receive: 2000 * 10.17 = 20,340 RSD<br/>Total debit: 2010 NOK

    D-->>U: Disclosure (PSD2 Art. 45):<br/>You send: 2,000 NOK<br/>Fee: 10 NOK (0.5%)<br/>Rate: 1 NOK = 10.17 RSD<br/>Recipient receives: 20,340 RSD<br/>Total cost: 2,010 NOK<br/>ETA: 2-4 business days

    U->>D: Confirm payment
    D->>D: Lock FX rate for 15 minutes<br/>(rate_locked_at = now, rate_expires_at = now + 15m)
    D->>D: Generate idempotency_key<br/>INSERT transaction (status: processing)

    D->>A: POST /v1/payments/cross-border-credit-transfers<br/>{debtorAccount: {iban}, instructedAmount: {NOK, 2010},<br/>creditorAccount: {bban: recipient_bank_account},<br/>creditorName: "Marko Petrovic",<br/>creditorAgent: {bic: DBDBRSBG}}
    A-->>D: {paymentId, scaRedirect}
    D-->>U: Redirect to bank SCA

    U->>A: BankID authentication
    A-->>U: Redirect to Drop callback
    D->>A: GET /v1/payments/{paymentId}/status
    A-->>D: {transactionStatus: ACCP}
    D->>D: UPDATE transaction status = 'completed'

    Note over A,CB: SWIFT gpi transfer<br/>UETR tracking ID assigned
    A->>CB: MT103 / pacs.008 (NOK)
    CB->>CB: FX conversion NOK to RSD<br/>(at correspondent bank rate)
    CB->>RB: Credit in RSD
    RB->>RB: Credit Marko's account<br/>20,340 RSD received

3.2 Supported Corridors

Corridor Currency Exchange Rate (NOK to) Rail Estimated Delivery
Norway to Serbia RSD 10.17 SWIFT gpi 2-4 business days
Norway to Bosnia BAM 0.17 SWIFT gpi 2-4 business days
Norway to Poland PLN 0.374 SEPA SCT (EEA) 1-2 business days
Norway to Pakistan PKR 26.5 SWIFT gpi 2-4 business days
Norway to Turkey TRY 3.39 SWIFT gpi 2-4 business days
Norway to EU (EUR) EUR 0.087 SEPA SCT/SCTInst 1-2 days / instant

Source: exchange_rates table, seeded in db.ts:234-237


4. FX Rate Management

4.1 Rate Sourcing

Phase Source Refresh Markup
MVP (current) Static seed data in exchange_rates table Manual update None (display rate = mid-market)
Phase 2 ECB reference rates + commercial FX provider Every 15 minutes 0.1-0.3% spread
Phase 3 Real-time feed from FX partner (e.g., Wise, CurrencyCloud) Real-time (streaming) Configurable per corridor

4.2 Rate Lock Window

When a user initiates a remittance, the FX rate is locked for 15 minutes:

  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

flowchart TD
    A[Scheduled reconciliation job<br/>runs every hour] --> B{Fetch transactions<br/>WHERE status = 'processing'<br/>AND created_at older than 1h}
    B --> C[For each pending transaction]
    C --> D[GET /v1/payments/paymentId/status<br/>from ASPSP]
    D --> E{ASPSP status?}

    E -->|ACSC / ACCP| F[UPDATE status = 'completed'<br/>SET completed_at = now]
    E -->|RJCT| G[UPDATE status = 'failed'<br/>Log rejection reason]
    E -->|PDNG / ACTC| H[Keep as 'processing'<br/>Check again next cycle]
    E -->|API error| I[Log error, retry next cycle<br/>Circuit breaker if repeated]

    F --> J[Create notification:<br/>'Overfoering fullfoert']
    G --> K[Create notification:<br/>'Overfoering feilet'<br/>+ refund logic]

    K --> L{Funds already debited?}
    L -->|Yes| M[Initiate refund via ASPSP<br/>or manual intervention]
    L -->|No| N[No action needed<br/>Payment was never executed]

6.3 ASPSP Transaction Statuses (Berlin Group)

Status Code Meaning Drop Action
RCVD Received (payment accepted for processing) Transaction created, status = processing
PDNG Pending (awaiting SCA or bank processing) Keep as processing
ACTC Accepted Technical (technical validation passed) Keep as processing
ACCP Accepted Customer Profile (customer checks passed) Keep as processing
ACSC Accepted Settlement Completed Update to completed
ACSP Accepted Settlement In Process Keep as processing
RJCT Rejected Update to failed
CANC Cancelled Update to failed

7. Idempotency & Retry Strategy

7.1 Idempotency

The transactions table has a unique index on idempotency_key (idx_tx_idempotency):

CREATE UNIQUE INDEX IF NOT EXISTS idx_tx_idempotency
  ON transactions(idempotency_key)
  WHERE idempotency_key IS NOT NULL;

Key generation: {userId}:{recipientId|merchantId}:{amount}:{timestamp_minute}

Flow:

  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

{
  "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:

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

Integration Specifications

Sentry Observability

Sentry Observability Integration

Error tracking, performance monitoring, alerting, and SLO/SLI definitions for the Drop fintech platform. Covers Sentry SDK integration, data scrubbing, Slack alerting, structured logging, and release tracking.


Observability Data Flow

flowchart LR
    subgraph DropAPI["drop-api (Hono v4)"]
        EH[Error Handler<br/>middleware/error-handler.ts]
        Logger[Structured Logger<br/>lib/logger.ts]
        SentryLib[Sentry SDK<br/>lib/sentry.ts]
        AlertLib[Alert System<br/>lib/alerts.ts]
    end

    subgraph SentryCloud["Sentry Cloud"]
        Issues[Issue Tracking]
        Perf[Performance Monitoring<br/>Transaction traces]
        Releases[Release Tracking<br/>Source maps + commits]
    end

    subgraph Alerting["Alert Channels"]
        Slack[Slack Webhook<br/>SLACK_WEBHOOK_URL]
        Console[stdout<br/>JSON structured logs]
    end

    subgraph Monitoring["Infrastructure"]
        CloudWatch[AWS CloudWatch<br/>Container logs + metrics]
    end

    EH -->|Unhandled errors| SentryLib
    EH -->|5xx errors| AlertLib
    EH -->|All errors| Logger
    SentryLib -->|captureException| Issues
    SentryLib -->|Transaction traces| Perf
    SentryLib -->|Release metadata| Releases
    AlertLib -->|Error spikes| Slack
    AlertLib -->|Startup/shutdown| Slack
    Logger -->|JSON log entries| Console
    Console -->|Container stdout| CloudWatch

    SentryLib -.->|beforeSend| Scrubber[Data Scrubber<br/>Removes PII, auth tokens,<br/>fødselsnummer, passwords]

Sentry Configuration

SDK Setup

Source: src/drop-api/src/lib/sentry.ts Package: @sentry/node Initialization: Lazy — initialized on first captureError() or captureMessage() call.

Sentry.init({
  dsn: process.env.SENTRY_DSN,
  environment: process.env.NODE_ENV || "development",
  release: `drop-api@${version}`,  // from package.json
  tracesSampleRate: parseFloat(process.env.SENTRY_TRACES_SAMPLE_RATE || "0.1"),
  beforeSend(event, hint) {
    // Scrub sensitive data before sending to Sentry
    // See "Data Scrubbing" section below
  },
});

Environment Variables

Variable Required Default Description
SENTRY_DSN No Not set (no-op mode) Sentry project DSN. When absent, all Sentry calls are no-ops and errors are only logged to console.
SENTRY_TRACES_SAMPLE_RATE No 0.1 (10%) Percentage of transactions to trace for performance monitoring.
NODE_ENV No development Maps to Sentry environment tag.
SLACK_WEBHOOK_URL No Not set (skip alerts) Slack incoming webhook for alert notifications.

Sentry Project Structure

Project Environment Purpose
drop-api (staging) staging Pre-release error tracking, higher trace sample rate (0.5)
drop-api (production) production Production error tracking, 10% trace sample rate
drop-web (planned) production Client-side error tracking via @sentry/nextjs
drop-mobile (planned) production React Native error tracking via @sentry/react-native

Data Scrubbing

Source: sentry.ts:108-139

All events pass through a beforeSend hook that removes sensitive data before transmission to Sentry.

String Scrubbing Patterns

Pattern Replacement Example
password=... password=[REDACTED] Request bodies, exception messages
pin=... pin=[REDACTED] Card PIN values
cardNumber=... cardNumber=[REDACTED] Card numbers (PCI-DSS)
cvv=... cvv=[REDACTED] Card verification values
fødselsnummer=... fødselsnummer=[REDACTED] Norwegian national ID (PII)
authorization:... authorization:[REDACTED] JWT tokens
cookie:... cookie:[REDACTED] Session cookies

Object Scrubbing

Sensitive keys are recursively scrubbed from event extra data, breadcrumbs, and request bodies:

Request Header Removal

The following headers are deleted from Sentry events entirely:


Sentry SDK Functions

Source: sentry.ts:141-244

Function Purpose When Used
captureError(error, context?) Capture exception with optional user ID, request ID, tags, and extra data. Returns Sentry event ID. error-handler.ts on 5xx errors
captureMessage(message, level?, context?) Capture informational message with severity level. Manual diagnostic logging
setUser(userId, metadata?) Associate current scope with user ID for error grouping. After successful authentication
clearUser() Remove user association from current scope. On logout
addBreadcrumb(message, category, level?, data?) Add navigation/action breadcrumb for error context. Data is scrubbed. Key user actions
setTag(key, value) Set tag on current scope for filtering. Environment, feature flags
flush(timeout?) Flush pending events on graceful shutdown. Default 2000ms. Process exit handler
isSentryEnabled() Check if SENTRY_DSN is set. Conditional logic

Dual Output Pattern

All Sentry functions also log to console.error / console.log. This ensures errors are captured in CloudWatch logs even if Sentry is unavailable, and provides a local development experience without requiring a Sentry DSN.


Alert System

Source: src/drop-api/src/lib/alerts.ts

Slack Alerting

Alerts are sent to a Slack channel via incoming webhook (SLACK_WEBHOOK_URL).

Alert Type Severity Trigger Cooldown
Error spike critical 5+ errors within 60 seconds 10 minutes per unique title
API startup info Application startup None
API shutdown info Graceful shutdown None

Error Spike Detection

Source: alerts.ts:65-83

Window: 60 seconds (sliding)
Threshold: 5 errors
Cooldown: 10 minutes per alert title

The trackError() function is called from the global error handler (error-handler.ts:10) for every 5xx error. It maintains a sliding window of error timestamps and triggers a Slack alert when the spike threshold is breached.

Alert Message Format

CRITICAL *Error spike detected*
7 errors in last minute
_2026-02-21T12:00:00.000Z_
View in Sentry (link if available)

Structured Logging

Source: src/drop-api/src/lib/logger.ts

Log Entry Format

All logs are written to stdout as newline-delimited JSON (NDJSON):

{
  "timestamp": "2026-02-21T12:00:00.000Z",
  "level": "info",
  "message": "BankID login successful",
  "requestId": "550e8400-e29b-41d4-a716-446655440000",
  "metadata": {
    "userId": "usr_a1b2c3d4e5f6g7h8",
    "method": "bankid"
  }
}

Log Levels

Level Usage Example
debug Detailed diagnostic info SQL query parameters
info Normal operations Login success, transaction created
warn Recoverable issues Token verification failed, rate limit approached
error Errors requiring attention BankID callback error, database connection failure

Request Context

Each request gets a unique requestId (from x-request-id header or crypto.randomUUID()). This ID is:

  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

sequenceDiagram
    participant Client
    participant Route as Route Handler
    participant ErrHandler as Global Error Handler
    participant Logger as Structured Logger
    participant Sentry as Sentry SDK
    participant Alerts as Alert System
    participant Slack as Slack Webhook

    Route->>Route: Business logic throws error

    alt HTTPException
        Route-->>ErrHandler: HTTPException (known status)
        ErrHandler->>Logger: Log error with request context
        ErrHandler-->>Client: { error: "http_error", message: "..." }, status
    else Unhandled Error
        Route-->>ErrHandler: Error thrown
        ErrHandler->>Logger: logger.error(message, stack, path, method)
        ErrHandler->>Sentry: captureError(error)
        Sentry->>Sentry: beforeSend → scrub PII
        Sentry->>Sentry: Send to Sentry Cloud
        ErrHandler->>Alerts: trackError(error)
        Alerts->>Alerts: Add to sliding window
        alt Error spike (5+ in 60s)
            Alerts->>Slack: POST webhook (CRITICAL alert)
        end
        ErrHandler-->>Client: { error: "internal_error", message: "An unexpected error occurred" }, 500
    end

SLO/SLI Definitions

Service Level Indicators (SLIs)

SLI Measurement Source
Availability Percentage of successful health check responses (200) App Runner health checks, Cloudflare origin monitoring
Latency p50, p95, p99 response time for API endpoints Sentry performance monitoring, CloudWatch
Error Rate Percentage of 5xx responses out of total requests Sentry issue counts, structured logs
Database Latency dbLatencyMs from /v1/health endpoint Health check response, CloudWatch custom metric
Auth Success Rate Percentage of successful BankID authentications Sentry custom metrics, audit_log table

Service Level Objectives (SLOs)

SLO Target Measurement Window Error Budget
API Availability 99.9% 30-day rolling 43 minutes/month downtime
API Latency (p95) < 300ms 30-day rolling 5% of requests may exceed
API Latency (p99) < 1000ms 30-day rolling 1% of requests may exceed
Error Rate (5xx) < 0.1% 30-day rolling 1 in 1000 requests
Database Latency < 50ms 30-day rolling 5% of queries may exceed
Auth Success Rate > 99% 30-day rolling 1% of auth attempts may fail
Transaction Success Rate > 99.5% 30-day rolling 0.5% of transactions may fail

Performance Targets (from architecture document)

Metric Target Source
First Contentful Paint (FCP) < 1.5s architecture-document.md section 8
Largest Contentful Paint (LCP) < 2.5s architecture-document.md section 8
Time to First Byte (TTFB) < 200ms architecture-document.md section 8
API p95 response time < 300ms architecture-document.md section 8
Lighthouse score > 90 architecture-document.md section 8
Build time < 60s architecture-document.md section 8

Alert Rule Configuration

Rule Condition Severity Channel Cooldown
Error spike 5+ errors in 60s window critical Slack webhook 10 min
API startup Application start event info Slack webhook None
API shutdown Graceful shutdown event info Slack webhook None
Health check failure /v1/health returns 503 critical App Runner auto-restart + CloudWatch alarm N/A
High error rate 5xx rate > 5% for 2 minutes critical CloudWatch alarm → SNS → Slack 5 min
High latency p95 > 500ms for 3 minutes warning CloudWatch alarm → SNS → Slack 5 min
Database slow query Query > 1000ms warning Sentry performance alert 10 min
Auth failure spike 10+ auth failures in 5 minutes warning Sentry alert rule 15 min

Release Tracking

Source Map Upload

# In CI/CD pipeline after build
sentry-cli releases new "drop-api@${VERSION}"
sentry-cli releases set-commits "drop-api@${VERSION}" --auto
sentry-cli sourcemaps upload --release "drop-api@${VERSION}" ./dist/
sentry-cli releases finalize "drop-api@${VERSION}"

Release Metadata

Each release in Sentry includes:


Cross-References

Integration Specifications

Sumsub KYC Integration

Sumsub KYC/AML Integration

Identity verification architecture for the Drop fintech platform using Sumsub as the KYC/AML provider. Covers webhook architecture, applicant lifecycle, document check types, risk level definitions, SDK integration for web and mobile, idempotency handling, and error recovery.


KYC Verification Flow

sequenceDiagram
    participant User as User (Web/Mobile)
    participant Client as Drop Client
    participant API as drop-api (Hono)
    participant DB as Database
    participant Sumsub as Sumsub API
    participant SumsubSDK as Sumsub SDK Widget

    Note over User,SumsubSDK: Step 1: Initiate KYC

    User->>Client: Navigate to KYC verification
    Client->>API: POST /v1/user/kyc/initiate
    API->>API: Verify auth (authMiddleware)
    API->>DB: Check users.kyc_status
    alt Already approved
        DB-->>API: kyc_status = 'approved'
        API-->>Client: { status: "approved" }
    else Needs verification
        API->>Sumsub: POST /resources/applicants<br/>{ externalUserId, email, levelName }
        Sumsub-->>API: { id: applicantId }
        API->>Sumsub: POST /resources/accessTokens<br/>{ userId, ttlInSecs: 3600 }
        Sumsub-->>API: { token: accessToken }
        API->>DB: UPDATE users SET kyc_status = 'pending'
        API->>DB: INSERT INTO screening_results
        API-->>Client: { status: "pending", redirectUrl, externalId }
    end

    Note over User,SumsubSDK: Step 2: Document Verification (in Sumsub SDK)

    Client->>SumsubSDK: Initialize SDK with accessToken
    User->>SumsubSDK: Upload ID document (passport/ID card)
    User->>SumsubSDK: Take selfie (liveness check)
    SumsubSDK->>Sumsub: Submit documents for review
    Sumsub->>Sumsub: AI + human review

    Note over User,SumsubSDK: Step 3: Webhook Callback

    Sumsub->>API: POST /v1/webhooks/sumsub<br/>{ type, applicantId, reviewResult }
    API->>API: Verify HMAC signature
    API->>API: Check idempotency (screening_results)
    API->>DB: UPDATE users SET kyc_status = 'approved'/'rejected'
    API->>DB: INSERT INTO screening_results
    API->>DB: INSERT INTO audit_log
    API-->>Sumsub: 200 OK

Applicant State Machine

stateDiagram-v2
    [*] --> init: POST /resources/applicants
    init --> pending: Documents submitted
    pending --> queued: Automated checks passed, queued for review
    queued --> completed: Review finished
    pending --> completed: Fast-track (low risk)
    completed --> [*]

    state completed {
        [*] --> GREEN: All checks passed
        [*] --> RED: Verification failed
        [*] --> RETRY: Resubmission requested
    }

    note right of init
        User created in Sumsub
        SDK widget opened
        No documents yet
    end note

    note right of pending
        Documents uploaded
        AI processing
    end note

    note right of queued
        AI checks passed
        Awaiting human review
        (if required by level)
    end note

    note left of GREEN
        kyc_status = 'approved'
        Full access granted
    end note

    note left of RED
        kyc_status = 'rejected'
        Financial operations blocked
        rejectLabels explains why
    end note

    note left of RETRY
        kyc_status = 'pending'
        User prompted to resubmit
    end note

Status Mapping

Sumsub Status Sumsub Review Answer Drop kyc_status User Impact
init N/A pending Cannot perform financial operations
pending N/A pending Cannot perform financial operations
queued N/A pending Cannot perform financial operations
completed GREEN approved Full access to remittance and QR payments
completed RED rejected Blocked from financial operations, can appeal
completed RETRY pending Prompted to resubmit documents
onHold N/A pending Under manual review

Webhook Architecture

Webhook Endpoint

URL: POST /v1/webhooks/sumsub Authentication: HMAC-SHA256 signature verification

Webhook Events

Event Type Trigger Drop Action
applicantCreated Applicant record created in Sumsub Log to audit_log, no status change
applicantPending Documents submitted, verification in progress Update kyc_status to pending if not already
applicantReviewed Verification completed (approved or rejected) Update kyc_status based on reviewResult.reviewAnswer
applicantOnHold Manual review required Log to audit_log, status remains pending
applicantActionPending Additional action required from user Notify user via notifications table
applicantReset Application reset for resubmission Reset kyc_status to pending

Signature Verification

POST /v1/webhooks/sumsub
Headers:
  X-Payload-Digest: HMAC-SHA256(request_body, SUMSUB_SECRET_KEY)
  Content-Type: application/json

Verification logic:

  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)

// Initialize Sumsub WebSDK in drop-web
import snsWebSdk from '@sumsub/websdk';

function launchSumsubWidget(accessToken: string, applicantId: string) {
  const snsWebSdkInstance = snsWebSdk.init(accessToken, () => {
    // Token expiry handler — request new token from API
    return fetch('/v1/user/kyc/refresh-token')
      .then(res => res.json())
      .then(data => data.token);
  })
  .withConf({
    lang: 'nb',  // Norwegian
    theme: 'dark',
    uiConf: {
      customCssStr: ':root { --primary-color: #0B6E35; }',  // Drop brand green
    },
  })
  .withOptions({
    addViewportTag: false,
    adaptIframeHeight: true,
  })
  .on('onError', (error) => {
    captureError(error, { tags: { component: 'sumsub-websdk' } });
  })
  .on('onApplicantStatusChanged', (payload) => {
    if (payload.reviewResult?.reviewAnswer === 'GREEN') {
      // Redirect to dashboard
      window.location.href = '/dashboard';
    }
  })
  .build();

  snsWebSdkInstance.launch('#sumsub-container');
}

Mobile Integration (React Native SDK)

// Initialize Sumsub React Native SDK in drop-mobile
import SNSMobileSDK from '@sumsub/react-native-mobilesdk-plugin';

async function launchSumsubMobile(accessToken: string) {
  const snsMobileSDK = SNSMobileSDK.init(accessToken, async () => {
    // Token expiry handler
    const response = await fetch(`${API_URL}/v1/user/kyc/refresh-token`, {
      headers: { Authorization: `Bearer ${authToken}` },
    });
    const data = await response.json();
    return data.token;
  })
  .withHandlers({
    onStatusChanged: (event) => {
      if (event.newStatus === 'Approved') {
        navigation.navigate('Dashboard');
      }
    },
    onError: (error) => {
      Sentry.captureException(error);
    },
  })
  .withLocale('nb')
  .withTheme({
    primaryColor: '#0B6E35',
  })
  .build();

  const result = await snsMobileSDK.launch();
  return result;
}

Error Recovery

Failure Scenarios and Recovery

Failure Detection Recovery
Sumsub API timeout (applicant creation) 30s timeout in kyc.ts:44 Return rejected with error message, user can retry
Sumsub API timeout (status check) 30s timeout in kyc.ts:148 Return rejected, poll again on next request
Access token generation failure Non-200 response from /resources/accessTokens Return pending with applicant ID (applicant created but no widget)
Webhook delivery failure Sumsub retry (8 attempts over 7h) Automatic retry by Sumsub
Webhook signature mismatch 401 response to webhook Alert ops team, check SUMSUB_SECRET_KEY rotation
Database write failure on webhook Transaction rollback Return 500 to trigger Sumsub retry
Duplicate webhook Idempotency check via screening_results Acknowledge with 200, skip processing
SDK widget crash onError callback Capture in Sentry, show user-friendly error with retry option

Manual Recovery Procedures

If a user is stuck in pending status after Sumsub has completed review:

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

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

Integration Specifications

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

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)

Kapitalni zahtjev

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 Architecture

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:

No table is redundant. No table combines unrelated concerns.


Complete Schema ERD

erDiagram
    users ||--|| settings : "1:1 preferences"
    users ||--o{ bank_accounts : "1:N linked accounts"
    users ||--o{ cards : "1:N payment cards"
    users ||--o{ recipients : "1:N saved recipients"
    users ||--o{ transactions : "1:N financial ops"
    users ||--o{ sessions : "1:N auth sessions"
    users ||--o{ notifications : "1:N alerts"
    users ||--o{ spending_limits : "1:N limits"
    users ||--o{ merchants : "1:N merchant profiles"
    users ||--o{ audit_log : "1:N audit entries"
    users ||--o{ aml_alerts : "1:N AML flags"
    users ||--o{ str_reports : "1:N STR filings"
    users ||--o{ screening_results : "1:N screenings"
    users ||--o{ consents : "1:N consents"
    users ||--o{ data_access_requests : "1:N DSARs"
    users ||--o{ complaints : "1:N complaints"

    transactions }o--o| recipients : "remittance target"
    transactions }o--o| merchants : "QR payment target"
    transactions ||--o{ aml_alerts : "triggers alert"
    aml_alerts ||--o{ str_reports : "escalates to STR"
    cards ||--o{ spending_limits : "card-level limits"

    users {
        text id PK "usr_ + 16 hex"
        text email UK "NOT NULL"
        text password_hash "NOT NULL, default EIDONLY"
        text auth_provider "default bankid"
        text first_name "NOT NULL"
        text last_name "NOT NULL"
        text phone "nullable"
        text date_of_birth "nullable"
        text kyc_status "CHECK pending|approved|rejected"
        text role "CHECK user|merchant"
        text risk_level "CHECK low|medium|high"
        text pep_status "CHECK not_checked|clear|match|pending_review"
        integer sanctions_cleared "default 0"
        text kyc_method "CHECK bankid|document|simplified"
        text kyc_verified_at "nullable"
        text national_id_hash "nullable, indexed WHERE NOT NULL"
        text deleted_at "nullable, soft delete"
        text created_at "default datetime now"
    }

    transactions {
        text id PK
        text user_id FK "NOT NULL"
        text type "CHECK remittance|qr_payment"
        text status "CHECK processing|completed|failed"
        integer amount "NOT NULL, in minor units"
        text currency "default NOK"
        integer fee "default 0"
        text recipient_id FK "nullable"
        text merchant_id FK "nullable"
        integer send_amount "nullable"
        text send_currency "nullable"
        integer receive_amount "nullable"
        text receive_currency "nullable"
        real exchange_rate "nullable"
        text purpose_code "nullable"
        text idempotency_key "UNIQUE WHERE NOT NULL"
        text created_at "default datetime now"
        text completed_at "nullable"
    }

    bank_accounts {
        text id PK
        text user_id FK "NOT NULL"
        text bank_name "NOT NULL"
        text account_number "NOT NULL"
        text iban "nullable"
        integer balance "default 0, cached AISP"
        text balance_synced_at "nullable"
        text currency "default NOK"
        integer is_primary "default 0"
        text connected_at "default datetime now"
    }

    merchants {
        text id PK
        text user_id FK "NOT NULL"
        text business_name "NOT NULL"
        text org_number "UNIQUE NOT NULL"
        text address "nullable"
        text bank_account "NOT NULL"
        real fee_rate "default 0.01"
        text status "default active"
        text qr_hmac_key "NOT NULL, random 32 bytes"
        text created_at "default datetime now"
    }

    recipients {
        text id PK
        text user_id FK "NOT NULL"
        text name "NOT NULL"
        text country "NOT NULL"
        text currency "NOT NULL"
        text bank_account "NOT NULL"
        text bank_name "nullable"
        text created_at "default datetime now"
    }

Table-by-Table Design Rationale

Core Tables

users

Normalization: 3NF. All columns are functionally dependent on the primary key.

Design decisions:

transactions

Normalization: 3NF with intentional denormalization.

Design decisions:

bank_accounts

Normalization: 3NF.

Design decisions:

recipients

Normalization: 3NF.

Design decisions:

merchants

Normalization: 3NF.

Design decisions:

exchange_rates

Normalization: 3NF.

Design decisions:

sessions

Normalization: 3NF.

Design decisions:

settings

Normalization: 3NF. 1:1 with users.

Design decisions:

notifications

Normalization: 3NF.

Design decisions:

cards (FUTURE)

Normalization: 3NF.

Design decisions:

spending_limits (FUTURE)

Normalization: 3NF.

Design decisions:

rate_limits

Normalization: 3NF (trivial -- 3 columns).

Design decisions:

Compliance Tables

audit_log

aml_alerts

str_reports

screening_results

consents

data_access_requests

complaints


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

Database Architecture

Migration Strategy

Migration Strategy: SQLite to PostgreSQL

STATUS: COMPLETED (2026-03-03) This document describes the completed migration from the old dual-driver architecture to PostgreSQL-only. The migration is done. Current architecture: PostgreSQL 16 (all environments), Drizzle ORM. See ADR-014 for the authoritative current state.

Version: 1.0 Date: 2026-02-21 Status: Completed — migration done per ADR-014 Owner: Database Architect


Overview

HISTORICAL NOTE: The dual-driver architecture and better-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.

This document captures the migration plan that was executed when transitioning from SQLite (development) + PostgreSQL (production) to PostgreSQL 16 in all environments. It is preserved as a historical record.


Migration Execution Flow

flowchart TD
    A[Phase 1: Prepare] --> B[Phase 2: Schema Migration]
    B --> C[Phase 3: Data Migration]
    C --> D[Phase 4: Validation]
    D --> E{All checks pass?}
    E -->|Yes| F[Phase 5: Cutover]
    E -->|No| G[Fix issues]
    G --> D
    F --> H[Phase 6: Post-migration]

    subgraph "Phase 1: Prepare"
        A1[Provision PostgreSQL instance]
        A2[Configure DATABASE_URL]
        A3[Create shadow database for testing]
        A4[Backup SQLite database file]
    end

    subgraph "Phase 2: Schema"
        B1[Run PostgreSQL schema DDL]
        B2[Create indexes]
        B3[Verify constraints]
    end

    subgraph "Phase 3: Data"
        C1[Export SQLite data as INSERT statements]
        C2[Transform data types]
        C3[Load into PostgreSQL]
        C4[Reset sequences]
    end

    subgraph "Phase 4: Validate"
        D1[Row count comparison]
        D2[Checksum validation]
        D3[Application smoke tests]
        D4[Run full test suite against PG]
    end

    subgraph "Phase 5: Cutover"
        F1[Set DATABASE_URL in production]
        F2[Deploy application]
        F3[Verify health endpoint]
    end

    subgraph "Phase 6: Post-migration"
        H1[Monitor error rates]
        H2[Monitor query performance]
        H3[Archive SQLite file]
    end

Data Type Mapping

The dual-driver layer already handles SQL syntax differences. The schema migration must map SQLite types to PostgreSQL equivalents:

SQLite Type PostgreSQL Type Tables Using It Notes
TEXT TEXT All tables Direct mapping, no change
TEXT PRIMARY KEY TEXT PRIMARY KEY All except exchange_rates Same behavior
INTEGER (boolean) BOOLEAN or INTEGER sessions.revoked, notifications.read, settings.push_enabled, settings.email_enabled, bank_accounts.is_primary, consents.granted, users.sanctions_cleared Keep as INTEGER for dual-driver compat, or convert to BOOLEAN in PG-only mode
INTEGER (currency) BIGINT transactions.amount, transactions.fee, transactions.send_amount, transactions.receive_amount, bank_accounts.balance, spending_limits.amount Use BIGINT for amounts in minor units to prevent overflow
INTEGER PRIMARY KEY AUTOINCREMENT SERIAL PRIMARY KEY exchange_rates.id Only auto-increment in schema
INTEGER (unix timestamp) INTEGER rate_limits.reset_at Unix epoch, no conversion needed
REAL DOUBLE PRECISION transactions.exchange_rate, merchants.fee_rate Direct mapping
TEXT DEFAULT (datetime('now')) TEXT DEFAULT CURRENT_TIMESTAMP All created_at, updated_at columns Handled by adaptSqlForPg() in db.ts

SQLite-specific SQL Adaptations (already implemented in db.ts:46-52)

SQLite SQL PostgreSQL Equivalent Handler
INSERT OR IGNORE INTO INSERT INTO ... ON CONFLICT DO NOTHING runIgnore() function
INSERT OR REPLACE INTO INSERT INTO ... ON CONFLICT (col) DO UPDATE SET runUpsert() function
datetime('now') CURRENT_TIMESTAMP adaptSqlForPg() regex
? placeholders $1, $2, ... positional convertPlaceholders()
randomblob(32) gen_random_bytes(32) Schema-level change (merchants.qr_hmac_key default)
hex() encode(..., 'hex') Schema-level change

PostgreSQL Schema DDL

The PostgreSQL schema must be created separately from the SQLite schema since CREATE TABLE IF NOT EXISTS syntax is shared but defaults and functions differ:

-- PostgreSQL schema for Drop
-- Run once when provisioning production database

CREATE TABLE IF NOT EXISTS users (
    id TEXT PRIMARY KEY,
    email TEXT UNIQUE NOT NULL,
    password_hash TEXT NOT NULL DEFAULT 'EIDONLY',
    auth_provider TEXT DEFAULT 'bankid',
    first_name TEXT NOT NULL,
    last_name TEXT NOT NULL,
    phone TEXT,
    date_of_birth TEXT,
    kyc_status TEXT DEFAULT 'pending' CHECK(kyc_status IN ('pending','approved','rejected')),
    role TEXT DEFAULT 'user' CHECK(role IN ('user','merchant')),
    risk_level TEXT DEFAULT 'low' CHECK(risk_level IN ('low','medium','high')),
    pep_status TEXT DEFAULT 'not_checked' CHECK(pep_status IN ('not_checked','clear','match','pending_review')),
    sanctions_cleared INTEGER DEFAULT 0,
    kyc_method TEXT CHECK(kyc_method IN ('bankid','document','simplified')),
    kyc_verified_at TEXT,
    national_id_hash TEXT,
    deleted_at TEXT,
    created_at TEXT DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_users_national_id ON users(national_id_hash) WHERE national_id_hash IS NOT NULL;

CREATE TABLE IF NOT EXISTS recipients (
    id TEXT PRIMARY KEY,
    user_id TEXT NOT NULL REFERENCES users(id),
    name TEXT NOT NULL,
    country TEXT NOT NULL,
    currency TEXT NOT NULL,
    bank_account TEXT NOT NULL,
    bank_name TEXT,
    created_at TEXT DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_recipients_user ON recipients(user_id);

CREATE TABLE IF NOT EXISTS merchants (
    id TEXT PRIMARY KEY,
    user_id TEXT NOT NULL REFERENCES users(id),
    business_name TEXT NOT NULL,
    org_number TEXT UNIQUE NOT NULL,
    address TEXT,
    bank_account TEXT NOT NULL,
    fee_rate DOUBLE PRECISION DEFAULT 0.01,
    status TEXT DEFAULT 'active',
    qr_hmac_key TEXT NOT NULL DEFAULT encode(gen_random_bytes(32), 'hex'),
    created_at TEXT DEFAULT CURRENT_TIMESTAMP
);

CREATE TABLE IF NOT EXISTS transactions (
    id TEXT PRIMARY KEY,
    user_id TEXT NOT NULL REFERENCES users(id),
    type TEXT NOT NULL CHECK(type IN ('remittance','qr_payment')),
    status TEXT DEFAULT 'processing' CHECK(status IN ('processing','completed','failed')),
    amount BIGINT NOT NULL,
    currency TEXT DEFAULT 'NOK',
    fee BIGINT DEFAULT 0,
    recipient_id TEXT REFERENCES recipients(id),
    merchant_id TEXT REFERENCES merchants(id),
    send_amount BIGINT,
    send_currency TEXT,
    receive_amount BIGINT,
    receive_currency TEXT,
    exchange_rate DOUBLE PRECISION,
    purpose_code TEXT,
    created_at TEXT DEFAULT CURRENT_TIMESTAMP,
    completed_at TEXT,
    idempotency_key TEXT
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_tx_idempotency ON transactions(idempotency_key) WHERE idempotency_key IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_transactions_user ON transactions(user_id);

CREATE TABLE IF NOT EXISTS exchange_rates (
    id SERIAL PRIMARY KEY,
    from_currency TEXT DEFAULT 'NOK',
    to_currency TEXT NOT NULL,
    rate DOUBLE PRECISION NOT NULL,
    updated_at TEXT DEFAULT CURRENT_TIMESTAMP
);

CREATE TABLE IF NOT EXISTS bank_accounts (
    id TEXT PRIMARY KEY,
    user_id TEXT NOT NULL REFERENCES users(id),
    bank_name TEXT NOT NULL,
    account_number TEXT NOT NULL,
    iban TEXT,
    balance BIGINT DEFAULT 0,
    balance_synced_at TEXT,
    currency TEXT DEFAULT 'NOK',
    is_primary INTEGER DEFAULT 0,
    connected_at TEXT DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_bank_accounts_user ON bank_accounts(user_id);

-- Remaining tables follow the same pattern...
-- See full DDL in migration script

Sequence/Auto-increment Migration

Only one table uses auto-increment: exchange_rates.

Aspect SQLite PostgreSQL Migration Step
Type INTEGER PRIMARY KEY AUTOINCREMENT SERIAL PRIMARY KEY Schema DDL change
Sequence reset N/A (built into rowid) SELECT setval('exchange_rates_id_seq', (SELECT MAX(id) FROM exchange_rates)) After data load
Gap behavior Gaps allowed Gaps allowed No difference

JSON Handling

Aspect SQLite PostgreSQL Impact
Storage type TEXT (plain string) TEXT (could use JSONB) No change needed for compatibility
JSON columns audit_log.details, aml_alerts.details, str_reports.details, screening_results.match_details Same columns, stored as TEXT Keep as TEXT for dual-driver compatibility
Querying JSON Not used (JSON is stored, not queried in SQL) Could use ->>/@> operators Future optimization: add JSONB indexes for audit log queries
Validation None (application layer) Could add CHECK with jsonb cast Future enhancement

Decision: Keep JSON columns as TEXT for now. Converting to JSONB is a future optimization that would break dual-driver compatibility.


Date/Time Handling

Aspect SQLite PostgreSQL Migration
Default value datetime('now') CURRENT_TIMESTAMP Handled by adaptSqlForPg()
Storage format ISO 8601 TEXT ISO 8601 TEXT (not TIMESTAMP type) No conversion needed
Timezone UTC (application convention) UTC (application convention) Consistent
Date arithmetic datetime('now', '-3 days') CURRENT_TIMESTAMP - INTERVAL '3 days' Only used in seed data, not production queries

Note: All timestamps are stored as TEXT in ISO 8601 format (YYYY-MM-DDTHH:MM:SS) in both databases. This is intentional for dual-driver compatibility. A future PostgreSQL-only optimization could convert to TIMESTAMPTZ.


Migration Checklist

Pre-Migration

Schema Migration

Data Migration

Validation

Cutover

Post-Migration


Rollback Procedure

flowchart TD
    A[Issue detected in PostgreSQL] --> B{Is it data corruption?}
    B -->|Yes| C[Stop application immediately]
    B -->|No| D{Is it a query/performance issue?}
    D -->|Yes| E[Fix query and redeploy]
    D -->|No| F[Investigate further]

    C --> G[Remove DATABASE_URL env var]
    G --> H[Redeploy application]
    H --> I[Application falls back to SQLite]
    I --> J[Investigate PostgreSQL issue offline]
    J --> K[Fix and retry migration]

Rollback is simple: Remove or unset the DATABASE_URL environment variable. The application immediately falls back to SQLite. This is the primary advantage of the dual-driver architecture.

Rollback Scenario Action Downtime
Schema issue in PostgreSQL Unset DATABASE_URL, redeploy ~2 minutes (deploy time)
Data integrity issue Unset DATABASE_URL, redeploy with SQLite backup ~2 minutes
Performance regression Unset DATABASE_URL, optimize PG offline ~2 minutes
Partial migration failure Drop PostgreSQL schema, fix script, retry No production impact (still on SQLite)

Key constraint: Rollback only works if no new data has been written to PostgreSQL that does not exist in SQLite. In practice, this means the migration window should be short and the SQLite database should be read-only during cutover.


Zero-Downtime Migration Using Dual-Driver

The dual-driver architecture enables a phased migration with zero downtime:

sequenceDiagram
    participant App as Application
    participant SQLite as SQLite (current)
    participant PG as PostgreSQL (new)

    Note over App,SQLite: Phase A: Normal operation (SQLite)
    App->>SQLite: All reads/writes

    Note over App,PG: Phase B: Provision and schema
    App->>SQLite: All reads/writes (unchanged)
    Note right of PG: Create schema, indexes

    Note over App,PG: Phase C: Data migration
    App->>SQLite: All reads/writes (unchanged)
    Note right of PG: Bulk load from SQLite export

    Note over App,PG: Phase D: Final sync + cutover
    App->>SQLite: Brief read-only mode
    Note right of PG: Delta sync (new records since bulk load)
    Note over App: Set DATABASE_URL
    App->>PG: All reads/writes

    Note over App,PG: Phase E: Production on PostgreSQL
    App->>PG: All reads/writes
    Note left of SQLite: Archived as backup

Total downtime: Only during Phase D final sync + deploy, estimated at 2-5 minutes for the current data volume.


Testing Approach

Shadow Database Testing

Before production migration, run the full application against a shadow PostgreSQL database:

  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

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

Database Architecture

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:

Key tension: GDPR right to erasure (Art. 17) vs. AML legal retention obligations. AML wins -- data required for anti-money laundering must be retained for 5 years regardless of erasure requests.


Retention Periods

Per-Table Retention Schedule

Table Retention Period Legal Basis Archival After Purge After
users 5 years post-relationship end Hvitvaskingsloven section 30 Account deletion + 1 year 5 years post-deletion
bank_accounts 5 years post-relationship end Hvitvaskingsloven section 30 Account deletion 5 years post-deletion
transactions 5 years from transaction date Bokforingsloven section 13, Hvitvaskingsloven section 30 1 year after transaction 5 years after transaction
recipients 5 years post-relationship end Hvitvaskingsloven section 30 (counterparty records) Account deletion 5 years post-deletion
merchants 5 years post-relationship end Bokforingsloven, Hvitvaskingsloven Account deletion 5 years post-deletion
sessions 90 days after expiry Legitimate interest (security) After expiry 90 days after expiry
notifications 1 year from creation Legitimate interest (UX) 6 months 1 year
settings Duration of relationship Contract performance Account deletion Immediate on deletion
exchange_rates Indefinite (reference data) Legitimate interest Never Never
cards 5 years post-cancellation PCI-DSS, Bokforingsloven Card cancellation 5 years post-cancellation
spending_limits Duration of card lifecycle Contract performance Card cancellation With card record
rate_limits Until window expires Legitimate interest (security) Auto-cleaned per request Immediate on expiry
audit_log 5 years from event PSD2 Art. 94, Hvitvaskingsloven 1 year after event 5 years after event
aml_alerts 5 years post-resolution Hvitvaskingsloven section 30 After resolution 5 years post-resolution
str_reports 5 years after filing Hvitvaskingsloven section 30 Never (active reference) 5 years after filing
screening_results 5 years post-relationship end Hvitvaskingsloven section 30 Account deletion 5 years post-deletion
consents Duration of consent + 5 years GDPR Art. 7(1) (proof of consent) After withdrawal + 1 year 5 years after withdrawal
data_access_requests 5 years from completion GDPR accountability (Art. 5(2)) After completion 5 years after completion
complaints 5 years from resolution Finansavtaleloven, Bokforingsloven After resolution 5 years after resolution

Per-Column Retention (Sensitive Fields)

Table.Column Contains Retention Anonymization Method
users.email PII (email address) Until erasure (then anonymized) Replace with deleted_usr_{hash}@anonymized.local
users.first_name PII Until erasure Replace with [REDACTED]
users.last_name PII Until erasure Replace with [REDACTED]
users.phone PII Until erasure Replace with NULL
users.date_of_birth PII Until erasure Replace with NULL
users.national_id_hash PII (hashed) 5 years (AML) Already hashed; set to NULL after retention
users.password_hash Auth credential Until erasure Replace with DELETED
bank_accounts.account_number Financial PII 5 years (AML) Replace with ****{last4}
bank_accounts.iban Financial PII 5 years (AML) Replace with ****{last4}
recipients.bank_account Financial PII 5 years (AML) Replace with ****{last4}
recipients.name PII 5 years (AML, counterparty) Replace with [REDACTED]
cards.last_four Financial (partial) 5 years Already truncated
cards.pin_hash Auth credential Until card cancellation Set to NULL
audit_log.ip_address PII (IP address) 5 years (PSD2) Replace with 0.0.0.0 after retention
audit_log.user_agent Quasi-PII 5 years Replace with [REDACTED] after retention
consents.ip_address PII 5 years (proof of consent) Replace with 0.0.0.0 after retention

Archival Strategy

Active vs. Archived Data

flowchart LR
    A[Active Data<br/>Primary Database] -->|After retention trigger| B[Cold Archive<br/>Read-only Storage]
    B -->|After full retention period| C[Purge<br/>Permanent Deletion]

    subgraph "Active (PostgreSQL)"
        A1[Recent transactions]
        A2[Active users]
        A3[Current sessions]
    end

    subgraph "Cold Archive (S3/Glacier)"
        B1[Old transactions > 1 year]
        B2[Deleted user records]
        B3[Resolved AML alerts]
        B4[Filed STR reports]
    end

    subgraph "Purge"
        C1[Records past 5-year retention]
        C2[Anonymized analytics retained]
    end

Archival Tiers

Tier Storage Access Time Data Types Cost
Hot (Active DB) PostgreSQL Milliseconds All current data, active users, recent transactions Primary DB cost
Warm (Archive DB) PostgreSQL read replica or separate schema Seconds Transactions > 1 year, deleted users pending retention Reduced compute
Cold (Object storage) AWS S3 / Glacier Minutes to hours Compliance exports, old audit logs, filed STR reports Minimal

Archival Process

  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:

flowchart TD
    A[DELETE /api/user/account] --> B{Active transactions?}
    B -->|Yes, processing| C[Reject: Wait for completion]
    B -->|No| D[Begin deletion cascade]

    D --> E[Revoke all sessions]
    E --> F[Soft-delete user record]
    F --> G[Anonymize PII fields]
    G --> H[Create data_access_request<br/>type=erasure, status=completed]

    subgraph "Immediate Actions"
        E
        F
        G
    end

    subgraph "Retained for AML (5 years)"
        I[transactions — amounts, dates, types]
        J[audit_log — anonymized entries]
        K[aml_alerts — if any]
        L[str_reports — if any]
        M[screening_results — if any]
    end

    subgraph "Deleted Immediately"
        N[settings — preferences]
        O[notifications — all]
        P[rate_limits — if any for user IP]
    end

    subgraph "Anonymized + Retained"
        Q[bank_accounts — account numbers masked]
        R[recipients — names redacted]
        S[consents — IP anonymized]
    end

    H --> I
    H --> J
    H --> K
    H --> N
    H --> Q

Deletion Cascade Detail

Step Table Action SQL
1 sessions Revoke all UPDATE sessions SET revoked = 1 WHERE user_id = ?
2 users Soft delete + anonymize UPDATE users SET deleted_at = CURRENT_TIMESTAMP, email = 'deleted_' || id || '@anonymized.local', first_name = '[REDACTED]', last_name = '[REDACTED]', phone = NULL, date_of_birth = NULL, password_hash = 'DELETED' WHERE id = ?
3 settings Delete DELETE FROM settings WHERE user_id = ?
4 notifications Delete DELETE FROM notifications WHERE user_id = ?
5 bank_accounts Anonymize UPDATE bank_accounts SET account_number = '****' || RIGHT(account_number, 4), iban = CASE WHEN iban IS NOT NULL THEN '****' || RIGHT(iban, 4) END WHERE user_id = ?
6 recipients Anonymize UPDATE recipients SET name = '[REDACTED]', bank_account = '****' || RIGHT(bank_account, 4) WHERE user_id = ?
7 consents Anonymize IP UPDATE consents SET ip_address = '0.0.0.0' WHERE user_id = ?
8 cards Anonymize UPDATE cards SET pin_hash = NULL WHERE user_id = ?
9 spending_limits Delete DELETE FROM spending_limits WHERE user_id = ?
10 data_access_requests Create record INSERT INTO data_access_requests (id, user_id, request_type, status, completed_at) VALUES (?, ?, 'erasure', 'completed', CURRENT_TIMESTAMP)
11 audit_log Log deletion INSERT INTO audit_log (id, user_id, action, details) VALUES (?, ?, 'user.deleted', '{"reason":"gdpr_erasure"}')

NOT deleted (AML retention): transactions, audit_log (existing entries), aml_alerts, str_reports, screening_results, merchants. These are retained for 5 years per hvitvaskingsloven section 30, with PII fields anonymized.


Data Subject Access Request (DSAR) Implementation

DSAR Types

Request Type GDPR Article SLA Implementation
Export (right to access) Art. 15 30 days GET /api/user/data-export -- returns JSON with all user data
Erasure (right to be forgotten) Art. 17 30 days DELETE /api/user/account -- soft delete + anonymization cascade
Rectification (right to correct) Art. 16 30 days POST /v1/user/rectification -- updates specified fields, creates data_access_request record
Restriction (right to restrict) Art. 18 30 days POST /v1/user/restriction -- flags account as restricted, creates data_access_request record

Export Flow

sequenceDiagram
    participant U as User
    participant API as API
    participant DB as Database

    U->>API: GET /api/user/data-export
    API->>DB: SELECT * FROM users WHERE id = ?
    API->>DB: SELECT * FROM transactions WHERE user_id = ?
    API->>DB: SELECT * FROM recipients WHERE user_id = ?
    API->>DB: SELECT * FROM bank_accounts WHERE user_id = ?
    API->>DB: SELECT * FROM settings WHERE user_id = ?
    API->>DB: SELECT * FROM consents WHERE user_id = ?

    API->>DB: INSERT INTO data_access_requests<br/>(type='export', status='completed')

    API-->>U: 200 JSON { user, transactions, recipients, bankAccounts, settings, consents }

The current implementation (/api/user/data-export) returns data inline as JSON. For production, large exports should be written to a temporary signed S3 URL and the download_url field in data_access_requests populated.

DSAR Tracking

All DSARs are tracked in the data_access_requests table:

Field Purpose
request_type export, erasure, rectification, restriction
status pending -> processing -> completed/rejected
requested_at When the user submitted the request
completed_at When the request was fulfilled
download_url Temporary URL for data export files
notes Internal processing documentation

Anonymization Techniques

For Analytics Retention

After the active retention period, data can be anonymized for analytics rather than deleted:

Data Type Anonymization Technique Reversible? Analytics Value
User identity Replace name/email with opaque ID No User-level metrics without PII
Transaction amounts Retain exact values (not PII) N/A Revenue and volume analytics
Geographic data Retain country codes only N/A Corridor analysis
Timestamps Retain date, remove time Partially Trend analysis
IP addresses Replace with 0.0.0.0 No None (removed for privacy)
Bank account numbers Replace with ****{last4} No None
Phone numbers Remove entirely No None

Anonymization SQL Pattern

-- Anonymize a deleted user's data for analytics retention
UPDATE users SET
    email = 'anon_' || id || '@analytics.internal',
    first_name = '[ANON]',
    last_name = '[ANON]',
    phone = NULL,
    date_of_birth = NULL,
    national_id_hash = NULL,
    password_hash = 'ANONYMIZED'
WHERE id = ? AND deleted_at IS NOT NULL;

-- Transaction data is retained as-is (amounts are not PII)
-- Recipient names are redacted
UPDATE recipients SET
    name = 'Recipient_' || id,
    bank_account = '****' || SUBSTR(bank_account, -4)
WHERE user_id = ?;

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 Architecture

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:


Audit Log Table Design

Schema

CREATE TABLE audit_log (
    id          TEXT PRIMARY KEY,           -- Prefixed ID (e.g., 'aud_a1b2c3...')
    timestamp   TEXT DEFAULT CURRENT_TIMESTAMP,
    user_id     TEXT REFERENCES users(id),  -- NULL for unauthenticated events
    action      TEXT NOT NULL,              -- Event type (e.g., 'auth.login')
    resource_type TEXT,                     -- Entity type (e.g., 'transaction')
    resource_id TEXT,                       -- Entity ID (e.g., 'tx_abc123')
    details     TEXT,                       -- JSON string with event-specific data
    ip_address  TEXT,                       -- Client IP (from X-Forwarded-For)
    user_agent  TEXT,                       -- Browser/app user agent
    request_id  TEXT                        -- Correlation ID for multi-event requests
);

CREATE INDEX idx_audit_log_user ON audit_log(user_id);
CREATE INDEX idx_audit_log_action ON audit_log(action);
-- Note: idx_audit_log_timestamp is planned but not yet implemented in db.ts

Design Rationale

Column Design Decision
id TEXT with prefix for consistency with all other Drop tables
timestamp TIMESTAMPTZ (PostgreSQL 16 — ADR-014; all environments)
user_id Nullable FK -- some events (failed login, rate limit hit) occur before authentication
action Dot-notation namespace (e.g., auth.login, transaction.create) for hierarchical filtering
resource_type + resource_id Generic resource reference avoids polymorphic FKs while enabling resource-specific queries
details JSON TEXT for flexible, event-specific metadata without schema changes per event type
request_id Correlates multiple audit entries from a single API request (e.g., transaction creation generates audit + notification)

Audit Event Flow

flowchart TD
    A[User Action] --> B[API Route Handler]
    B --> C{Action Type}

    C -->|Authentication| D[Auth Events]
    C -->|Transaction| E[Financial Events]
    C -->|Settings/Profile| F[Account Events]
    C -->|Admin/Compliance| G[Admin Events]

    D --> H[Write audit_log]
    E --> H
    F --> H
    G --> H

    H --> I[Primary Indexes]
    I --> J[idx_audit_log_user<br/>User investigation]
    I --> K[idx_audit_log_timestamp<br/>Time-range queries]
    I --> L[idx_audit_log_action<br/>Event type filtering]

    H --> M{Severity Check}
    M -->|Critical| N[AML Alert Pipeline]
    M -->|Normal| O[Stored for review]
    N --> P[aml_alerts table]
sequenceDiagram
    participant User
    participant API as API Handler
    participant Audit as Audit Logger
    participant DB as Database
    participant AML as AML Monitor

    User->>API: POST /transactions/remittance
    API->>API: Validate request
    API->>DB: BEGIN transaction
    API->>DB: UPDATE bank_accounts (debit)
    API->>DB: INSERT transactions
    API->>Audit: Log 'transaction.create'
    Audit->>DB: INSERT audit_log
    API->>DB: COMMIT

    Audit->>AML: Check transaction patterns
    AML->>DB: Query recent transactions for user
    alt Suspicious pattern detected
        AML->>DB: INSERT aml_alerts
    end

Audit Event Types

Action Category Trigger Logged Details
auth.login Authentication Successful BankID login {method: "bankid", provider: "bankid"}
auth.login.failed Authentication Failed login attempt {reason: "invalid_credentials", email: "..."}
auth.logout Authentication User logout {sessions_revoked: N}
auth.session.created Authentication New session created {session_id: "ses_..."}
auth.session.revoked Authentication Session revoked {session_id: "ses_..."}
auth.token.refreshed Authentication JWT token refreshed {new_session_id: "ses_..."}
transaction.create Financial Remittance or QR payment created {type, amount, currency, fee, recipient_id/merchant_id}
transaction.complete Financial Transaction marked completed {transaction_id: "tx_..."}
transaction.fail Financial Transaction marked failed {transaction_id, reason}
qr_payment.create Financial QR payment executed {amount, merchant_id, fee}
bank_account.link Account Bank account linked via AISP {bank_name, last4_account}
bank_account.balance_sync Account Balance refreshed from AISP {bank_account_id, balance}
recipient.create Account New recipient added {country, currency}
recipient.delete Account Recipient removed {recipient_id}
settings.update Account User settings changed {changed_fields: ["currency","language"]}
merchant.register Account Merchant profile created {business_name, org_number}
kyc.status_change Compliance KYC status updated {old_status, new_status, method}
consent.granted Compliance GDPR consent given {consent_type, ip_address}
consent.withdrawn Compliance GDPR consent withdrawn {consent_type, ip_address}
dsar.export Compliance Data export request completed {request_id}
dsar.erasure Compliance Account deletion requested {request_id}
complaint.created Compliance Customer complaint filed {category, complaint_id}
complaint.resolved Compliance Complaint resolved {complaint_id, resolution_days}
aml.alert_created AML Suspicious activity detected {alert_type, severity, transaction_id}
aml.alert_resolved AML AML alert investigated and resolved {alert_id, resolution}
str.filed AML STR submitted to authorities {str_id, reference_number}
screening.completed AML PEP/sanctions screening done {screening_type, result}
user.deleted Account User account deleted (GDPR) {reason: "gdpr_erasure"}
rate_limit.exceeded Security Rate limit hit {endpoint, ip_address, limit}
card.created Account Card created (FUTURE) {type, last_four}
card.frozen Account Card frozen (FUTURE) {card_id}
card.cancelled Account Card cancelled (FUTURE) {card_id}

Tamper Detection

Hash Chain Mechanism

To ensure audit log integrity (detect unauthorized modifications or deletions), the audit system should implement a hash chain:

flowchart LR
    E1[Entry 1<br/>hash = SHA256<br/>data + genesis] --> E2[Entry 2<br/>hash = SHA256<br/>data + E1.hash]
    E2 --> E3[Entry 3<br/>hash = SHA256<br/>data + E2.hash]
    E3 --> E4[Entry N<br/>hash = SHA256<br/>data + E(N-1).hash]

Proposed implementation:

Add a chain_hash column to audit_log:

ALTER TABLE audit_log ADD COLUMN chain_hash TEXT;

Each entry's hash is computed as:

chain_hash = SHA256(
    timestamp || user_id || action || resource_type ||
    resource_id || details || previous_chain_hash
)

Verification: Walk the chain from the first entry, recomputing each hash. A mismatch indicates tampering. This can be run as a periodic integrity check job.

Current status: Not yet implemented. The audit table stores events without tamper detection. Hash chain is a Phase 3 enhancement (pre-production launch).

Alternative: Append-Only with Write-Once Storage

For production, audit logs should be replicated to write-once storage (AWS S3 Object Lock / Glacier Vault Lock) within minutes of creation. This provides:


Compliance Requirements

PSD2 Audit Trail

Requirement Implementation Status
Record all payment transactions transaction.create event with full details Implemented
Record authentication events auth.login, auth.logout events Implemented
Record consent actions consent.granted, consent.withdrawn events Implemented
5-year retention Retention policy defined (see data-lifecycle.md) Policy defined
Tamper-evident Hash chain proposed, not yet implemented Planned

GDPR Audit Trail

Requirement Implementation Status
Record data access requests dsar.export, dsar.erasure events Implemented
Record consent changes consent.granted, consent.withdrawn events Implemented
Record data deletion user.deleted event Implemented
Demonstrate accountability Full audit trail queryable by user_id Implemented

AML Audit Trail

Requirement Implementation Status
Record suspicious activity alerts aml.alert_created event Implemented
Record STR filings str.filed event Implemented
Record screening results screening.completed event Implemented
Record KYC status changes kyc.status_change event Implemented

Log Retention and Searchability

Query Patterns

Query Use Case Index Used Example SQL
All events for a user Investigation, DSAR idx_audit_log_user SELECT * FROM audit_log WHERE user_id = ? ORDER BY timestamp DESC
Events in time range Compliance reporting idx_audit_log_timestamp SELECT * FROM audit_log WHERE timestamp BETWEEN ? AND ?
Events by type Pattern analysis idx_audit_log_action SELECT * FROM audit_log WHERE action = 'transaction.create'
Events for a resource Transaction audit trail Sequential scan (consider composite index) SELECT * FROM audit_log WHERE resource_type = 'transaction' AND resource_id = ?
Recent events globally Dashboard, monitoring idx_audit_log_timestamp SELECT * FROM audit_log ORDER BY timestamp DESC LIMIT 50

Retention Tiers

Tier Age Storage Access
Hot 0-3 months Primary database, fully indexed Real-time queries
Warm 3-12 months Primary database, indexed Standard queries
Cold 1-5 years Archive storage (S3) Restored on demand
Purge 5+ years Deleted Not available

Cross-References

Database Architecture

Indexing Strategy

Indexing Strategy

Version: 1.0 Date: 2026-02-21 Status: Approved Owner: Database Architect


Overview

Drop's indexing strategy is designed around actual user flow query patterns. Every index exists because a specific query needs it. No speculative indexes.

Current index count: 16 indexes across 19 tables (defined in db.ts schema).


Query Patterns by User Flow

Login Flow (BankID OIDC)

sequenceDiagram
    participant U as User
    participant API as API
    participant DB as Database

    U->>API: BankID callback (code, state)
    API->>DB: Q1: Find user by national_id_hash
    DB-->>API: User or NULL
    alt New user
        API->>DB: Q2: INSERT users
        API->>DB: Q3: INSERT settings (defaults)
    end
    API->>DB: Q4: INSERT sessions
    API-->>U: JWT cookie + redirect
Query ID SQL Pattern Index Required Current Coverage
Q1 SELECT * FROM users WHERE national_id_hash = ? idx_users_national_id (partial: WHERE NOT NULL) Covered
Q2 INSERT INTO users (...) None (PK insert) N/A
Q3 INSERT INTO settings (...) None (PK insert) N/A
Q4 INSERT INTO sessions (...) None (PK insert) N/A

Authentication Middleware (every authenticated request)

Query ID SQL Pattern Index Required Current Coverage
Q5 SELECT * FROM sessions WHERE token_hash = ? AND revoked = 0 AND expires_at > ? idx_sessions_token Covered
Q6 SELECT * FROM users WHERE id = ? PRIMARY KEY Covered

Dashboard View

sequenceDiagram
    participant U as User
    participant API as API
    participant DB as Database

    U->>API: GET /auth/me
    API->>DB: Q5: Verify session (token_hash)
    API->>DB: Q6: Get user by PK
    API->>DB: Q7: Get bank accounts for user
    DB-->>API: Bank accounts with cached balances
    API-->>U: User profile + total balance
Query ID SQL Pattern Index Required Current Coverage
Q7 SELECT * FROM bank_accounts WHERE user_id = ? idx_bank_accounts_user Covered

Transaction History

Query ID SQL Pattern Index Required Current Coverage
Q8 SELECT * FROM transactions WHERE user_id = ? [AND type = ?] [AND status = ?] ORDER BY created_at DESC LIMIT ? OFFSET ? idx_transactions_user Covered (user_id)
Q9 SELECT COUNT(*) FROM transactions WHERE user_id = ? idx_transactions_user Covered

Note: The type and status filters are applied after the user_id index lookup. At current scale (< 10K transactions per user), this is efficient. A composite index (user_id, created_at DESC) would optimize the ORDER BY for users with many transactions.

Create Remittance

Query ID SQL Pattern Index Required Current Coverage
Q10 SELECT * FROM recipients WHERE id = ? AND user_id = ? idx_recipients_user + PK Covered
Q11 SELECT * FROM exchange_rates WHERE to_currency = ? Sequential scan (6 rows) Acceptable (tiny table)
Q12 SELECT * FROM bank_accounts WHERE user_id = ? AND is_primary = 1 idx_bank_accounts_user Covered
Q13 UPDATE bank_accounts SET balance = balance - ? WHERE id = ? AND balance >= ? PK Covered
Q14 INSERT INTO transactions (...) None (PK insert) N/A

Create QR Payment

Query ID SQL Pattern Index Required Current Coverage
Q15 SELECT * FROM merchants WHERE id = ? PK Covered
Q12 (Same as remittance -- primary bank account) idx_bank_accounts_user Covered
Q13 (Same as remittance -- balance debit) PK Covered
Q14 (Same as remittance -- insert transaction) N/A N/A

Notifications List

Query ID SQL Pattern Index Required Current Coverage
Q16 SELECT * FROM notifications WHERE user_id = ? ORDER BY created_at DESC idx_notifications_user Covered
Q17 UPDATE notifications SET read = 1 WHERE id IN (?, ?, ...) AND user_id = ? PK + idx_notifications_user Covered

Settings View/Update

Query ID SQL Pattern Index Required Current Coverage
Q18 SELECT * FROM settings WHERE user_id = ? PK (user_id IS the PK) Covered
Q19 UPDATE settings SET ... WHERE user_id = ? PK Covered

Merchant Dashboard

Query ID SQL Pattern Index Required Current Coverage
Q20 SELECT * FROM merchants WHERE user_id = ? Sequential scan (1 merchant per user) Acceptable
Q21 SELECT * FROM transactions WHERE merchant_id = ? AND created_at >= ? idx_transactions_merchant Partially covered (no composite with created_at)

Recipient Management

Query ID SQL Pattern Index Required Current Coverage
Q22 SELECT * FROM recipients WHERE user_id = ? LIMIT ? OFFSET ? idx_recipients_user Covered
Q23 SELECT * FROM recipients WHERE id = ? AND user_id = ? PK + idx_recipients_user Covered
Q24 DELETE FROM recipients WHERE id = ? AND user_id = ? PK Covered

Compliance Queries (Admin/Internal)

Query ID SQL Pattern Index Required Current Coverage
Q25 SELECT * FROM audit_log WHERE user_id = ? ORDER BY timestamp DESC idx_audit_log_user Covered
Q26 SELECT * FROM audit_log WHERE action = ? AND timestamp BETWEEN ? AND ? idx_audit_log_action + idx_audit_log_timestamp Partially (separate indexes, no composite)
Q27 SELECT * FROM aml_alerts WHERE user_id = ? AND status IN ('open','investigating') idx_aml_alerts_user Covered
Q28 SELECT * FROM aml_alerts WHERE status = 'open' ORDER BY created_at idx_aml_alerts_status (proposed) Not covered (needs new index)
Q29 SELECT * FROM complaints WHERE user_id = ? ORDER BY created_at DESC idx_complaints_user Covered
Q30 SELECT * FROM complaints WHERE status IN ('received','investigating') idx_complaints_status (proposed) Not covered (needs new index)

Index Inventory

Current Indexes (defined in db.ts)

Index Name Table Column(s) Type Rationale
idx_users_national_id users national_id_hash B-tree, partial (WHERE NOT NULL) BankID login deduplication -- find user by hashed national ID
idx_recipients_user recipients user_id B-tree List recipients per user, verify ownership
idx_transactions_user transactions user_id B-tree Transaction history per user (most frequent query)
idx_transactions_merchant transactions merchant_id B-tree Merchant dashboard -- transactions for merchant (documented in DATABASE-SCHEMA.md)
idx_tx_idempotency transactions idempotency_key B-tree, unique, partial (WHERE NOT NULL) Prevent duplicate transaction submission
idx_bank_accounts_user bank_accounts user_id B-tree Dashboard balance lookup, transaction source
idx_sessions_user sessions user_id B-tree Revoke all sessions on logout
idx_sessions_token sessions token_hash B-tree Auth middleware -- validate session on every request
idx_notifications_user notifications user_id B-tree Notifications list per user (documented in DATABASE-SCHEMA.md)
idx_audit_log_user audit_log user_id B-tree User investigation, DSAR compliance
idx_audit_log_action audit_log action B-tree Event type filtering for monitoring
idx_audit_log_timestamp audit_log timestamp B-tree Time-range queries for compliance reporting (documented in DATABASE-SCHEMA.md)
idx_aml_alerts_user aml_alerts user_id B-tree Per-user AML alert lookup
idx_aml_alerts_status aml_alerts status B-tree Open alerts dashboard (documented in DATABASE-SCHEMA.md)
idx_complaints_user complaints user_id B-tree Per-user complaint history
idx_screening_user screening_results user_id B-tree Per-user screening history

Indexes from DATABASE-SCHEMA.md (not in db.ts code)

The DATABASE-SCHEMA.md documentation lists additional indexes that may not be in the current db.ts SQLITE_SCHEMA string:

Index Name Table Column(s) Status
idx_merchants_org merchants org_number Documented but covered by UNIQUE constraint
idx_cards_user cards user_id Documented, may not be in db.ts
idx_spending_limits_user spending_limits user_id Documented, may not be in db.ts
idx_spending_limits_card spending_limits card_id Documented, may not be in db.ts
idx_consents_user consents user_id Documented, may not be in db.ts
idx_data_requests_user data_access_requests user_id Documented, may not be in db.ts
idx_complaints_status complaints status Documented, may not be in db.ts

Recommendation: Reconcile DATABASE-SCHEMA.md with actual db.ts code. Add missing indexes to the schema if the queries justify them.


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


EXPLAIN ANALYZE Examples

Transaction History Query (most common)

-- PostgreSQL 16 (all environments — ADR-014)
EXPLAIN ANALYZE
SELECT * FROM transactions
WHERE user_id = 'usr_demo1'
ORDER BY created_at DESC
LIMIT 20 OFFSET 0;
-- Expected: Index Scan using idx_transactions_user on transactions
--           Sort Key: created_at DESC
--           Rows Removed by Index: 0 (all rows match user_id)

Session Validation (every request)

-- PostgreSQL 16
EXPLAIN ANALYZE
SELECT * FROM sessions
WHERE token_hash = 'abc123...'
AND revoked = FALSE
AND expires_at > NOW();
-- Expected: Index Scan using idx_sessions_token on sessions (token_hash=?)

-- PostgreSQL
EXPLAIN ANALYZE
SELECT * FROM sessions
WHERE token_hash = 'abc123...'
AND revoked = 0
AND expires_at > '2026-02-21T00:00:00';
-- Expected: Index Scan using idx_sessions_token on sessions (cost=0.28..8.30)
--           Filter: (revoked = 0 AND expires_at > ...)

Audit Log by User (investigation)

-- PostgreSQL
EXPLAIN ANALYZE
SELECT * FROM audit_log
WHERE user_id = 'usr_demo1'
ORDER BY timestamp DESC
LIMIT 100;
-- Expected: Index Scan Backward using idx_audit_log_user

Performance Monitoring

Key Metrics to Track

Metric Target Alert Threshold Query
Session validation latency < 5ms > 20ms SELECT * FROM sessions WHERE token_hash = ?
Transaction list latency < 50ms > 200ms SELECT * FROM transactions WHERE user_id = ? ORDER BY created_at DESC LIMIT 20
Audit log write latency < 10ms > 50ms INSERT INTO audit_log (...)
Index bloat < 20% > 50% pg_stat_user_indexes
Sequential scans on large tables 0 Any pg_stat_user_tables.seq_scan for transactions, audit_log

Periodic Index Maintenance (PostgreSQL)

-- Check index usage
SELECT schemaname, tablename, indexname, idx_scan, idx_tup_read
FROM pg_stat_user_indexes
ORDER BY idx_scan ASC;

-- Check for bloated indexes
SELECT pg_size_pretty(pg_relation_size(indexrelid)) as size, indexrelid::regclass
FROM pg_stat_user_indexes
ORDER BY pg_relation_size(indexrelid) DESC;

-- Reindex if bloated
REINDEX INDEX CONCURRENTLY idx_transactions_user;

Cross-References

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:

Scope: This document covers the Drop platform — web app (drop-web), API server (drop-api), and mobile app (drop-mobile) — and their integrations with BankID, Open Banking (AISP/PISP), Sumsub KYC, and payment rails. It excludes the Drop landing/marketing site, the Cards feature (feature-flagged, future), and future Vipps Login integration.


2. System Context (C4 Level 1)

graph TB
    subgraph actors["External Actors"]
        sender["Sender<br/>(Norwegian Resident, 18+)<br/>Sends money abroad via PISP"]
        receiver["Receiver<br/>(30+ countries)<br/>Receives remittance"]
        merchant["Merchant<br/>(Norwegian Business)<br/>Accepts QR payments"]
    end

    subgraph drop_system["Drop Payment System (ALAI Holding AS)"]
        drop["Drop<br/>Next.js 15 + Hono v4 + Expo SDK 54<br/>PSD2 Pass-through App<br/>(AISP + PISP)"]
    end

    subgraph banking["Banking & Open Banking"]
        bankid["BankID Norway<br/>OIDC Identity Provider<br/>Strong Customer Authentication"]
        nordic_banks["Nordic Banks<br/>(DNB, SpareBank1, Nordea)<br/>Berlin Group NextGenPSD2 APIs<br/>AISP: Read balance<br/>PISP: Initiate payment"]
        payment_rails["Payment Rails<br/>SEPA (EEA) / SWIFT (non-EEA)<br/>30+ remittance corridors"]
    end

    subgraph compliance["Compliance & KYC"]
        sumsub["Sumsub<br/>KYC/AML Provider<br/>Document verification<br/>PEP/sanctions screening"]
        finanstilsynet["Finanstilsynet<br/>Norwegian FSA<br/>PISP/AISP registration<br/>Regulatory oversight"]
        okokrim["Okokrim / EFE<br/>Financial Intelligence Unit<br/>STR/SAR filing"]
    end

    subgraph infrastructure["Infrastructure"]
        aws["AWS App Runner<br/>eu-north-1 (Stockholm)<br/>Container hosting + auto-scaling"]
        cloudflare["Cloudflare<br/>CDN, WAF, DDoS protection<br/>DNS, TLS termination<br/>getdrop.no"]
        sentry["Sentry<br/>Error tracking<br/>Performance monitoring"]
    end

    sender -->|"BankID login, view balance (AISP), send money (PISP), QR payments"| drop
    receiver -.->|"Receives funds via bank transfer"| payment_rails
    merchant -->|"Register business, view dashboard, generate QR code"| drop

    drop -->|"OIDC authorize, ID token verification, age/identity check"| bankid
    drop -->|"AISP: GET /accounts /balances; PISP: POST /payments"| nordic_banks
    drop -->|"PISP payment routing — SEPA for EEA, SWIFT for non-EEA"| payment_rails

    drop -->|"Applicant creation, document upload, webhook results"| sumsub
    drop -.->|"License registration, regulatory reporting"| finanstilsynet
    drop -.->|"STR filing (hvitvaskingsloven)"| okokrim

    drop -->|"Deploy containers, auto-scale"| aws
    drop -->|"DNS routing, TLS, WAF, DDoS protection"| cloudflare
    drop -->|"Error events, performance traces"| sentry

    nordic_banks -->|"Execute transfers"| payment_rails

3. Container Diagram (C4 Level 2)

C4Container
  title Drop — Container Diagram (C4 Level 2)

  Person(user, "End User", "Norwegian resident 18+, authenticated via BankID")
  Person(merchant, "Merchant", "Business owner receiving QR payments")

  System_Boundary(drop, "Drop Platform") {
    Container(web, "drop-web", "Next.js 15, React 19, Tailwind v4", "SSR web app. 10 screens: Login, Onboarding, Dashboard, SendMoney, BankAccounts, TransactionHistory, ScanQR, Profile, Notifications, MerchantDashboard. BankID auth via httpOnly cookie.")
    Container(api, "drop-api", "Hono v4, Node.js 22 Alpine", "REST API server. 26+ endpoints under /v1/. BankID OIDC callback, transaction processing, recipient management, merchant registration, GDPR compliance, admin operations.")
    Container(mobile, "drop-mobile", "Expo SDK 54, React Native", "Native iOS/Android app. BankID auth via expo-web-browser deep linking (drop://auth/callback). AsyncStorage for token. 4 tabs: Hjem, Send, QR, Profil.")
    ContainerDb(db, "Database", "SQLite (dev) / PostgreSQL 16 (prod)", "19 tables: users, sessions, transactions, bank_accounts, recipients, merchants, notifications, settings, cards, spending_limits, exchange_rates, audit_log, aml_alerts, str_reports, screening_results, consents, data_access_requests, complaints, rate_limits.")
  }

  System_Ext(bankid, "BankID OIDC", "Norwegian eID provider. OIDC authorize/token/JWKS endpoints. auth.bankid.no (prod).")
  System_Ext(sumsub, "Sumsub", "KYC/AML identity verification. WebSDK (web), React Native SDK (mobile), webhooks for status updates.")
  System_Ext(openbanking, "Open Banking APIs", "Berlin Group NextGenPSD2. AISP (balance reads) and PISP (payment initiation) via Neonomics aggregator (planned).")
  System_Ext(sepa, "SEPA/SWIFT Networks", "International payment rails for remittance settlement to 30+ countries.")

  Rel(user, web, "HTTPS", "Browser — getdrop.no")
  Rel(user, mobile, "HTTPS", "iOS/Android app")
  Rel(merchant, web, "HTTPS", "Merchant dashboard")

  Rel(web, api, "HTTPS REST", "/api/* and /v1/* endpoints, JSON, httpOnly cookie")
  Rel(mobile, api, "HTTPS REST", "/v1/* endpoints, JSON, Bearer token")

  Rel(api, db, "SQL", "Parameterized queries via db.ts dual-driver abstraction")
  Rel(api, bankid, "OIDC", "Authorization code flow, JWKS token verification")
  Rel(api, sumsub, "REST + Webhooks", "Applicant creation, document checks, HMAC-verified webhooks")
  Rel(api, openbanking, "Berlin Group NextGenPSD2", "AISP balance reads, PISP payment initiation with SCA")
  Rel(api, sepa, "ISO 20022 (via banking partner)", "Remittance settlement to 30+ countries")

4. Component Overview

Component Responsibility Technology Owner Team
drop-web SSR web application, user onboarding, dashboard, send money, QR scan, merchant dashboard Next.js 15, React 19, Tailwind v4, shadcn/ui ALAI — Frontend
drop-api REST API, BankID OIDC, JWT sessions, payment processing, KYC/GDPR/AML compliance Hono v4, Node.js 22 ALAI — Backend
drop-mobile Native iOS/Android, BankID auth, send money, QR scan, transaction history Expo SDK 54, React Native ALAI — Mobile
Database Persistent storage, 19 tables, dual-driver (SQLite/PostgreSQL) SQLite 3 (dev) / PostgreSQL 16 (prod) ALAI — Backend
BankID OIDC Strong Customer Authentication (SCA), Norwegian identity provider OIDC 1.0 BankID Norge
Sumsub KYC Document verification, PEP/sanctions screening, AML risk scoring Sumsub API + SDK Sumsub
Open Banking (AISP/PISP) Balance reads from user's bank, payment initiation from user's bank Berlin Group NextGenPSD2, Neonomics aggregator ALAI + Neonomics

Component Descriptions

drop-web

Responsibility: Server-side rendered web application serving all 10 core screens. Handles the BankID OIDC redirect initiation, authentication callback (sets httpOnly cookie), and renders the full UI from Login to MerchantDashboard. Acts as BFF (Backend For Frontend) for the Next.js API routes at /api/auth/*. Key Interfaces: HTTP GET/POST to Next.js API routes (/api/auth/bankid/*); Hono API REST calls (/v1/*) via fetch with cookie credentials. Rationale: Separate from the API to allow independent scaling, enable SSR for SEO on the landing/marketing site, and encapsulate web-specific auth session management (httpOnly cookie).

drop-api

Responsibility: Central REST API serving both web and mobile clients. Owns all business logic: BankID OIDC code exchange, JWT issuance, transaction processing (remittance, QR payment), KYC initiation, GDPR endpoints, merchant management, admin operations, and AML compliance. Applies a 7-step middleware chain on every request. Key Interfaces: 26+ endpoints under /v1/. External calls to BankID token endpoint, Sumsub API, and Open Banking PISP/AISP. Rationale: Single source of business logic truth, consumed by both web (cookie auth) and mobile (Bearer token auth). Hono v4 chosen for performance on Node.js 22 (see ADR-008).

drop-mobile

Responsibility: Native iOS and Android application. Provides the core payment features: BankID login, dashboard with balance, send money, QR scanner, transaction history, and profile management. Key Interfaces: Same Hono API /v1/* endpoints as web, using Bearer token (Authorization: Bearer <jwt>) instead of cookies. BankID auth via expo-web-browser + deep link drop://auth/callback. Rationale: Separate from drop-web to allow platform-native UX, native push notifications (future), and biometric auth (future).


5. Technology Stack

Layer Technology Version Rationale
Frontend Framework Next.js 15 (App Router) SSR + RSC for performance; BFF capability for auth cookie management
UI Framework React 19 Concurrent features, server components
Styling Tailwind CSS v4 Utility-first, design token support
UI Components shadcn/ui (Radix UI) Latest Accessible primitives, keyboard nav, unstyled baseline
Mobile Framework Expo (React Native) SDK 54 Cross-platform iOS/Android, managed workflow, OTA updates
Backend Language TypeScript / Node.js Node 22 LTS Type safety end-to-end, team expertise, shared types with frontend
Backend Framework Hono v4 Ultrafast edge-compatible framework; better performance than Express; native middleware chaining
Primary Database (prod) PostgreSQL 16 ACID compliance, row-level security, rich indexing, AWS RDS managed
Development Database SQLite (better-sqlite3) 3.x Zero-config local dev, WAL mode, dual-driver abstraction switches transparently
Authentication BankID OIDC + jose 2.0 Norwegian legal requirement for SCA; jose for JWKS verification
KYC/AML Sumsub API v1 Document verification, PEP/sanctions, Norwegian compliance coverage
Open Banking Berlin Group NextGenPSD2 via Neonomics (planned) v1.3.12+ PSD2 AISP/PISP; Neonomics aggregator for Nordic bank coverage
Error Tracking Sentry SDK v8 Full-stack error capture, session replay, performance tracing
Container Runtime Docker 24+ Multi-stage build (4 stages), non-root user, Node 22 Alpine
Orchestration AWS App Runner - Auto-scaling, managed TLS, no Kubernetes operational overhead
Edge / CDN Cloudflare - WAF, DDoS protection, CDN for static assets, geo-blocking
Secrets AWS Secrets Manager - JWT_SECRET, BANKID_CLIENT_SECRET, DATABASE_URL, SENTRY_DSN
CI/CD GitHub Actions (planned) - Automated: tsc → lint → vitest → Docker build → ECR push → App Runner deploy

6. Data Flow Overview

6.1 Remittance Payment Flow (Write)

flowchart LR
    A([User — Web/Mobile]) -->|"POST /v1/transactions/remittance"| B[Hono API]
    B -->|"1. Verify JWT + session"| C[(PostgreSQL)]
    B -->|"2. Validate: KYC approved, recipient exists, amount 100-50000 NOK"| B
    B -->|"3. Lookup exchange rate"| C
    B -->|"4. Begin atomic transaction"| C
    C -->|"INSERT transactions status=processing"| C
    C -->|"INSERT audit_log"| C
    C -->|"INSERT notifications"| C
    B -->|"5. Initiate PISP payment"| D[Open Banking API]
    D -->|"SCA redirect URL"| B
    B -->|"6. Return 201 + redirect"| A
    A -->|"7. User completes BankID SCA at bank"| D
    D -->|"8. Webhook: payment confirmed"| B
    B -->|"9. UPDATE transactions status=completed"| C

6.2 Balance Read Flow (Read — AISP)

flowchart LR
    A([User]) -->|"GET /api/auth/me"| B[Next.js BFF / Hono API]
    B -->|"Verify JWT cookie"| C[(PostgreSQL)]
    C -->|"bank_accounts.balance (cached)"| B
    B -->|"If stale: GET /v1/accounts/{id}/balances"| D[Open Banking AISP]
    D -->|"Live balance"| B
    B -->|"UPDATE bank_accounts SET balance, balance_synced_at"| C
    B -->|"Return {totalBalance, accounts}"| A

7. Integration Points

7.1 External Integrations

System Direction Protocol Auth Data Exchanged SLA/Criticality
BankID OIDC Outbound OIDC 1.0 / HTTPS Client ID + Client Secret (code flow) ID token (pid, name, birthdate), access token 99.9% / Critical — all auth blocked if down
Sumsub KYC Outbound + Inbound webhooks REST HTTPS + Webhooks API token + HMAC-SHA256 Applicant data, documents, verification results, risk scores 99.5% / High — new registrations blocked
Open Banking (Neonomics/ASPSP) Outbound Berlin Group NextGenPSD2 / HTTPS eIDAS QWAC cert + OAuth2 Account lists, balances (AISP); payment initiations, payment status (PISP) 99.5% / Critical — payments blocked if PISP down; AISP degrades to cached balance
SEPA/SWIFT Outbound via banking partner ISO 20022 Banking partner credentials Remittance transfers (amounts, IBANs, reference) Best-effort / High — delays expected on bank outages
Cloudflare Inbound (proxied) DNS + HTTPS Cloudflare API key HTTP traffic, TLS, WAF rules 99.99% / Critical — all traffic routed via Cloudflare
AWS Secrets Manager Outbound HTTPS IAM role JWT_SECRET, BANKID_CLIENT_SECRET, DATABASE_URL, SENTRY_DSN 99.99% / Critical — startup fails if unavailable
Sentry Outbound HTTPS (SDK) DSN token Error events, stack traces, performance traces Best-effort / Low — observability only

7.2 Internal Service Integrations

Service Integration Type Protocol Notes
drop-web → drop-api Synchronous REST HTTPS Web auth via httpOnly cookie (drop_token); API calls to /v1/*
drop-mobile → drop-api Synchronous REST HTTPS Bearer token in Authorization header; same /v1/* API endpoints
drop-api → PostgreSQL Synchronous TCP (SQL) db.ts dual-driver abstraction; parameterized queries only

8. Deployment Overview

flowchart TB
    subgraph Internet
        Users[End Users — Browser + Mobile]
    end

    subgraph Cloudflare["Cloudflare Edge (getdrop.no)"]
        DNS[DNS]
        CDN[CDN — Static Assets /_next/static/*]
        WAF[WAF — OWASP CRS + custom rules]
        DDoS[DDoS Protection L3/L4/L7]
    end

    subgraph AWS["AWS eu-north-1 (Stockholm)"]
        subgraph AppRunner["AWS App Runner (PLANNED)"]
            WebApp[drop-web<br/>Next.js 15 standalone<br/>Node.js 22 Alpine<br/>Port 3000<br/>1-5 instances]
            API[drop-api<br/>Hono v4<br/>Node.js 22 Alpine<br/>Port 3001<br/>1-10 instances]
        end

        subgraph DataTier["Data Tier"]
            RDS[(RDS PostgreSQL 16<br/>db.t3.medium → db.r6g.large<br/>Multi-AZ — prod<br/>100GB gp3, auto-scale to 500GB<br/>30-day backup retention)]
        end

        subgraph Supporting["Supporting"]
            ECR[ECR — Container Registry<br/>Image scanning enabled]
            SM[Secrets Manager<br/>JWT_SECRET / BANKID_CLIENT_SECRET<br/>DATABASE_URL / SENTRY_DSN]
            CW[CloudWatch<br/>Logs + Metrics + Alarms]
        end
    end

    Users --> DNS
    DNS --> CDN
    CDN --> WAF
    WAF --> DDoS
    DDoS --> WebApp
    DDoS --> API
    WebApp --> RDS
    API --> RDS
    AppRunner --> ECR
    AppRunner --> SM
    AppRunner --> CW

Environments

Environment URL Purpose Database BankID Scale
Development http://localhost:3000 + :3001 Local dev via docker compose up SQLite (./data/drop.db) Mock (BANKID_MOCK=true) Single instance
Staging https://staging.getdrop.no Pre-release validation, QA, E2E RDS PostgreSQL (separate) BankID test environment 1 replica
Production https://getdrop.no Live traffic RDS PostgreSQL Multi-AZ BankID production Auto-scaled (1-5 web, 1-10 API)

9. Cross-Cutting Concerns

9.1 Authentication & Authorization

9.2 Logging

9.3 Error Handling

9.4 Rate Limiting

9.5 Secrets Management

9.6 Feature Flags


10. Quality Attributes & Architectural Trade-offs

Quality Attribute Target Approach Trade-off
Availability 99.5% uptime AWS App Runner multi-instance, Cloudflare 99.99% edge, RDS Multi-AZ Higher AWS cost vs single-AZ
Performance (p99 latency) < 200ms API responses No external cache (SQLite WAL / PostgreSQL handles load), Cloudflare CDN for static assets No Redis cache — acceptable at current scale
Scalability 1,000 concurrent users (MVP) App Runner auto-scale: 1-10 API instances, 1-5 web instances; stateless API (JWT) All-or-nothing scaling (monolith)
Security OWASP Top 10 compliant Cloudflare WAF, parameterized SQL, httpOnly cookies, BankID SCA, HMAC webhooks BankID adds auth flow complexity
Regulatory Compliance PSD2, GDPR, AML (hvitvaskingsloven) BankID SCA for payments, Sumsub KYC, 19-table compliance schema, STR filing Compliance overhead slows feature delivery
Maintainability Weekly deploys Monolith-first (ADR-005), vitest test suite, TypeScript strict mode Module boundary erosion risk without process isolation
Data Consistency Strong (per transaction) Atomic DB transactions for all financial operations, idempotency keys on payments No eventual consistency — simpler but single-DB dependency

11. Key Architectural Decisions

ADR Decision Status Date
ADR-001 Consolidate to single Hono backend (remove dual middleware) Accepted 2026-02-12
ADR-003 Adopt PSD2 pass-through model — no wallet, no held funds Accepted 2026-02-12
ADR-004 JWT in httpOnly cookies (web) + Bearer tokens (mobile) Accepted 2026-02-12
ADR-005 Monolith-first architecture — extract microservices when team/scale demands Accepted 2026-02-21
ADR-006 Dual-driver DB abstraction: SQLite (dev) / PostgreSQL (prod) Accepted 2026-02-21
ADR-007 BankID as sole identity provider (email/password removed) Accepted 2026-02-21
ADR-008 Hono v4 as the API framework Accepted 2026-02-21
ADR-012 AWS App Runner for container hosting Accepted 2026-02-21

12. Constraints & Assumptions

12.1 Constraints

# Constraint Category Impact
C1 Users must be Norwegian residents (18+) with Norwegian BankID and +47 phone number Regulatory Limits market to Norway; no international expansion without separate licensing
C2 Drop must never hold customer funds (PSD2 pass-through model) Regulatory PISP/AISP architecture mandatory; wallet model legally excluded
C3 BankID SCA required for every financial operation (PISP payment) Regulatory / PSD2 RTS Each payment requires bank SCA redirect — adds UX friction
C4 5-year AML data retention (hvitvaskingsloven) Regulatory Compliance tables cannot be purged; storage costs grow over time
C5 GDPR Art. 17 right to erasure — soft delete + 5yr AML retention override Regulatory Cannot hard-delete user data if AML records exist
C6 Finanstilsynet PISP/AISP license not yet obtained (Phase 2 blocker) Regulatory Live Open Banking API calls not permitted until license or agent arrangement secured
C7 Monolith-first — all containers deploy together Technical No independent scaling per module; full deploy required for any change
C8 Budget: AWS Secrets Manager, App Runner, RDS — cost scales with usage Business Architecture chosen for low base cost; scales to higher tiers on growth

12.2 Assumptions

# Assumption Validation Method Risk if Wrong
A1 Neonomics or Tink will provide Open Banking aggregator service for Phase 2 Nordic bank connectivity Contract negotiation in Phase 2 Direct per-bank ASPSP integration required (significantly higher effort)
A2 BankID Norge will approve Drop's OIDC client registration BankID developer portal application Must use Vipps Login or alternative OIDC provider
A3 PostgreSQL on RDS handles expected transaction volume without read replicas at MVP Load testing before production launch Must add read replicas or implement caching layer
A4 App Runner rolling updates are sufficient (no true blue/green needed at MVP scale) Monitor during first production deploy Must implement custom blue/green via ALB traffic shifting

13. Risks & Mitigations

Risk Likelihood Impact Score Mitigation Contingency
Finanstilsynet license delayed (>12 months) 3 5 15 Use licensed PSP agent arrangement (1-3 months setup) while applying Demo/mock mode continues; partner with licensed PSP
BankID integration blocked (client not approved) 2 5 10 Apply early; prepare Vipps Login as alternative OIDC (same pid claim) Vipps Login fallback (same architecture, different OIDC endpoints)
Open Banking ASPSP API unavailability (AISP) 4 2 8 Show cached balance with staleness indicator Degrade gracefully: display last-known balance
Open Banking ASPSP API unavailability (PISP) 3 5 15 Circuit breaker; notify user to retry Payment cannot proceed — user notified with ETA
Single database bottleneck (PostgreSQL) 2 4 8 Connection pooling, read replicas when needed, App Runner horizontal scale Add read replicas, implement CQRS for transaction reads
Data breach via SQL injection 1 5 5 Parameterized queries (db.ts enforces), WAF, no raw SQL strings in routes GDPR breach notification within 72h, incident response plan
Sumsub KYC outage (new user registrations blocked) 2 3 6 Retry queue; existing approved users unaffected Queue new registrations; manual KYC review for priority users

Approval

Role Name Date Signature
Author Petter Graff 2026-02-23
Technical Lead John (AI Director)
Security Review
Approver (CEO) Alem Bašić

Low-Level Design (LLD)

Low-Level Design Document

Project: {{PROJECT_NAME}} Module/Component: {{MODULE_NAME}} Version: {{VERSION}} Date: {{DATE}} Author: {{AUTHOR}} Status: Draft | In Review | Approved Reviewers: {{REVIEWERS}} Related HLD: HLD Document

Document History

Version Date Author Changes
0.1 {{DATE}} {{AUTHOR}} Initial draft

1. Module Overview

Module: {{MODULE_NAME}} Service/Repo: {{REPO_NAME}} Team Owner: {{TEAM_NAME}}

Single Responsibility: {{ONE_SENTENCE_WHAT_THIS_MODULE_DOES}}

Boundaries:

Key Business Rules:

  1. {{BUSINESS_RULE_1}}
  2. {{BUSINESS_RULE_2}}
  3. {{BUSINESS_RULE_3}}

2. Class / Module Diagram

classDiagram
    class {{ServiceClassName}} {
        -repository: {{RepositoryInterface}}
        -eventBus: EventBus
        -logger: Logger
        +create(dto: Create{{Entity}}Dto): Promise~{{Entity}}~
        +findById(id: string): Promise~{{Entity}} | null~
        +findAll(filter: {{Filter}}Dto): Promise~PaginatedResult~{{Entity}}~~
        +update(id: string, dto: Update{{Entity}}Dto): Promise~{{Entity}}~
        +delete(id: string): Promise~void~
        -validate(dto: unknown): void
        -publishEvent(event: {{DomainEvent}}): Promise~void~
    }

    class {{RepositoryInterface}} {
        <<interface>>
        +findById(id: string): Promise~{{Entity}} | null~
        +findMany(filter: {{Filter}}): Promise~{{Entity}}[]~
        +create(data: Partial~{{Entity}}~): Promise~{{Entity}}~
        +update(id: string, data: Partial~{{Entity}}~): Promise~{{Entity}}~
        +delete(id: string): Promise~void~
        +count(filter: {{Filter}}): Promise~number~
    }

    class {{Entity}} {
        +id: string
        +createdAt: Date
        +updatedAt: Date
        +deletedAt: Date | null
        {{FIELD_1}}: {{TYPE_1}}
        {{FIELD_2}}: {{TYPE_2}}
        +isDeleted(): boolean
        +toJSON(): {{EntityJSON}}
    }

    class Create{{Entity}}Dto {
        {{FIELD_1}}: {{TYPE_1}}
        {{FIELD_2}}: {{TYPE_2}}
    }

    class {{Controller}} {
        -service: {{ServiceClassName}}
        +POST /{{resource}}(body: Create{{Entity}}Dto): Response
        +GET /{{resource}}/:id(): Response
        +GET /{{resource}}(query: {{Filter}}Dto): Response
        +PUT /{{resource}}/:id(body: Update{{Entity}}Dto): Response
        +DELETE /{{resource}}/:id(): Response
    }

    {{ServiceClassName}} --> {{RepositoryInterface}}
    {{ServiceClassName}} --> {{Entity}}
    {{Controller}} --> {{ServiceClassName}}
    {{RepositoryInterface}} ..> {{Entity}}

3. Database Schema

3.1 Tables

{{TABLE_NAME_1}}

Purpose: {{TABLE_PURPOSE}}

Column Type Nullable Default Constraints Description
id UUID NO gen_random_uuid() PK Primary key
created_at TIMESTAMPTZ NO NOW() Record creation timestamp
updated_at TIMESTAMPTZ NO NOW() Last update timestamp
deleted_at TIMESTAMPTZ YES NULL Soft delete timestamp
{{COLUMN_1}} {{TYPE}} {{YES/NO}} {{DEFAULT}} {{CONSTRAINTS}} {{DESCRIPTION}}
{{COLUMN_2}} {{TYPE}} {{YES/NO}} {{DEFAULT}} {{CONSTRAINTS}} {{DESCRIPTION}}
{{FK_COLUMN}} UUID NO FK → {{OTHER_TABLE}}(id) Reference to {{OTHER_TABLE}}

Indexes:

Index Name Columns Type Rationale
{{TABLE_NAME_1}}_pkey id B-tree (PK) Primary key lookup
idx_{{TABLE_NAME_1}}_{{COLUMN}} {{COLUMN}} B-tree {{QUERY_RATIONALE}}
idx_{{TABLE_NAME_1}}_deleted_at deleted_at Partial (WHERE deleted_at IS NULL) Soft-delete filter performance

{{TABLE_NAME_2}}

Purpose: {{TABLE_PURPOSE}}

Column Type Nullable Default Constraints Description
id UUID NO gen_random_uuid() PK Primary key
{{COLUMN_1}} {{TYPE}} {{YES/NO}} {{DEFAULT}} {{CONSTRAINTS}} {{DESCRIPTION}}

3.2 Enums

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

3.3 Migration Notes


4. API Contract

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

POST /{{resource}}

Summary: Create a new {{entity}}

Authentication: Bearer JWT required

Request Body:

{
  "{{field1}}": "{{type_and_example}}",
  "{{field2}}": "{{type_and_example}}"
}

Success Response — 201 Created:

{
  "id": "550e8400-e29b-41d4-a716-446655440000",
  "{{field1}}": "{{value}}",
  "createdAt": "2024-01-01T00:00:00.000Z"
}

Error Responses:

Status Code Description
400 VALIDATION_ERROR Request body fails validation
401 UNAUTHORIZED Missing or invalid JWT
403 FORBIDDEN Insufficient permissions
409 CONFLICT {{DUPLICATE_FIELD}} already exists
422 BUSINESS_RULE_VIOLATION {{BUSINESS_RULE}} not met
500 INTERNAL_ERROR Unexpected server error

GET /{{resource}}/:id

Summary: Retrieve a {{entity}} by ID

Path Parameters:

Parameter Type Description
id UUID The {{entity}} identifier

Success Response — 200 OK:

{
  "id": "550e8400-e29b-41d4-a716-446655440000",
  "{{field1}}": "{{value}}",
  "createdAt": "2024-01-01T00:00:00.000Z"
}

Error Responses:

Status Code Description
401 UNAUTHORIZED Missing or invalid JWT
404 NOT_FOUND {{entity}} not found

GET /{{resource}}

Summary: List {{entities}} with pagination and filtering

Query Parameters:

Parameter Type Default Description
page integer 1 Page number (1-based)
limit integer 20 Items per page (max: 100)
sort string createdAt:desc Sort field and direction
{{filter1}} {{type}} Filter by {{filter1}}

Success Response — 200 OK:

{
  "data": [],
  "pagination": {
    "page": 1,
    "limit": 20,
    "total": 0,
    "totalPages": 0
  }
}

5. Algorithm Specifications

5.1 {{ALGORITHM_NAME}}

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

function {{algorithmName}}(input: {{InputType}}): {{OutputType}}
    // Step 1: Validate input
    if input is null or invalid then
        throw ValidationError("{{VALIDATION_MESSAGE}}")
    end if

    // Step 2: {{STEP_2_DESCRIPTION}}
    result = initialize()
    for each item in input.items do
        if item.satisfies({{CONDITION}}) then
            result.add(transform(item))
        end if
    end for

    // Step 3: {{STEP_3_DESCRIPTION}}
    sorted = result.sortBy({{SORT_CRITERIA}})

    // Step 4: Apply business rules
    for each rule in {{BUSINESS_RULES}} do
        sorted = rule.apply(sorted)
    end for

    return sorted
end function

Edge Cases:


6. Sequence Diagrams

6.1 Create {{Entity}} Flow

sequenceDiagram
    autonumber
    actor Client
    participant GW as API Gateway
    participant Auth as Auth Middleware
    participant SVC as {{ServiceClassName}}
    participant DB as Database
    participant MQ as Message Queue
    participant Email as Email Service

    Client->>GW: POST /{{resource}} {body}
    GW->>Auth: Validate JWT
    Auth-->>GW: User context {userId, roles}
    GW->>SVC: create(dto, userContext)
    SVC->>SVC: validate(dto)
    alt Validation fails
        SVC-->>Client: 400 ValidationError
    end
    SVC->>DB: BEGIN TRANSACTION
    SVC->>DB: INSERT INTO {{TABLE_NAME_1}}
    DB-->>SVC: {{entity}} record
    SVC->>DB: COMMIT
    SVC->>MQ: publish("{{ENTITY_CREATED_EVENT}}", event)
    MQ->>Email: Trigger confirmation email
    Email-->>Client: Email delivered async
    SVC-->>GW: {{entity}} dto
    GW-->>Client: 201 Created

6.2 {{SECONDARY_FLOW_NAME}} Flow

sequenceDiagram
    autonumber
    actor Client
    participant SVC as {{ServiceClassName}}
    participant Cache as Redis
    participant DB as Database

    Client->>SVC: GET /{{resource}}/:id
    SVC->>Cache: GET {{resource}}:{{id}}
    alt Cache hit
        Cache-->>SVC: Cached {{entity}}
        SVC-->>Client: 200 OK (from cache)
    else Cache miss
        Cache-->>SVC: null
        SVC->>DB: SELECT * FROM {{TABLE_NAME_1}} WHERE id = $1
        alt Not found
            DB-->>SVC: null
            SVC-->>Client: 404 Not Found
        end
        DB-->>SVC: {{entity}} record
        SVC->>Cache: SET {{resource}}:{{id}} TTL={{CACHE_TTL}}
        SVC-->>Client: 200 OK
    end

7. State Diagrams

stateDiagram-v2
    [*] --> {{STATE_DRAFT}} : Created

    {{STATE_DRAFT}} --> {{STATE_PENDING}} : submit()
    {{STATE_DRAFT}} --> [*] : delete()

    {{STATE_PENDING}} --> {{STATE_ACTIVE}} : approve()
    {{STATE_PENDING}} --> {{STATE_REJECTED}} : reject(reason)
    {{STATE_PENDING}} --> {{STATE_DRAFT}} : recall()

    {{STATE_ACTIVE}} --> {{STATE_SUSPENDED}} : suspend(reason)
    {{STATE_ACTIVE}} --> {{STATE_COMPLETED}} : complete()
    {{STATE_ACTIVE}} --> {{STATE_CANCELLED}} : cancel(reason)

    {{STATE_SUSPENDED}} --> {{STATE_ACTIVE}} : reactivate()
    {{STATE_SUSPENDED}} --> {{STATE_CANCELLED}} : cancel(reason)

    {{STATE_REJECTED}} --> {{STATE_DRAFT}} : revise()
    {{STATE_COMPLETED}} --> [*]
    {{STATE_CANCELLED}} --> [*]

State Transition Rules:

From To Trigger Guard Condition Side Effect
DRAFT PENDING submit() All required fields populated Notify reviewers
PENDING ACTIVE approve() Approver has {{PERMISSION}} role Send welcome email
ACTIVE SUSPENDED suspend(reason) Admin only Log audit event

8. Error Handling Strategy

8.1 Error Classification

Error Type HTTP Status Retry? Log Level Alert?
ValidationError 400 No INFO No
UnauthorizedError 401 No WARN No
ForbiddenError 403 No WARN Suspicious patterns only
NotFoundError 404 No INFO No
ConflictError 409 No WARN No
ExternalServiceError 502 Yes (3x) ERROR Yes (if sustained)
DatabaseError 500 Yes (1x) ERROR Yes
UnexpectedError 500 No ERROR Yes

8.2 Error Response Format (RFC 7807)

{
  "type": "https://api.{{DOMAIN}}/errors/{{ERROR_CODE}}",
  "title": "{{Human-readable error title}}",
  "status": 400,
  "detail": "{{Specific error message}}",
  "instance": "/{{resource}}/{{id}}",
  "traceId": "{{TRACE_ID}}"
}

8.3 Retry & Fallback Strategy

External API call failure:
  → Retry with exponential backoff: [1s, 2s, 4s]
  → Max retries: 3
  → Circuit breaker: Open after 5 failures in 60s window
  → Fallback: {{FALLBACK_BEHAVIOR}}
  → Alert: PagerDuty if circuit remains open > 5 minutes

9. Concurrency & Thread Safety

Concern Scenario Mitigation
Duplicate creation Two requests create same {{entity}} simultaneously Unique constraint on {{UNIQUE_FIELD}} + catch constraint error → 409
Optimistic locking Two updates to same record version column + check-and-increment
Race condition in {{FLOW}} {{RACE_CONDITION_DESCRIPTION}} {{MITIGATION}} (e.g., DB-level lock, Redis SETNX)

10. Performance Considerations

Operation Target (p99) Current Baseline Optimization
GET /{{resource}}/:id < 50ms {{BASELINE}} Redis cache (TTL={{TTL}})
GET /{{resource}} (list) < 200ms {{BASELINE}} Indexed query + cursor pagination
POST /{{resource}} < 300ms {{BASELINE}} Async event publishing
Bulk import (1000 items) < 5s {{BASELINE}} Batch insert in chunks of 100

Known bottlenecks:


11. Dependencies

Internal Dependencies

Dependency Type Purpose Fallback if unavailable
{{INTERNAL_SERVICE_1}} Synchronous {{PURPOSE}} {{FALLBACK}}
{{SHARED_LIB}} Library {{PURPOSE}} N/A (required)

External Dependencies

Dependency Version Purpose Fallback if unavailable
PostgreSQL {{VERSION}} Primary data store None — module unavailable
Redis {{VERSION}} Caching + session Degrade gracefully (skip cache)
{{EXTERNAL_API}} v{{VERSION}} {{PURPOSE}} {{FALLBACK}}

12. Configuration Parameters

Variable Type Default Required Description
{{MODULE_NAME}}_MAX_PAGE_SIZE integer 100 No Maximum items per page
{{MODULE_NAME}}_CACHE_TTL_SECONDS integer 300 No Cache TTL in seconds
{{MODULE_NAME}}_RETRY_ATTEMPTS integer 3 No External API retry count
{{EXTERNAL_API_KEY_VAR}} string Yes API key for {{EXTERNAL_SERVICE}}
{{DB_CONNECTION_VAR}} string Yes Database connection string

13. Testing Approach

Test Type Tool Coverage Target Location
Unit tests Jest / Vitest > {{UNIT_COVERAGE}}% src/{{module}}/__tests__/unit/
Integration tests Supertest + TestContainers Key flows src/{{module}}/__tests__/integration/
Contract tests Pact All public APIs src/{{module}}/__tests__/contract/

Key test scenarios:


Approval

Role Name Date Signature
Author
Module Owner
Security Review
Tech Lead

Architecture Decision Record (ADR)

Architecture Decision Record — ADR-{{NUMBER}}

Project: {{PROJECT_NAME}} ADR Number: ADR-{{NUMBER}} Title: {{SHORT_DECISION_TITLE}} Version: 1.0 Date: {{DATE}} Author: {{AUTHOR}} Status: Proposed | Accepted | Deprecated | Superseded by ADR-{{NEW_NUMBER}} Reviewers: {{REVIEWERS}}

Document History

Version Date Author Changes
0.1 {{DATE}} {{AUTHOR}} Initial draft
1.0 {{DATE}} {{AUTHOR}} Accepted after review

ADR Numbering Scheme

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


1. Context

1.1 Situation

{{DESCRIBE_THE_SITUATION_REQUIRING_A_DECISION}}

1.2 Forces & Constraints

Technical forces:

Business forces:

Compliance & regulatory:

Existing decisions that constrain this:

1.3 Problem Statement

We need to decide: {{CLEAR_ONE_SENTENCE_PROBLEM_STATEMENT}}


2. Decision

We will: {{CLEAR_DECISION_STATEMENT}}

Rationale (summary): {{ONE_PARAGRAPH_WHY}}


3. Alternatives Considered

Option A: {{OPTION_A_NAME}} ← Selected

Description: {{OPTION_A_DESCRIPTION}}

Pros:

Cons:

Cost/Effort: {{ESTIMATE}} — {{NOTES}}

Risk: {{RISK_LEVEL}} — {{RISK_DESCRIPTION}}


Option B: {{OPTION_B_NAME}}

Description: {{OPTION_B_DESCRIPTION}}

Pros:

Cons:

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:

Cons:

Cost/Effort: {{ESTIMATE}}

Why not chosen: {{SPECIFIC_REASON_REJECTED}}


Comparison Matrix

Criterion Weight Option A (Selected) Option B Option C
{{CRITERION_1}} {{1-5}} {{SCORE}} {{SCORE}} {{SCORE}}
{{CRITERION_2}} {{1-5}} {{SCORE}} {{SCORE}} {{SCORE}}
{{CRITERION_3}} {{1-5}} {{SCORE}} {{SCORE}} {{SCORE}}
Team expertise 4 {{SCORE}} {{SCORE}} {{SCORE}}
Operational complexity 3 {{SCORE}} {{SCORE}} {{SCORE}}
Cost (3-year TCO) 4 {{SCORE}} {{SCORE}} {{SCORE}}
Community/support 2 {{SCORE}} {{SCORE}} {{SCORE}}
Weighted Total {{TOTAL_A}} {{TOTAL_B}} {{TOTAL_C}}

Score: 1 (poor) to 5 (excellent)


4. Consequences

4.1 Positive Consequences

4.2 Negative Consequences

4.3 Neutral / Secondary Effects

4.4 Technical Debt Created


5. Compliance Impact

Regulation Impact Notes
GDPR {{NONE/LOW/MEDIUM/HIGH}} {{DETAILS}}
PCI-DSS {{NONE/LOW/MEDIUM/HIGH}} {{DETAILS}}
HIPAA {{NONE/LOW/MEDIUM/HIGH}} {{DETAILS}}
SOC2 {{NONE/LOW/MEDIUM/HIGH}} {{DETAILS}}

Data residency implications: {{NONE / DATA_MUST_STAY_IN_REGION}}


6. Performance Impact

Metric Before After (Expected) Source
Latency (p99) {{BEFORE}} {{AFTER}} {{BENCHMARK_SOURCE}}
Throughput {{BEFORE}} {{AFTER}} {{BENCHMARK_SOURCE}}
Memory footprint {{BEFORE}} {{AFTER}} {{BENCHMARK_SOURCE}}
Cost (monthly) {{BEFORE}} {{AFTER}} {{ESTIMATE_SOURCE}}

Performance testing plan: {{HOW_WILL_WE_VALIDATE}}


7. Migration / Implementation Notes

7.1 Migration Plan

Phase 1 ({{DURATION}}): {{PHASE_1_DESCRIPTION}}
  - [ ] {{TASK_1}}
  - [ ] {{TASK_2}}

Phase 2 ({{DURATION}}): {{PHASE_2_DESCRIPTION}}
  - [ ] {{TASK_3}}
  - [ ] {{TASK_4}}

Phase 3 ({{DURATION}}): {{PHASE_3_DESCRIPTION}} (if needed)
  - [ ] {{TASK_5}}

7.2 Rollback Strategy

Can we roll back? {{YES/NO/PARTIAL}} — {{EXPLANATION}}

If yes: {{ROLLBACK_STEPS}}

7.3 Feature Flags

Flag Purpose Default
{{FLAG_NAME}} {{PURPOSE}} {{DEFAULT_VALUE}}

ADR Relationship Notes
ADR-{{N}} Prerequisite {{HOW_IT_RELATES}}
ADR-{{N}} Supersedes This decision replaces {{OLD_DECISION}}
ADR-{{N}} Affected This decision impacts {{HOW}}

9. Review Date

Next review: {{REVIEW_DATE}} or when {{TRIGGER_CONDITION}}

Review trigger conditions:

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:

This module does NOT own:

Why this is a separate module: Payments is the core revenue-generating bounded context for Drop. It has distinct business rules (PSD2 compliance, FX calculation, PISP orchestration), its own data domain (transactions, recipients, exchange_rates), and its own compliance requirements (PSD2 Art. 45/46 disclosure, idempotency, AML monitoring). Separating it enables independent testing, focused security review, and future extraction to a dedicated service if transaction volume demands it (per ADR-005 extraction triggers).


2. Interface Definition (Public API)

2.1 Exported Service Interface

// Remittance
interface IRemittanceService {
  /**
   * Calculate pre-payment disclosure (fee, FX rate, receive amount)
   * Required by PSD2 Art. 45/46 before every payment
   * @throws {NotFoundError} if recipientId not found or not owned by user
   * @throws {NotFoundError} if exchange rate for currency not found
   */
  calculateDisclosure(dto: DisclosureDto, userId: string): Promise<DisclosureResult>;

  /**
   * Initiate international remittance via PISP
   * @throws {ForbiddenError} if KYC not approved
   * @throws {ForbiddenError} if insufficient balance
   * @throws {NotFoundError} if recipient not found
   * @throws {ConflictError} if idempotency key collision (returns existing tx)
   * @throws {ValidationError} if amount outside 100-50000 NOK range
   */
  initiateRemittance(dto: RemittanceDto, userId: string): Promise<Transaction>;

  /**
   * Initiate QR merchant payment via PISP (domestic)
   * @throws {ForbiddenError} if KYC not approved
   * @throws {NotFoundError} if merchant not found or inactive
   */
  initiateQRPayment(dto: QRPaymentDto, userId: string): Promise<Transaction>;

  /**
   * List user's transactions with pagination and optional filters
   */
  listTransactions(filter: TransactionFilter, userId: string): Promise<PaginatedTransactions>;

  /**
   * Get a single transaction by ID (must belong to requesting user)
   * @throws {NotFoundError} if not found or access denied
   */
  getTransaction(txId: string, userId: string): Promise<Transaction>;
}

// Recipients
interface IRecipientsService {
  createRecipient(dto: CreateRecipientDto, userId: string): Promise<Recipient>;
  listRecipients(userId: string): Promise<Recipient[]>;
  getRecipient(recipientId: string, userId: string): Promise<Recipient>;
  deleteRecipient(recipientId: string, userId: string): Promise<void>;
}

// Types
export type RemittanceDto = {
  recipientId: string;
  amount: number; // NOK, 100-50000
  bankAccountId: string;
  currency?: string; // Default: 'NOK'
};

export type QRPaymentDto = {
  merchantId: string;
  amount: number; // NOK, positive
};

export type DisclosureDto = {
  type: 'remittance';
  amount: number;
  recipientId: string;
};

export type Transaction = {
  id: string; // tx_<hex16>
  userId: string;
  type: 'remittance' | 'qr_payment';
  status: 'processing' | 'completed' | 'failed';
  amount: number;
  fee: number;
  receiveAmount?: number;
  receiveCurrency?: string;
  exchangeRate?: number;
  recipientId?: string;
  merchantId?: string;
  idempotencyKey?: string;
  createdAt: string;
};

2.2 HTTP Endpoints

Method Path Auth Rate Limit Description
POST /v1/transactions/remittance JWT 10/IP + 3/user per 60s Initiate remittance
POST /v1/transactions/qr-payment JWT 10/IP + 3/user per 60s Initiate QR payment
POST /v1/transactions/disclosure JWT None specific Pre-payment fee disclosure
GET /v1/transactions JWT None specific List user transactions
GET /v1/transactions/:id JWT None specific Get transaction by ID
GET /v1/recipients JWT None specific List saved recipients
POST /v1/recipients JWT None specific Add recipient
GET /v1/recipients/:id JWT None specific Get recipient
DELETE /v1/recipients/:id JWT None specific Delete recipient
GET /v1/rates/:currency JWT 120 req/60s per IP Get exchange rate

2.3 Events Published

The Payments module does not use an async event bus (monolith-first architecture per ADR-005). Side effects (notifications, audit log) are written synchronously within the same DB transaction.

Side Effect Table Written Trigger
Audit trail audit_log Every transaction creation
User notification notifications Transaction created, completed, failed
AML monitoring aml_alerts Triggered by AML rules engine (high amount, high-risk corridor)

3. Internal Structure

routes/
├── transactions.ts         # HTTP request handling, rate limiting, routes
├── recipients.ts           # Recipient CRUD routes
└── rates.ts                # Exchange rate lookup

lib/
├── db.ts                   # Dual-driver DB abstraction (query, run, transaction)
├── middleware/
│   ├── auth.ts             # JWT verification, session validation
│   └── rate-limit.ts       # DB-backed IP + user rate limiting
└── openbanking/
    └── neonomics.ts        # PISP client (Phase 2 — currently mock)

Layer rules:


4. Database Schema

Primary Table: transactions

Column Type Nullable Default Constraints Description
id TEXT NO PK, tx_<hex16> Transaction ID
user_id TEXT NO FK → users(id) Initiating user
type TEXT NO CHECK('remittance','qr_payment') Payment type
status TEXT NO 'processing' CHECK('processing','completed','failed') Payment status
amount REAL NO NOT NULL Amount in NOK
currency TEXT YES 'NOK' Source currency
fee REAL YES 0 Fee in NOK
recipient_id TEXT YES NULL FK → recipients(id) Remittance target
merchant_id TEXT YES NULL FK → merchants(id) QR payment target
send_amount REAL YES NULL Amount in source currency
receive_amount REAL YES NULL Amount in target currency
receive_currency TEXT YES NULL Target currency
exchange_rate REAL YES NULL Rate at payment time
description TEXT YES NULL User description
idempotency_key TEXT YES NULL UNIQUE Duplicate prevention
created_at TEXT NO datetime('now') Creation timestamp

Indexes:

-- PostgreSQL
CREATE INDEX CONCURRENTLY idx_transactions_user_id ON transactions(user_id);
-- Rationale: Every list query filters by user_id

CREATE UNIQUE INDEX idx_tx_idempotency ON transactions(idempotency_key)
    WHERE idempotency_key IS NOT NULL;
-- Rationale: Prevent duplicate payments on retry

CREATE INDEX CONCURRENTLY idx_transactions_recipient ON transactions(recipient_id)
    WHERE recipient_id IS NOT NULL;

CREATE INDEX CONCURRENTLY idx_transactions_merchant ON transactions(merchant_id)
    WHERE merchant_id IS NOT NULL;

Secondary Table: recipients

Column Type Nullable Default Constraints Description
id TEXT NO PK, rec_<hex16> Recipient ID
user_id TEXT NO FK → users(id) Owner
name TEXT NO NOT NULL Recipient full name
country TEXT NO NOT NULL Country code (RS, BA, PL, PK, TR, EU)
currency TEXT NO NOT NULL Target currency
bank_account TEXT NO NOT NULL IBAN or local account number
bank_name TEXT YES NULL Bank name (optional)
created_at TEXT NO datetime('now') Created timestamp

Secondary Table: exchange_rates

Column Type Nullable Default Constraints Description
id INTEGER NO auto PK Surrogate key
from_currency TEXT NO NOT NULL Always 'NOK' at MVP
to_currency TEXT NO NOT NULL RSD, BAM, PLN, PKR, TRY, EUR
rate REAL NO NOT NULL 1 NOK = N target units
updated_at TEXT YES Last update timestamp

5. API Endpoints (Detailed)

POST /v1/transactions/remittance

Request:

{
  "recipientId": "rec_abc123def456gh78",
  "amount": 2000,
  "bankAccountId": "ba_abc123def456gh78",
  "currency": "NOK"
}

Validation:

Success 201:

{
  "data": {
    "id": "tx_rem_abc123def456gh78",
    "type": "remittance",
    "status": "processing",
    "amount": 2000,
    "fee": 10,
    "receiveAmount": 20340,
    "receiveCurrency": "RSD",
    "exchangeRate": 10.17,
    "estimatedDelivery": "2-4 business days",
    "scaRedirect": "https://bank.no/sca/pay/...",
    "createdAt": "2026-02-23T10:00:00.000Z"
  }
}

POST /v1/transactions/disclosure

Request:

{
  "type": "remittance",
  "amount": 2000,
  "recipientId": "rec_abc123def456gh78"
}

Success 200:

{
  "data": {
    "sendAmount": 2000,
    "sendCurrency": "NOK",
    "fee": 10,
    "feePercentage": 0.5,
    "exchangeRate": 10.17,
    "receiveAmount": 20340,
    "receiveCurrency": "RSD",
    "totalCost": 2010,
    "estimatedDelivery": "2-4 business days"
  }
}

6. Business Logic Specifications

6.1 Business Rules

Rule ID Rule Enforced In Error
BR-001 User must have kyc_status = 'approved' before initiating any payment Route handler kyc_required (403)
BR-002 Remittance amount must be 100–50,000 NOK Route validation amount_out_of_range (422)
BR-003 Fee = 0.5% of send amount for remittance Route calculation N/A (business calculation)
BR-004 QR payment fee = merchants.fee_rate (default 1%) Route calculation N/A
BR-005 Recipient must belong to the authenticated user DB query WHERE user_id recipient_not_found (404)
BR-006 Cached bank balance must cover amount + fee Route check insufficient_balance (403)
BR-007 Idempotency key uniqueness prevents duplicate payment on retry UNIQUE DB constraint Return existing transaction (409 or 200)
BR-008 Pre-payment disclosure must be shown before every remittance (PSD2 Art. 45/46) Frontend enforces; API provides via /disclosure N/A
BR-009 Drop never initiates PISP without recording transaction in DB first Atomic transaction: INSERT tx → PISP call Rollback on PISP error

6.2 Validation Rules

Field Type Required Validation Error Message
recipientId string Yes Exists in recipients WHERE user_id "Mottaker ikke funnet"
amount number Yes 100 ≤ amount ≤ 50000, positive "Beløp må være mellom 100 og 50 000 kr"
bankAccountId string Yes Exists in bank_accounts WHERE user_id "Bankkonto ikke funnet"
merchantId string Yes (QR) Exists in merchants WHERE status='active' "Butikk ikke funnet"

6.3 Authorization Rules

Operation Required Role Additional Conditions
Initiate remittance user kyc_status = 'approved'
Initiate QR payment user kyc_status = 'approved'
Get disclosure user Must own recipient
List transactions user Only own transactions (user_id = currentUser)
Add recipient user Any authenticated user
Delete recipient user Must own recipient
View exchange rates user Any authenticated user

7. Event Publishing / Consuming

The Payments module operates in a monolith (ADR-005) — no async message bus. All side effects are synchronous within DB transactions:

7.1 Side Effects on Payment Creation

Side Effect Table Written Method Notes
Audit trail audit_log INSERT within transaction action='transaction.create', resource_type='transaction'
User notification notifications INSERT within transaction title='Overføring startet', body='Din overføring på {amount} kr er under behandling'
AML monitoring aml_alerts INSERT if rule triggered Checked post-commit by AML rules engine

7.2 Events Consumed

The module receives PISP payment status updates via:


8. Dependencies

8.1 Upstream (what this module depends on)

Dependency Type Coupling Reason
middleware/auth.ts Internal module Hard (required for every route) JWT validation, user identity
middleware/rate-limit.ts Internal module Hard (required for payment routes) IP + user rate limiting
lib/db.ts Shared library Hard (required) All data access
PostgreSQL / SQLite Infrastructure Hard Primary storage
Open Banking PISP (Neonomics) External API Hard (prod) Payment initiation — module unavailable if PISP down

8.2 Downstream (what depends on this module)

Consumer What they use Notes
drop-web (Next.js) REST API /v1/transactions/*, /v1/recipients/* Via fetch with cookie auth
drop-mobile (Expo) REST API /v1/transactions/*, /v1/recipients/* Via fetch with Bearer token
compliance/aml module transactions table reads AML rules engine monitors transaction patterns

9. Error Handling & Recovery

Error Scenario Handling User Impact Recovery
DB connection lost Retry 1x, then 503 Request fails — user retries Auto-recover when DB reconnects
PISP API timeout Return 502, transaction stays processing Payment may or may not have gone through PISP idempotency key prevents double charge; poll status endpoint
Duplicate submission Detect via UNIQUE constraint on idempotency_key Return existing transaction No user action needed
KYC not approved Return 403 immediately Clear error message with KYC link User completes KYC verification
Exchange rate missing Return 404 — payment blocked Clear error: corridor not supported Admin updates exchange_rates table
Balance insufficient Return 403 Clear error with balance shown User reduces amount or top-up bank account

10. Configuration & Feature Flags

Environment Variables

Variable Type Default Description
NEXT_PUBLIC_SERVICE_MODE string mock mock = simulated PISP; production = real Neonomics calls
OPEN_BANKING_API_URL string Neonomics base URL (production)
OPEN_BANKING_CLIENT_ID string eIDAS client ID for Neonomics
OPEN_BANKING_CLIENT_SECRET string eIDAS client secret (from Secrets Manager)

Feature Flags (environment variables)

Flag Type Default Description
FEATURE_QR_ENABLED boolean true Toggle QR payment feature
FEATURE_WITHDRAW_ENABLED boolean false Toggle withdrawal feature (Phase 3)

11. Monitoring & Health Checks

Health Check Endpoint

GET /v1/health (owned by health route, includes payment module indicators)

{
  "status": "ok",
  "version": "0.1.0",
  "uptime": 3600,
  "db": "connected",
  "dbLatencyMs": 1,
  "timestamp": "2026-02-23T10:00:00.000Z"
}

Key Metrics (via Sentry + CloudWatch)

Metric Type Alert Threshold Dashboard
Remittance 201 rate Counter Drop > 50% over 5m Sentry Issues
Remittance 502 (PISP down) Counter Any occurrence Sentry Alert
Transaction processing time Histogram p99 > 2000ms CloudWatch
kyc_required 403 rate Counter > 20% of remittance attempts Sentry
insufficient_balance 403 rate Counter > 30% of remittance attempts Sentry

12. Primary Flow — Sequence Diagram

sequenceDiagram
    autonumber
    participant C as Client (Web/Mobile)
    participant RL as Rate Limiter
    participant Auth as Auth Middleware
    participant Route as Transactions Route
    participant DB as PostgreSQL
    participant PISP as Open Banking PISP (Neonomics)

    C->>RL: POST /v1/transactions/remittance
    RL->>RL: Check rate_limits (10/IP, 3/user per 60s)
    RL->>Auth: Forward if within limits
    Auth->>DB: Verify JWT + session + user
    Auth-->>Route: {userId, role, kycStatus}

    Route->>Route: Validate: kycStatus='approved', amount 100-50000
    alt Validation fails
        Route-->>C: 400/403/422
    end

    Route->>DB: SELECT recipient WHERE id=? AND user_id=?
    Route->>DB: SELECT exchange_rate WHERE to_currency=?
    Route->>DB: SELECT bank_account WHERE id=? AND user_id=?
    Route->>Route: Calculate fee, totalCost, receiveAmount
    alt balance < totalCost
        Route-->>C: 403 insufficient_balance
    end

    Route->>DB: BEGIN TRANSACTION
    Route->>DB: UPDATE bank_accounts SET balance = balance - totalCostInOere
    Route->>DB: INSERT INTO transactions (status='processing', idempotency_key=?)
    Route->>DB: INSERT INTO audit_log
    Route->>DB: INSERT INTO notifications
    Route->>DB: COMMIT

    Route->>PISP: POST /v1/payments/sepa-credit-transfers (with idempotency_key)
    PISP-->>Route: {paymentId, scaRedirect, transactionStatus: "RCVD"}

    Route-->>C: 201 {transactionId, status: "processing", scaRedirect}

Approval

Role Name Date Signature
Author Petter Graff 2026-02-23
Module Owner John (AI Director)
Tech Lead John (AI Director)
Reviewer Alem Bašić

Integration Design Document

Integration Design Document

Project: Drop Integration: BankID OIDC + Open Banking (Neonomics AISP/PISP) + Sumsub KYC/AML Version: 1.0 Date: 2026-02-23 Author: Petter Graff, Senior Enterprise Architect Status: Approved Reviewers: Alem Bašić (CEO), John (AI Director)

Document History

Version Date Author Changes
0.1 2026-02-23 Petter Graff Initial draft from real integration docs

1. Integration Overview & Context

Integration Name: Drop External Integration Stack (BankID OIDC + Neonomics Open Banking + Sumsub KYC) Type: Synchronous (REST/HTTPS) + Asynchronous (Webhooks)

Business Purpose: Drop cannot function without these three integrations:

Criticality:

Parties:

Party System Team Contact
Consumer (Drop) drop-api (Hono v4) ALAI Backend john@alai.no
Provider: BankID auth.bankid.no OIDC BankID Norge developer.bankid.no
Provider: Open Banking Neonomics REST API Neonomics neonomics.io
Provider: KYC Sumsub REST API + Webhooks Sumsub sumsub.com

2. Integration Topology Diagram

flowchart TB
    subgraph UserSide["User Devices"]
        Browser["Browser (Next.js)"]
        App["Mobile (Expo)"]
    end

    subgraph DropPlatform["Drop Platform (AWS eu-north-1)"]
        subgraph Edge["Cloudflare Edge"]
            CF["WAF + CDN + DDoS"]
        end
        subgraph App_["drop-web (Next.js BFF)"]
            WebBFF["Next.js API Routes\n/api/auth/bankid/*"]
        end
        subgraph API["drop-api (Hono v4)"]
            AuthRoute["routes/auth.ts\nBankID OIDC callback"]
            TxRoute["routes/transactions.ts\nPISP orchestration"]
            KYCRoute["routes/user.ts\nKYC initiation + webhooks"]
            WebhookRoute["POST /v1/webhooks/sumsub\nHMAC-verified"]
        end
        subgraph DB["PostgreSQL 16"]
            Users["users + sessions"]
            Txs["transactions"]
            Kyc["screening_results\nkyc_status"]
        end
    end

    subgraph BankIDProvider["BankID Norge (auth.bankid.no)"]
        BIDAuth["OIDC /auth endpoint"]
        BIDToken["OIDC /token endpoint"]
        BIDJWKS["JWKS /certs endpoint"]
    end

    subgraph Neonomics["Neonomics Open Banking"]
        NeoAISP["AISP: GET /v1/accounts/{id}/balances"]
        NeoPISP["PISP: POST /v1/payments/sepa-credit-transfers"]
        NeoCB["Circuit Breaker\n3 failures → 60s cooldown"]
    end

    subgraph SumsubProvider["Sumsub KYC"]
        SumAPI["REST API\n/resources/applicants"]
        SumWebhook["Webhooks (HMAC-signed)\nPOST → Drop /v1/webhooks/sumsub"]
        SumSDK["WebSDK (web) +\nReact Native SDK (mobile)"]
    end

    Browser & App --> CF
    CF --> WebBFF & API
    WebBFF --> BIDAuth
    BIDToken --> AuthRoute
    BIDJWKS --> AuthRoute
    AuthRoute --> Users
    TxRoute --> NeoCB --> NeoPISP & NeoAISP
    KYCRoute --> SumAPI
    SumWebhook --> WebhookRoute
    WebhookRoute --> Kyc
    SumSDK --> Browser & App

3. Service Contracts

3.1 Integration: BankID OIDC (Authentication)

Protocol: OpenID Connect 1.0 Authorization Code Flow over HTTPS Direction: Drop → BankID Norge Idempotency: YES — state and nonce parameters per-request

Authentication

Method Details
Type OAuth2 Authorization Code with Client Secret
Client ID Header Sent in token exchange POST body: client_id=BANKID_CLIENT_ID
Client Secret Sent in token exchange POST body: client_secret=BANKID_CLIENT_SECRET
Key rotation BankID JWKS keys rotate periodically — jose library handles automatic JWKS refresh
Token endpoint https://auth.bankid.no/auth/realms/prod/protocol/openid-connect/token

Request Contract — Step 1: Initiate (Redirect)

Endpoint: GET https://auth.bankid.no/auth/realms/prod/protocol/openid-connect/auth

Query Parameters:

client_id=BANKID_CLIENT_ID
redirect_uri=https://getdrop.no/api/auth/bankid/callback
response_type=code
scope=openid+profile
state=CRYPTO_RANDOM_UUID
nonce=CRYPTO_RANDOM_UUID

Drop stores state in: httpOnly cookie bankid_state={state} (web) or in-memory (mobile)

Request Contract — Step 2: Token Exchange

Endpoint: POST https://auth.bankid.no/auth/realms/prod/protocol/openid-connect/token

Request Body:

grant_type=authorization_code
code=AUTH_CODE
redirect_uri=https://getdrop.no/api/auth/bankid/callback
client_id=BANKID_CLIENT_ID
client_secret=BANKID_CLIENT_SECRET

Successful Response 200:

{
  "id_token": "eyJ...",
  "access_token": "...",
  "token_type": "Bearer",
  "expires_in": 300
}

ID Token Claims Used by Drop

Claim Type Usage
pid string Norwegian fødselsnummer (11 digits). Hashed (SHA-256) → users.national_id_hash
name string Full name. Split into users.first_name + users.last_name
sub string Fallback subject identifier if pid absent
iss string Validated: https://auth.bankid.no/auth/realms/prod
aud string Validated: matches BANKID_CLIENT_ID
exp number Token expiry — verified via jose
nonce string Anti-replay — matched against stored nonce

Error Handling

HTTP Status Error Code Drop Action
User cancels BankID N/A (redirect with error=access_denied) Return bankid_cancelled 400
400 invalid_grant Return token_exchange_failed 502
401 unauthorized_client Alert ops — client ID invalid
JWKS verification fails N/A Return jwks_verification_failed 502 — alert ops
pid age check fails (< 18) N/A Return underage 403 — Norwegian legal requirement

Retry Policy

Token exchange: No retry (state-dependent flow — retry means restarting from login)
JWKS fetch: jose handles with built-in caching (5-minute TTL)
On JWKS failure: Alert Sentry, return 502 to user

Rate Limiting

Limit Value Window Action when exceeded
BankID initiate 10 req 60s per IP HTTP 429 from Drop rate limiter
BankID callback 10 req 60s per IP HTTP 429 from Drop rate limiter

3.2 Integration: Open Banking AISP (Balance Reads)

Protocol: Berlin Group NextGenPSD2 v1.3.12+ over HTTPS Direction: Drop → Neonomics → ASPSP (user's bank) Idempotency: YES — X-Request-ID UUID per request

Authentication

Method Details
Type OAuth2 Client Credentials (Neonomics) + eIDAS QWAC certificate (ASPSP direct)
Header Authorization: Bearer {neonomics_access_token}
Key rotation OAuth2 token refresh; eIDAS cert rotation annually
Token endpoint https://api.neonomics.io/auth/token

Request Contract — AISP Balance Read

Endpoint: GET https://api.neonomics.io/v1/accounts/{accountId}/balances

Headers:

Authorization: Bearer {NEONOMICS_ACCESS_TOKEN}
X-Request-ID: {UUID}
X-Consent-ID: {consentId}
Content-Type: application/json

Successful Response 200:

{
  "balances": [
    {
      "balanceType": "expected",
      "balanceAmount": {
        "currency": "NOK",
        "amount": "45230.00"
      },
      "lastChangeDateTime": "2026-02-23T08:00:00.000Z"
    }
  ]
}

PSD2 Constraint: Maximum 4 TPP-initiated balance reads per account per day (RTS Art. 36(6)). User-initiated reads are unlimited.

Drop caches in: bank_accounts.balance (in øre) + bank_accounts.balance_synced_at

Error Handling

HTTP Status Berlin Group Code Drop Handling
403 CONSENT_EXPIRED Delete consent, prompt user to re-link bank account
429 ACCESS_EXCEEDED Back off, show cached balance with timestamp
500+ Server error Circuit breaker, show cached balance

Circuit Breaker Configuration

Failure threshold: 3 failures in 60s window
Open duration: 60s
Half-open test: 1 request
Alert on: Circuit open for > 5 minutes → Sentry HIGH alert
Fallback: Return cached balance from bank_accounts.balance with staleness warning

3.3 Integration: Open Banking PISP (Payment Initiation)

Protocol: Berlin Group NextGenPSD2 v1.3.12+ over HTTPS Direction: Drop → Neonomics → ASPSP (user's bank) Idempotency: YES — X-Request-ID = idempotency_key from transactions table

Request Contract — PISP Remittance

Endpoint: POST https://api.neonomics.io/v1/payments/sepa-credit-transfers

Headers:

Authorization: Bearer {NEONOMICS_ACCESS_TOKEN}
Content-Type: application/json
X-Request-ID: {idempotency_key}
X-Consent-ID: {pisConsentId}

Request Body:

{
  "debtorAccount": {
    "iban": "NO9386011117947"
  },
  "instructedAmount": {
    "currency": "NOK",
    "amount": "2010.00"
  },
  "creditorName": "Marko Petrovic",
  "creditorAccount": {
    "bban": "265-1234567-89"
  },
  "remittanceInformationUnstructured": "Drop remittance tx_rem_abc123"
}

Successful Response 201:

{
  "paymentId": "pay_xyz123",
  "transactionStatus": "RCVD",
  "_links": {
    "scaRedirect": {
      "href": "https://dnb.no/sca/pay/abc123"
    }
  }
}

SCA Redirect Flow: Drop returns scaRedirect URL to client → client redirects user to bank → user authenticates with BankID at bank → bank redirects back to Drop callback → Drop polls payment status.

Retry Policy

Max retries: 3 (retry only on 500, 502, 503 — NOT on 4xx)
Strategy: Exponential backoff with jitter
Delays: [1000ms, 2000ms, 4000ms]
Timeout per attempt: 30000ms
Idempotency: X-Request-ID = idempotency_key prevents double-payment on retry

Timeout Configuration

Timeout Type Value Notes
Connection timeout 5000ms Fail fast if Neonomics unreachable
Read timeout 30000ms ASPSP processing can take up to 30s
SCA callback timeout 300s (5 min) Mark transaction failed if no callback received

3.4 Integration: Sumsub KYC/AML

Protocol: REST HTTPS (outbound) + Webhooks HTTPS (inbound) Direction: Drop → Sumsub (applicant creation), Sumsub → Drop (webhook status updates) Idempotency: YES — webhook idempotency via screening_results table check

Authentication

Method Details
Outbound (Drop → Sumsub) Authorization: Bearer {SUMSUB_APP_TOKEN}
Inbound Webhook Verification HMAC-SHA256 of request body using SUMSUB_SECRET_KEY
Webhook Header X-Payload-Digest: HMAC-SHA256(body, SUMSUB_SECRET_KEY)
Key rotation Manual rotation in Sumsub dashboard; update SUMSUB_SECRET_KEY in Secrets Manager

Request Contract — KYC Initiation

Endpoint: POST https://api.sumsub.com/resources/applicants

Headers:

Authorization: Bearer {SUMSUB_APP_TOKEN}
Content-Type: application/json
X-Request-ID: {UUID}

Request Body:

{
  "externalUserId": "usr_abc123def456gh78",
  "email": "usr_abc123@bankid.drop.local",
  "levelName": "basic-kyc-level"
}

Successful Response 201:

{
  "id": "5f9e1b2c3d4e5f6g7h8i9j0k",
  "externalUserId": "usr_abc123def456gh78",
  "review": {
    "reviewStatus": "init"
  }
}

Webhook Contract (Sumsub → Drop)

Endpoint: POST /v1/webhooks/sumsub Verification: HMAC-SHA256(rawBody, SUMSUB_SECRET_KEY) must match X-Payload-Digest header

Webhook Payload:

{
  "type": "applicantReviewed",
  "applicantId": "5f9e1b2c3d4e5f6g7h8i9j0k",
  "externalUserId": "usr_abc123def456gh78",
  "reviewResult": {
    "reviewAnswer": "GREEN",
    "rejectLabels": []
  },
  "levelName": "basic-kyc-level",
  "createdAt": "2026-02-23T10:00:00.000Z"
}

Drop Action per Event:

Event Type Drop kyc_status Change Side Effects
applicantReviewed + GREEN pendingapproved notification: "Du er nå verifisert", audit_log
applicantReviewed + RED pendingrejected 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):

Rate Limiting

Limit Value Notes
Applicant creation 100 req/min Sumsub API limit
Webhook endpoint No rate limit Must always return 200 quickly

4. Event-Driven Integrations

4.1 Sumsub Webhook Events

Published by: Sumsub Consumed by: Drop POST /v1/webhooks/sumsub

Event: applicantReviewed

{
  "type": "applicantReviewed",
  "applicantId": "sumsub_internal_id",
  "externalUserId": "usr_abc123def456gh78",
  "inspectionId": "inspection_id",
  "correlationId": "correlation_id",
  "levelName": "basic-kyc-level",
  "sandboxMode": false,
  "reviewStatus": "completed",
  "createdAt": "2026-02-23T10:00:00.000Z",
  "reviewResult": {
    "reviewAnswer": "GREEN",
    "rejectLabels": [],
    "reviewRejectType": null,
    "moderationComment": null
  }
}

4.2 Topics / Queues

Drop does not use a message broker at current scale. Webhook delivery is direct HTTP. Internal side effects use synchronous DB writes.

4.3 Ordering Guarantees

Integration Ordering Notes
BankID OIDC callback Per-session (stateful via state cookie) State cookie ensures correct session
Sumsub webhooks Best-effort Idempotency key prevents duplicate processing
Open Banking payment callbacks Per-payment (paymentId) Poll status if callback missing

4.4 Idempotency Strategy

BankID callback:
  State cookie + nonce = per-session idempotency; JWT issuance is idempotent (same pid = same user)

Open Banking PISP:
  X-Request-ID = transactions.idempotency_key
  Unique DB index ensures duplicate INSERT is rejected → return existing transaction

Sumsub webhooks:
  Check screening_results for existing result before processing
  Duplicate: acknowledge (200 OK), skip processing

5. Data Consistency Patterns

5.1 Consistency Model

Model: Strong (within Drop DB) + Eventual (between Drop and external systems) Acceptable lag: PISP status lag: 5 minutes max; AISP balance lag: 6 hours (PSD2 constraint)

5.2 PISP Payment Saga

sequenceDiagram
    autonumber
    participant Drop as Drop API
    participant DB as PostgreSQL
    participant PISP as Neonomics PISP
    participant ASPSP as User's Bank
    participant User as User

    Drop->>DB: INSERT transactions (status='processing', idempotency_key)
    Drop->>DB: COMMIT
    Drop->>PISP: POST /v1/payments/sepa-credit-transfers (X-Request-ID=idempotency_key)
    PISP-->>Drop: {paymentId, scaRedirect}
    Drop-->>User: 201 + scaRedirect URL
    User->>ASPSP: Complete BankID SCA at bank
    ASPSP-->>User: Redirect to Drop callback
    User->>Drop: GET /api/payments/callback?paymentId=xxx
    Drop->>PISP: GET /v1/payments/{paymentId}/status
    PISP-->>Drop: {transactionStatus: "ACCP"}
    Drop->>DB: UPDATE transactions SET status='completed'

Compensation strategies:

Step Compensation Notes
DB INSERT failed Rollback automatically — no PISP call made Clean state
PISP initiation failed Transaction stays processing; PISP idempotency key prevents double charge on retry Alert ops if persistent
SCA timeout (no callback in 5min) UPDATE transactions SET status='failed'; restore cached balance User notified to retry

6. Integration Testing Strategy

6.1 Contract Testing

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:

Error scenarios:


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

7.3 Alert Routing

Condition Alert Channel Escalation
BankID OIDC unreachable Sentry HIGH alert On-call engineer via Sentry
PISP circuit breaker open Sentry CRITICAL alert On-call + Alem notification
Sumsub webhook HMAC failure Sentry HIGH alert Check SUMSUB_SECRET_KEY rotation
AISP balance > 12h stale Sentry MEDIUM alert Investigate Neonomics API

Approval

Role Name Date Signature
Author Petter Graff 2026-02-23
Consumer Team Lead John (AI Director)
Provider Team Lead External (BankID/Neonomics/Sumsub)
Platform/Infra
Approver (CEO) Alem Bašić

Data Flow Document

Data Flow Document

Project: {{PROJECT_NAME}} Version: {{VERSION}} Date: {{DATE}} Author: {{AUTHOR}} Status: Draft | In Review | Approved Reviewers: {{REVIEWERS}} Classification: Public | Internal | Confidential | Restricted

Document History

Version Date Author Changes
0.1 {{DATE}} {{AUTHOR}} Initial draft

1. Data Flow Overview

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

Overview: {{DESCRIBE_WHAT_DATA_FLOWS_THROUGH_SYSTEM}}

High-Level Data Flow

flowchart LR
    subgraph Inputs["Data Sources / Ingestion"]
        U[Users — Web/App]
        API_IN[External API]
        IMPORT[Bulk Import]
        WEBHOOK[Webhooks]
    end

    subgraph Processing["Processing Layer"]
        VAL[Validation & Sanitization]
        TRANS[Business Logic / Transformation]
        ENRICH[Data Enrichment]
    end

    subgraph Storage["Storage Layer"]
        DB[(Primary DB\nPostgreSQL)]
        CACHE[(Cache\nRedis)]
        BLOB[Object Storage\nS3/Blob]
        SEARCH[Search Index\nElasticsearch]
        DW[Data Warehouse\n{{DW_TECH}}]
    end

    subgraph Outputs["Data Consumers / Egress"]
        API_OUT[REST API]
        REPORTS[Reports / Analytics]
        EXPORT[Data Export]
        THIRD[Third-party Integrations]
        EMAIL[Email / Notifications]
    end

    U & API_IN & IMPORT & WEBHOOK --> VAL
    VAL --> TRANS
    TRANS --> ENRICH
    ENRICH --> DB & BLOB
    DB --> CACHE & SEARCH
    DB --> DW
    DB --> API_OUT & REPORTS & EXPORT
    DW --> REPORTS
    ENRICH --> THIRD
    DB --> EMAIL

2. Data Sources & Ingestion

Source Type Protocol Volume (est.) Format PII? Validation
Web application users Real-time HTTPS POST {{REQ_PER_DAY}} req/day JSON YES Schema + business rules
Mobile app users Real-time HTTPS POST {{REQ_PER_DAY}} req/day JSON YES Schema + business rules
{{EXTERNAL_SYSTEM}} API Real-time Webhooks {{EVENTS_PER_DAY}} events/day JSON {{YES/NO}} HMAC signature + schema
CSV bulk import Batch File upload {{IMPORTS_PER_DAY}} files/day CSV {{YES/NO}} Column mapping + row validation
{{THIRD_PARTY_API}} Polling REST/HTTPS {{CALLS_PER_HOUR}}/hour JSON {{YES/NO}} Response schema validation

Ingestion Error Handling

Error Type Action Notification
Schema validation failure Reject with error details Return 400 to caller
Duplicate record Upsert (prefer existing) or reject Log, return 409
PII fields contain unexpected data Quarantine + alert Slack #{{CHANNEL}}
Import file corrupted Reject entire file Email uploader + error report

3. Data Transformations

3.1 Ingestion Transformations (before storage)

Step Input Transformation Output Notes
1. Sanitization Raw user input Strip HTML, trim whitespace Clean strings Prevents XSS
2. Normalization {{FIELD}} Lowercase + trim Normalized {{FIELD}} e.g., email normalization
3. Enrichment User IP GeoIP lookup {country, region, city} Third-party API call
4. PII masking {{PII_FIELD}} Hash / mask for logs Masked value Never log raw PII
5. Encryption Sensitive fields AES-256-GCM Encrypted blob At application layer

3.2 ETL Pipeline (to Data Warehouse)

flowchart LR
    subgraph Extract["Extract"]
        PGLOG[PostgreSQL WAL / CDC]
        SCHED[Scheduled SQL Export]
    end

    subgraph Transform["Transform ({{TRANSFORM_TOOL}})"]
        CLEAN[Data Cleaning]
        JOIN[Joins & Aggregations]
        DEDUP[Deduplication]
        ANON[PII Anonymization]
    end

    subgraph Load["Load"]
        DW[({{DATA_WAREHOUSE}})]
    end

    PGLOG --> CLEAN
    SCHED --> CLEAN
    CLEAN --> JOIN
    JOIN --> DEDUP
    DEDUP --> ANON
    ANON --> DW

Pipeline schedule: {{PIPELINE_SCHEDULE}} (e.g., hourly incremental, daily full) Latency: Source to DW within {{MAX_LATENCY}} Tool: {{ETL_TOOL}} (e.g., dbt, Airbyte, custom)


4. Data Storage

Storage System Technology Purpose Data Classification Encryption at Rest
Primary Database {{DB_TECH}} {{VERSION}} Transactional data Confidential AES-256 ({{KEY_MGMT}})
Cache Redis {{VERSION}} Hot data, sessions Internal AES-256
Object Storage {{S3_COMPATIBLE}} Files, documents, media {{CLASSIFICATION}} SSE-S3 / SSE-KMS
Search Index Elasticsearch Full-text search Internal TLS + at-rest encryption
Data Warehouse {{DW}} Analytics, reporting Anonymized {{DW_ENCRYPTION}}
Backup Storage {{BACKUP_TECH}} Disaster recovery Restricted AES-256
Audit Logs {{LOG_STORAGE}} Compliance / audit trail Restricted Immutable, encrypted

5. Data Access Patterns

5.1 Read Patterns

Consumer Data Accessed Frequency Access Method Caching
Web application User profile, settings Per request REST API Redis 5min TTL
Web application {{ENTITY}} list Per page load REST API (paginated) CDN + Redis
Reporting service Aggregated metrics Every 1h DW query Materialized views
Admin dashboard Raw records On demand REST API (admin) No cache
External partner {{SUBSET_OF_DATA}} {{FREQUENCY}} REST API (scoped JWT) {{CACHING}}

5.2 Write Patterns

Writer Data Written Frequency Write Method Consistency
User actions (web) CRUD operations Per user action REST API Strong (synchronous)
Background worker Aggregates, computed fields Every {{INTERVAL}} Direct DB write Eventual
Import process Bulk records {{FREQUENCY}} Batch insert Strong (per batch)
Event consumer Denormalized cache On event Direct DB write Eventual

6. Data Retention & Archival

Data Category Retention Period Legal Basis Action at Expiry Automated?
User account data Duration of relationship + {{N}} years Contract Soft delete → anonymize Automated (nightly job)
Transaction records {{N}} years Legal obligation ({{REGULATION}}) Archive to cold storage Automated
Audit logs {{N}} years Legitimate interest (security) Delete Automated
Session tokens {{N}} hours/days Technical necessity Auto-expire via TTL Yes (Redis TTL)
Marketing consent Until withdrawn Consent Delete within {{N}} days of withdrawal Manual + automated
Analytics data {{N}} years (anonymized) Legitimate interest Delete Automated
Backup files {{N}} days Business continuity Overwrite (rolling) Automated
Error logs {{N}} days Legitimate interest Delete Automated

Retention schedule job: retention-policy.job.ts — runs daily at {{TIME}} UTC Archival target: {{COLD_STORAGE_LOCATION}}


7. Data Quality Rules

7.1 Validation Rules

Field Rule Error Action Severity
email Valid RFC 5322 format Reject CRITICAL
phone E.164 format Reject HIGH
{{DATE_FIELD}} Not in future Reject HIGH
{{AMOUNT_FIELD}} >= 0 Reject CRITICAL
{{FK_FIELD}} References existing record Reject CRITICAL
{{TEXT_FIELD}} Max {{N}} characters Reject MEDIUM

7.2 Data Quality Metrics

Metric Target Current Alert Threshold
Null rate on required fields 0% {{CURRENT}} > 0.1%
Duplicate rate < 0.01% {{CURRENT}} > 0.1%
Schema validation pass rate > 99.9% {{CURRENT}} < 99%
ETL pipeline success rate > 99.5% {{CURRENT}} < 98%

8. PII Data Flow Mapping

8.1 PII Inventory

PII Category Fields Storage Location Encrypted? Access Controls Lawful Basis
Contact info email, phone, address Primary DB, Email system Yes Role-based (user self + admin) Contract
Identity full_name, date_of_birth Primary DB Yes (field-level) Role-based Contract
Financial {{PAYMENT_FIELD}} {{PAYMENT_PROVIDER}} (tokenized) Tokenized PCI scope only Contract
Behavioral login_history, click_events Analytics DB No (anonymized) Admin only Legitimate interest
Location ip_address (→ geo) Logs (masked) N/A Admin only Legitimate interest
Device user_agent, device_id Analytics DB No Admin only Legitimate interest

8.2 PII Flow Diagram

flowchart TD
    USER([Data Subject]) -->|Provides| INGESTION[Ingestion Layer]
    INGESTION -->|Validates & encrypts| DB[(Primary DB\nPII encrypted at rest)]
    DB -->|Pseudonymized| DW[(Data Warehouse\nNo direct PII)]
    DB -->|Masked in logs| LOGS[Log Aggregator]
    DB -->|Tokenized| PAYMENT[Payment Provider\nPCI scope]
    DB -->|Explicit consent| EMAIL[Email Provider\nEmail + name only]
    DB -->|Right to erasure| DELETION[Anonymization Service]
    DELETION -->|Anonymized| DB
    DB -->|Audit trail| AUDIT[Audit Log\nRestricted access]

    style DB fill:#ffcccc
    style DW fill:#ccffcc
    style LOGS fill:#ffffcc
    style AUDIT fill:#ffcccc

9. Cross-Border Data Transfer

Transfer From To Data Category Mechanism DPA/SCCs?
{{TRANSFER_1}} EU ({{COUNTRY}}) US ({{PROVIDER}}) {{DATA_CATEGORY}} Standard Contractual Clauses (SCCs) Yes — signed {{DATE}}
{{TRANSFER_2}} EU {{COUNTRY}} {{DATA_CATEGORY}} Adequacy decision N/A
{{TRANSFER_3}} EU {{COUNTRY}} {{DATA_CATEGORY}} Binding Corporate Rules {{YES/NO}}

Third-party processors with data access:

Processor Service Data Accessed DPA Signed Location
{{PROCESSOR_1}} {{SERVICE}} {{DATA}} Yes {{LOCATION}}
{{PROCESSOR_2}} {{SERVICE}} {{DATA}} Yes {{LOCATION}}

10. Data Lineage Tracking

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

Lineage Events Captured

{
  "eventType": "DATA_WRITE",
  "timestamp": "ISO8601",
  "actor": "system/user-id",
  "action": "CREATE | UPDATE | DELETE | EXPORT | IMPORT",
  "resource": {
    "type": "{{ENTITY}}",
    "id": "UUID"
  },
  "fields_modified": ["{{field1}}", "{{field2}}"],
  "sourceSystem": "{{SOURCE}}",
  "traceId": "UUID"
}

11. Backup & Recovery for Data

Storage Backup Method Frequency Retention RTO RPO Test Frequency
Primary DB Continuous WAL archiving + snapshots Continuous / Daily 30 days 1h 5min Monthly
Object Storage Cross-region replication Continuous 30 days 4h 1h Quarterly
Data Warehouse Snapshot Daily 14 days 8h 24h Quarterly
Redis Cache RDB snapshots Every 15min 24h 15min 15min Monthly

Last backup test: {{DATE}} — Result: {{PASS/FAIL}} Recovery runbook: {{LINK_TO_RUNBOOK}}


Approval

Role Name Date Signature
Author
Data Owner
DPO / Privacy
Security
Tech Lead

API Specification

API Specification

Project: {{PROJECT_NAME}} API Name: {{API_NAME}} Version: {{API_VERSION}} Date: {{DATE}} Author: {{AUTHOR}} Status: Draft | In Review | Approved Reviewers: {{REVIEWERS}} Spec Format: OpenAPI 3.1

Document History

Version Date Author Changes
0.1 {{DATE}} {{AUTHOR}} Initial draft
{{VERSION}} {{DATE}} {{AUTHOR}} {{CHANGE_SUMMARY}}

1. API Overview

Purpose: {{API_PURPOSE}} Primary Consumers: {{CONSUMER_DESCRIPTION}} (e.g., internal frontend, partner systems, public developers) Design Philosophy: REST + JSON | Resource-oriented | Stateless | Idempotent where possible

Base URLs:

Environment Base URL
Production https://api.{{DOMAIN}}/v{{MAJOR_VERSION}}
Staging https://api.staging.{{DOMAIN}}/v{{MAJOR_VERSION}}
Development http://localhost:{{PORT}}/api/v{{MAJOR_VERSION}}

2. API Versioning Strategy

Strategy: URL path versioning — /api/v{MAJOR}

Versioning rules:

Deprecation Policy:

Sunset Header Example:

Deprecation: Sat, 01 Jan 2025 00:00:00 GMT
Sunset: Sat, 01 Jul 2025 00:00:00 GMT
Link: <https://api.{{DOMAIN}}/v2/{{resource}}>; rel="successor-version"

3. Authentication & Authorization

3.1 Authentication Methods

Primary: Bearer JWT (OAuth2 / OIDC)

Authorization: Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...

JWT Claims:

{
  "sub": "user-uuid",
  "email": "user@example.com",
  "tenant_id": "tenant-uuid",
  "roles": ["{{ROLE_1}}", "{{ROLE_2}}"],
  "scopes": ["{{SCOPE_1}}:read", "{{SCOPE_1}}:write"],
  "iat": 1700000000,
  "exp": 1700003600
}

Token Lifetimes:

Token Type Lifetime Storage
Access Token {{ACCESS_TTL}} (e.g., 1h) Memory only (not localStorage)
Refresh Token {{REFRESH_TTL}} (e.g., 30d) HttpOnly cookie
API Key Non-expiring (rotatable) Secure vault

Refresh Flow:

POST /auth/refresh
Cookie: refresh_token={{REFRESH_TOKEN}}
→ 200: { "access_token": "...", "expires_in": 3600 }
→ 401: Refresh token expired — re-authenticate

3.2 API Keys (for server-to-server)

X-API-Key: ak_live_{{KEY_PREFIX_SHOWN_TO_USER}}

3.3 OAuth2 Scopes

Scope Description Grantable to
{{resource}}:read Read {{resource}} data All auth users
{{resource}}:write Create/update {{resource}} {{ROLE_REQUIRED}}
{{resource}}:delete Delete {{resource}} {{ROLE_REQUIRED}}
admin:* Full admin access Admin users only

4. Common Headers

Request Headers

Header Required Description
Authorization Yes (except public endpoints) Bearer {JWT} or N/A
Content-Type Yes (POST/PUT/PATCH) application/json
Accept No application/json (default)
X-Request-ID Recommended UUID v4 — echoed in response for tracing
X-Idempotency-Key Yes (POST mutations) UUID v4 — prevents duplicate operations
Accept-Language No en, no, de — for localized error messages

Response Headers

Header Description
X-Request-ID Echo of request ID (or generated if not provided)
X-RateLimit-Limit Rate limit ceiling
X-RateLimit-Remaining Remaining requests in current window
X-RateLimit-Reset Unix timestamp when rate limit resets
X-Response-Time Server processing time in ms
Cache-Control Caching directives
ETag Entity tag for conditional requests

5. Error Response Format (RFC 7807)

{
  "type": "https://api.{{DOMAIN}}/errors/{{ERROR_CODE}}",
  "title": "Human-readable error title",
  "status": 400,
  "detail": "Specific, actionable error description",
  "instance": "/api/v1/{{resource}}/{{id}}",
  "traceId": "{{TRACE_ID}}",
  "errors": [
    {
      "field": "{{FIELD_NAME}}",
      "code": "{{VALIDATION_CODE}}",
      "message": "{{FIELD_SPECIFIC_MESSAGE}}"
    }
  ]
}

Standard Error Codes

HTTP Status Error Type When to Use
400 validation-error Request body/params fail validation
401 unauthorized Missing or invalid authentication
403 forbidden Authenticated but lacks permission
404 not-found Resource does not exist
405 method-not-allowed HTTP method not supported
409 conflict Duplicate or state conflict
410 gone Resource permanently deleted
422 business-rule-violation Business logic rejection
429 rate-limit-exceeded Too many requests
500 internal-error Unexpected server error
502 bad-gateway Upstream service failure
503 service-unavailable Planned downtime or overload

6. Pagination Strategy

Strategy: Cursor-based (preferred) | Offset-based (legacy support)

Cursor-Based (default for all new endpoints)

Request:

GET /api/v1/{{resource}}?limit=20&after={{CURSOR}}

Response:

{
  "data": [],
  "pagination": {
    "hasNextPage": true,
    "hasPreviousPage": false,
    "startCursor": "{{BASE64_CURSOR}}",
    "endCursor": "{{BASE64_CURSOR}}",
    "limit": 20
  }
}

Offset-Based (legacy)

Request:

GET /api/v1/{{resource}}?page=1&limit=20

Response:

{
  "data": [],
  "pagination": {
    "page": 1,
    "limit": 20,
    "total": 500,
    "totalPages": 25
  }
}

Limits: Minimum 1, Maximum 100 items per request.


7. Rate Limiting

Tier Limit Window Scope
Anonymous {{N}} req per minute Per IP
Authenticated (free) {{N}} req per minute Per API key
Authenticated (paid) {{N}} req per minute Per API key
Admin {{N}} req per minute Per user

Rate limit exceeded response:

HTTP/1.1 429 Too Many Requests
X-RateLimit-Limit: {{LIMIT}}
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1700003600
Retry-After: 37

{
  "type": "https://api.{{DOMAIN}}/errors/rate-limit-exceeded",
  "title": "Rate Limit Exceeded",
  "status": 429,
  "detail": "You have exceeded {{N}} requests per minute. Retry after 37 seconds."
}

8. Endpoint Documentation

Resource: {{Resource Name}}

POST /{{resource}}

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

Request:

POST /api/v1/{{resource}} HTTP/1.1
Authorization: Bearer {{TOKEN}}
Content-Type: application/json
X-Idempotency-Key: 550e8400-e29b-41d4-a716-446655440000

{
  "{{field1}}": "string (required)",
  "{{field2}}": 123,
  "{{field3}}": "ENUM_VALUE_A | ENUM_VALUE_B"
}

Response 201 Created:

{
  "id": "550e8400-e29b-41d4-a716-446655440000",
  "{{field1}}": "value",
  "{{field2}}": 123,
  "{{field3}}": "ENUM_VALUE_A",
  "createdAt": "2024-01-01T00:00:00.000Z",
  "updatedAt": "2024-01-01T00:00:00.000Z"
}

Error scenarios:

Scenario Status Error Code
Missing required {{field1}} 400 validation-error
{{field1}} exceeds max length 400 validation-error
Duplicate {{unique_field}} 409 conflict
Invalid enum value 400 validation-error
Business rule: {{RULE_DESCRIPTION}} 422 business-rule-violation

GET /{{resource}}/:id

Summary: Retrieve a {{entity}} by ID Auth: Required | Scope: {{resource}}:read Cache: ETag + Last-Modified supported

Path Parameters:

Parameter Type Description
id UUID The {{entity}} unique identifier

Response 200 OK:

{
  "id": "550e8400-e29b-41d4-a716-446655440000",
  "{{field1}}": "value",
  "createdAt": "2024-01-01T00:00:00.000Z"
}

Error scenarios:

Scenario Status Error Code
Invalid UUID format 400 validation-error
{{entity}} not found 404 not-found
Access to other tenant's data 403 forbidden

GET /{{resource}}

Summary: List {{entities}} with filtering and pagination Auth: Required | Scope: {{resource}}:read

Query Parameters:

Parameter Type Default Description
limit integer [1-100] 20 Items per page
after string Cursor for next page
before string Cursor for previous page
sort string createdAt:desc Sort: {field}:{asc|desc}
{{FILTER_1}} string Filter by {{FILTER_1}}
{{FILTER_2}} string (ISO8601) Filter by date range: after:DATE
search string Full-text search

Response 200 OK:

{
  "data": [
    { "id": "...", "{{field1}}": "..." }
  ],
  "pagination": {
    "hasNextPage": true,
    "endCursor": "{{CURSOR}}"
  }
}

9. Webhook Documentation

9.1 Webhook Configuration

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

9.2 Signature Verification

// Verify webhook authenticity
const payload = request.rawBody;
const signature = request.headers['X-Webhook-Signature'];
const secret = process.env.WEBHOOK_SECRET;

const expected = 'sha256=' + crypto
  .createHmac('sha256', secret)
  .update(payload)
  .digest('hex');

const isValid = crypto.timingSafeEqual(
  Buffer.from(signature),
  Buffer.from(expected)
);

9.3 Webhook Events

Event Description Payload
{{entity}}.created {{entity}} created {id, ...}
{{entity}}.updated {{entity}} updated {id, changes: {...}}
{{entity}}.deleted {{entity}} deleted {id, deletedAt}

9.4 Webhook Delivery


10. OpenAPI 3.1 YAML Skeleton

openapi: '3.1.0'

info:
  title: '{{API_NAME}}'
  description: '{{API_DESCRIPTION}}'
  version: '{{API_VERSION}}'
  contact:
    name: '{{TEAM_NAME}}'
    email: '{{TEAM_EMAIL}}'
  license:
    name: 'Proprietary'

servers:
  - url: 'https://api.{{DOMAIN}}/v{{MAJOR_VERSION}}'
    description: 'Production'
  - url: 'https://api.staging.{{DOMAIN}}/v{{MAJOR_VERSION}}'
    description: 'Staging'

security:
  - bearerAuth: []

tags:
  - name: '{{Resource}}'
    description: 'Operations on {{resource}}'

paths:
  /{{resource}}:
    post:
      tags: ['{{Resource}}']
      summary: 'Create {{entity}}'
      operationId: 'create{{Entity}}'
      security:
        - bearerAuth: ['{{resource}}:write']
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/Create{{Entity}}Request'
      responses:
        '201':
          description: 'Created'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/{{Entity}}'
        '400':
          $ref: '#/components/responses/ValidationError'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '409':
          $ref: '#/components/responses/Conflict'

    get:
      tags: ['{{Resource}}']
      summary: 'List {{entities}}'
      operationId: 'list{{Entities}}'
      parameters:
        - $ref: '#/components/parameters/limit'
        - $ref: '#/components/parameters/after'
      responses:
        '200':
          description: 'OK'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Paginated{{Entity}}Response'

components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: JWT

  parameters:
    limit:
      name: limit
      in: query
      schema:
        type: integer
        minimum: 1
        maximum: 100
        default: 20

    after:
      name: after
      in: query
      schema:
        type: string

  schemas:
    {{Entity}}:
      type: object
      required: [id, {{field1}}, createdAt]
      properties:
        id:
          type: string
          format: uuid
        {{field1}}:
          type: string
        createdAt:
          type: string
          format: date-time

    Create{{Entity}}Request:
      type: object
      required: [{{field1}}]
      properties:
        {{field1}}:
          type: string
          minLength: 1
          maxLength: 255

    ProblemDetails:
      type: object
      properties:
        type:
          type: string
          format: uri
        title:
          type: string
        status:
          type: integer
        detail:
          type: string
        instance:
          type: string
        traceId:
          type: string

    Paginated{{Entity}}Response:
      type: object
      properties:
        data:
          type: array
          items:
            $ref: '#/components/schemas/{{Entity}}'
        pagination:
          type: object
          properties:
            hasNextPage:
              type: boolean
            endCursor:
              type: string

  responses:
    ValidationError:
      description: 'Validation Error'
      content:
        application/problem+json:
          schema:
            $ref: '#/components/schemas/ProblemDetails'
    Unauthorized:
      description: 'Unauthorized'
      content:
        application/problem+json:
          schema:
            $ref: '#/components/schemas/ProblemDetails'
    Conflict:
      description: 'Conflict'
      content:
        application/problem+json:
          schema:
            $ref: '#/components/schemas/ProblemDetails'

11. SDK Generation Notes

Generation tool: {{SDK_TOOL}} (e.g., openapi-generator, Speakeasy, Fern) Generated SDKs:

Language Package Registry
TypeScript/JS @{{ORG}}/{{sdk-name}} npm
Python {{org}}-{{sdk-name}} PyPI
Go github.com/{{org}}/{{sdk-name}} pkg.go.dev

Generation command:

openapi-generator-cli generate \
  -i ./api-specification.yaml \
  -g typescript-fetch \
  -o ./sdk/typescript \
  --additional-properties=npmName=@{{org}}/{{sdk-name}}

12. API Changelog

Version Date Change Type Description
{{API_VERSION}} {{DATE}} Added POST /{{resource}} endpoint
{{API_VERSION}} {{DATE}} Changed {{FIELD}} is now optional (was required)
{{API_VERSION}} {{DATE}} Deprecated GET /{{old-resource}} — use GET /{{new-resource}}
{{API_VERSION}} {{DATE}} Removed DELETE /{{old-resource}} — deprecated since {{DATE}}

Approval

Role Name Date Signature
Author
API Consumer Rep
Security Review
Tech Lead

Database Schema Document

Database Schema Document

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

Cross-references to ALAI DATABASE/ standards. Apply these alongside this schema document.

Document Path Purpose
alai-db-standards.md DATABASE/ Master naming conventions, type standards, column defaults
indexing-strategy.md DATABASE/ Index design guide — B-tree, GIN, partial, composite
migration-strategy.md DATABASE/ Migration tooling, zero-downtime patterns, rollback procedures
rls-policy-guide.md DATABASE/ Row Level Security for multi-tenant schemas
connection-pooling.md DATABASE/ PgBouncer and application pool configuration

Document History

Version Date Author Changes
0.1 {{DATE}} {{AUTHOR}} Initial draft

1. Database Technology & Version

Property Value
Technology {{DB_TECHNOLOGY}} (e.g., PostgreSQL, MySQL, MongoDB, DynamoDB)
Version {{DB_VERSION}}
Hosting {{HOSTING}} (e.g., AWS RDS, Cloud SQL, self-hosted)
Instance type {{INSTANCE_TYPE}}
Storage {{STORAGE_SIZE}} — auto-scaling: {{YES/NO}}
Read replicas {{N}} replicas in {{REGIONS}}
Connection pooling {{POOLER}} (e.g., PgBouncer, RDS Proxy) — pool size: {{POOL_SIZE}}
Encoding UTF-8
Timezone UTC (all timestamps in UTC)
Migration tool {{MIGRATION_TOOL}} (e.g., Flyway, Liquibase, Prisma Migrate, custom)

2. ER Diagram

erDiagram
    TENANT {
        uuid id PK
        string name
        string slug UK
        string plan
        timestamptz created_at
        timestamptz deleted_at
    }

    USER {
        uuid id PK
        uuid tenant_id FK
        string email UK
        string full_name
        string password_hash
        string role
        boolean is_verified
        timestamptz last_login_at
        timestamptz created_at
        timestamptz deleted_at
    }

    {{ENTITY_1}} {
        uuid id PK
        uuid tenant_id FK
        uuid created_by FK
        string {{field_1}}
        string {{field_2}}
        string status
        int version
        timestamptz created_at
        timestamptz updated_at
        timestamptz deleted_at
    }

    {{ENTITY_2}} {
        uuid id PK
        uuid {{entity_1_id}} FK
        string {{field_1}}
        decimal {{amount_field}}
        timestamptz created_at
    }

    AUDIT_LOG {
        uuid id PK
        uuid tenant_id FK
        uuid actor_id FK
        string entity_type
        uuid entity_id
        string action
        jsonb old_values
        jsonb new_values
        string ip_address
        timestamptz created_at
    }

    TENANT ||--o{ USER : "has"
    TENANT ||--o{ {{ENTITY_1}} : "owns"
    USER ||--o{ {{ENTITY_1}} : "creates"
    {{ENTITY_1}} ||--o{ {{ENTITY_2}} : "contains"
    USER ||--o{ AUDIT_LOG : "generates"

3. Schema Conventions

3.1 Naming Conventions

Element Convention Example
Tables snake_case, plural user_profiles, order_items
Columns snake_case created_at, tenant_id
Primary keys Always id (UUID) id UUID PRIMARY KEY
Foreign keys {referenced_table_singular}_id user_id, tenant_id
Indexes idx_{table}_{column(s)} idx_users_email
Unique indexes uq_{table}_{column(s)} uq_users_tenant_email
Enum types snake_case user_role, order_status
Junction tables {table1}_{table2} (alphabetical) role_permissions
Sequences Auto (via gen_random_uuid())

3.2 Standard Columns (all tables)

Column Type Nullable Default Description
id UUID NO gen_random_uuid() Surrogate primary key
created_at TIMESTAMPTZ NO NOW() Immutable — set on insert
updated_at TIMESTAMPTZ NO NOW() Auto-updated via trigger
deleted_at TIMESTAMPTZ YES NULL Soft delete (NULL = active)
version INTEGER NO 1 Optimistic lock counter

3.3 Data Type Standards

Data PostgreSQL Type Notes
Primary keys UUID gen_random_uuid() default
Short strings VARCHAR(N) Specify max length
Long text TEXT No length limit
Money / currency NUMERIC(19, 4) Never FLOAT for money
Booleans BOOLEAN NOT NULL with DEFAULT
Enums custom ENUM type Define in migrations
JSON data JSONB Prefer JSONB over JSON
IP addresses INET Native IP type
URLs TEXT Validated at app layer
Timestamps TIMESTAMPTZ Always with timezone
Dates (no time) DATE
Durations INTERVAL

4. Tables by Domain

4.1 Identity & Access Domain

Table: tenants

Purpose: Top-level multi-tenancy isolation unit. Every resource belongs to a tenant.

Column Type Nullable Default Constraints Description
id UUID NO gen_random_uuid() PK Tenant identifier
name VARCHAR(255) NO NOT NULL Display name
slug VARCHAR(100) NO UNIQUE, NOT NULL URL-safe identifier
plan tenant_plan NO 'free' NOT NULL Subscription plan
settings JSONB NO '{}'::jsonb Tenant configuration
created_at TIMESTAMPTZ NO NOW()
deleted_at TIMESTAMPTZ YES NULL
-- Enum
CREATE TYPE tenant_plan AS ENUM ('free', 'starter', 'pro', 'enterprise');

-- Table
CREATE TABLE tenants (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    name VARCHAR(255) NOT NULL,
    slug VARCHAR(100) NOT NULL,
    plan tenant_plan NOT NULL DEFAULT 'free',
    settings JSONB NOT NULL DEFAULT '{}',
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    deleted_at TIMESTAMPTZ
);

-- Indexes
CREATE UNIQUE INDEX uq_tenants_slug ON tenants(slug) WHERE deleted_at IS NULL;

Table: users

Purpose: System users. Authentication identity.

Column Type Nullable Default Constraints Description
id UUID NO gen_random_uuid() PK
tenant_id UUID NO FK → tenants(id) Tenant membership
email VARCHAR(320) NO NOT NULL Normalized lowercase
password_hash VARCHAR(255) YES NULL bcrypt/Argon2 hash. NULL for SSO users
full_name VARCHAR(255) NO NOT NULL
role user_role NO 'member' NOT NULL RBAC role
is_verified BOOLEAN NO FALSE NOT NULL Email verified
last_login_at TIMESTAMPTZ YES NULL
mfa_enabled BOOLEAN NO FALSE NOT NULL
mfa_secret TEXT YES NULL Encrypted TOTP secret
created_at TIMESTAMPTZ NO NOW()
updated_at TIMESTAMPTZ NO NOW()
deleted_at TIMESTAMPTZ YES NULL
version INTEGER NO 1
CREATE TYPE user_role AS ENUM ('owner', 'admin', 'member', 'viewer', 'api');

CREATE TABLE users (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE RESTRICT,
    email VARCHAR(320) NOT NULL,
    password_hash VARCHAR(255),
    full_name VARCHAR(255) NOT NULL,
    role user_role NOT NULL DEFAULT 'member',
    is_verified BOOLEAN NOT NULL DEFAULT FALSE,
    last_login_at TIMESTAMPTZ,
    mfa_enabled BOOLEAN NOT NULL DEFAULT FALSE,
    mfa_secret TEXT,
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    deleted_at TIMESTAMPTZ,
    version INTEGER NOT NULL DEFAULT 1
);

CREATE UNIQUE INDEX uq_users_tenant_email ON users(tenant_id, lower(email))
    WHERE deleted_at IS NULL;
CREATE INDEX idx_users_tenant_id ON users(tenant_id)
    WHERE deleted_at IS NULL;

4.2 {{DOMAIN_NAME}} Domain

Table: {{table_name}}

Purpose: {{TABLE_PURPOSE}}

Column Type Nullable Default Constraints Description
id UUID NO gen_random_uuid() PK
tenant_id UUID NO FK → tenants(id)
created_by UUID NO FK → users(id)
{{field_1}} {{TYPE}} {{YES/NO}} {{DEFAULT}} {{CONSTRAINTS}} {{DESCRIPTION}}
{{field_2}} {{TYPE}} {{YES/NO}} {{DEFAULT}} {{CONSTRAINTS}} {{DESCRIPTION}}
status {{status_enum}} NO '{{DEFAULT_STATUS}}' NOT NULL
created_at TIMESTAMPTZ NO NOW()
updated_at TIMESTAMPTZ NO NOW()
deleted_at TIMESTAMPTZ YES NULL
version INTEGER NO 1

5. Enums & Lookup Tables

5.1 Enum Types

CREATE TYPE user_role AS ENUM ('owner', 'admin', 'member', 'viewer', 'api');
CREATE TYPE tenant_plan AS ENUM ('free', 'starter', 'pro', 'enterprise');
CREATE TYPE {{entity_1}}_status AS ENUM ('draft', 'active', 'suspended', 'archived');
CREATE TYPE {{entity_2}}_type AS ENUM ('{{VALUE_1}}', '{{VALUE_2}}', '{{VALUE_3}}');

5.2 Lookup Tables

Table: {{lookup_table}}

Column Type Description
code VARCHAR(50) PK Machine-readable identifier
label VARCHAR(255) Human-readable label
description TEXT Detailed description
sort_order INTEGER Display ordering
is_active BOOLEAN Whether selectable

6. Views & Materialized Views

6.1 Views

View: active_{{entities}}

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

CREATE VIEW active_{{entities}} AS
    SELECT * FROM {{table_name}}
    WHERE deleted_at IS NULL;

6.2 Materialized Views

Materialized View: {{entity}}_summary

Purpose: Pre-aggregated summary for dashboard queries Refreshed: Every {{INTERVAL}} via scheduled job Staleness acceptable: Up to {{MAX_STALENESS}}

CREATE MATERIALIZED VIEW {{entity}}_summary AS
    SELECT
        tenant_id,
        DATE_TRUNC('day', created_at) AS date,
        COUNT(*) AS total,
        COUNT(*) FILTER (WHERE status = 'active') AS active_count
    FROM {{table_name}}
    WHERE deleted_at IS NULL
    GROUP BY tenant_id, DATE_TRUNC('day', created_at);

CREATE UNIQUE INDEX ON {{entity}}_summary(tenant_id, date);

-- Refresh command (run by scheduler):
-- REFRESH MATERIALIZED VIEW CONCURRENTLY {{entity}}_summary;

7. Stored Procedures & Functions

updated_at_trigger()

Purpose: Auto-update updated_at column on any row update

CREATE OR REPLACE FUNCTION updated_at_trigger()
RETURNS TRIGGER AS $$
BEGIN
    NEW.updated_at = NOW();
    RETURN NEW;
END;
$$ LANGUAGE plpgsql;

-- Apply to every table with updated_at:
CREATE TRIGGER set_updated_at
    BEFORE UPDATE ON {{table_name}}
    FOR EACH ROW EXECUTE FUNCTION updated_at_trigger();

8. Migration Strategy & Tooling

Tool: {{MIGRATION_TOOL}} (e.g., Flyway, Liquibase, Prisma Migrate) Convention: V{timestamp}__{description}.sql or {NNN}_{description}.{up|down}.sql Location: db/migrations/ Executed by: CI/CD pipeline before application deployment

Zero-Downtime Migration Checklist

Before every DDL migration:

Expansion-Contraction Pattern

Step 1 (Expand): Add new_column alongside old_column
Step 2 (App deploy): Write to both, read from old
Step 3 (Backfill): Copy data from old to new
Step 4 (App deploy): Read from new, write to both
Step 5 (App deploy): Write to new only
Step 6 (Contract): Drop old_column

9. Seed Data Requirements

9.1 Required Seed Data (production)

-- System tenant (for internal operations)
INSERT INTO tenants (id, name, slug, plan)
VALUES ('00000000-0000-0000-0000-000000000001', 'System', 'system', 'enterprise')
ON CONFLICT DO NOTHING;

-- Default lookup values
INSERT INTO {{lookup_table}} (code, label, sort_order) VALUES
    ('{{VALUE_1}}', '{{LABEL_1}}', 1),
    ('{{VALUE_2}}', '{{LABEL_2}}', 2)
ON CONFLICT (code) DO UPDATE SET label = EXCLUDED.label;

9.2 Development Seed Data

Script: db/seeds/development.sql Volume: {{N}} tenants, {{N}} users per tenant, {{N}} sample records Command: npm run db:seed or make seed-dev


10. Performance Considerations

10.1 Partitioning

Table Partition Strategy Partition Key Partition Size
audit_logs Range (time) created_at Monthly
{{events_table}} Range (time) created_at Weekly
{{large_table}} List (tenant) tenant_id Per tenant
CREATE TABLE audit_logs (
    id UUID NOT NULL,
    tenant_id UUID NOT NULL,
    created_at TIMESTAMPTZ NOT NULL
) PARTITION BY RANGE (created_at);

CREATE TABLE audit_logs_2024_01
    PARTITION OF audit_logs
    FOR VALUES FROM ('2024-01-01') TO ('2024-02-01');

10.2 Query Performance Standards

Query Pattern Target (p99) Optimization
PK lookup < 5ms B-tree index on id
Tenant-scoped list < 50ms Composite index (tenant_id, created_at)
Full-text search < 200ms GIN index on search_vector
Aggregation (dashboard) < 500ms Materialized view
Cross-tenant report < 30s Data warehouse

10.3 Connection Pooling

Application connections → PgBouncer (transaction mode) → PostgreSQL
Pool size: min={{MIN_POOL}}, max={{MAX_POOL}} per application instance
Max DB connections: {{MAX_DB_CONNECTIONS}} (= pool_size × instances + 10 reserve)

11. Backup & Recovery Procedures

Backup Type Method Frequency Retention Location
Continuous WAL pg_wal_archive Continuous {{N}} days {{BACKUP_LOCATION}}
Base snapshot pg_basebackup / cloud snapshot Daily {{N}} days {{BACKUP_LOCATION}}
Logical dump pg_dump (select tables) Weekly {{N}} weeks {{COLD_STORAGE}}
Schema-only pg_dump --schema-only On every migration Indefinite Git repository

RTO target: {{RTO}} | RPO target: {{RPO}}

Recovery test schedule: Monthly ({{DAY_OF_MONTH}}) Recovery runbook: {{LINK_TO_RUNBOOK}}

# Point-in-time recovery command
pg_restore \
  --host={{HOST}} \
  --port=5432 \
  --username={{USER}} \
  --dbname={{DB}} \
  --target-time="{{TIMESTAMP}}" \
  {{BACKUP_FILE}}

Approval

Role Name Date Signature
Author
DBA / Platform
Security Review
Tech Lead

High-Level Design (HLD)

High-Level Design Document

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

Document History

Version Date Author Changes
0.1 {{DATE}} {{AUTHOR}} Initial draft

1. Executive Summary

Purpose: {{ONE_LINE_DESCRIPTION_OF_SYSTEM}}

Business Context: {{WHY_THIS_SYSTEM_EXISTS}}

Key Outcomes:

Scope: This document covers {{IN_SCOPE}} and excludes {{OUT_OF_SCOPE}}.


2. System Context (C4 Level 1)

C4Context
    title System Context — {{PROJECT_NAME}}

    Person(user, "{{PRIMARY_USER_TYPE}}", "{{USER_DESCRIPTION}}")
    Person(admin, "System Administrator", "Manages and configures the system")

    System(system, "{{SYSTEM_NAME}}", "{{SYSTEM_SHORT_DESCRIPTION}}")

    System_Ext(extSystem1, "{{EXTERNAL_SYSTEM_1}}", "{{EXT_SYSTEM_1_DESCRIPTION}}")
    System_Ext(extSystem2, "{{EXTERNAL_SYSTEM_2}}", "{{EXT_SYSTEM_2_DESCRIPTION}}")
    System_Ext(emailSystem, "Email Provider", "Sends transactional emails")

    Rel(user, system, "Uses", "HTTPS")
    Rel(admin, system, "Manages", "HTTPS")
    Rel(system, extSystem1, "Calls", "REST/HTTPS")
    Rel(system, extSystem2, "Publishes events to", "AMQP")
    Rel(system, emailSystem, "Sends emails via", "SMTP/API")

3. Container Diagram (C4 Level 2)

C4Container
    title Container Diagram — {{PROJECT_NAME}}

    Person(user, "{{PRIMARY_USER_TYPE}}")

    Container_Boundary(system, "{{SYSTEM_NAME}}") {
        Container(webApp, "Web Application", "{{FRONTEND_TECH}}", "Single-page application served to users")
        Container(api, "API Gateway / Backend", "{{BACKEND_TECH}}", "Handles business logic and orchestration")
        Container(workerService, "Background Worker", "{{WORKER_TECH}}", "Processes async jobs and scheduled tasks")
        ContainerDb(database, "Primary Database", "{{DB_TECH}}", "Stores persistent application data")
        ContainerDb(cache, "Cache Layer", "Redis", "Session storage and hot data caching")
        Container(messageQueue, "Message Queue", "{{QUEUE_TECH}}", "Async event bus between services")
    }

    System_Ext(extApi, "{{EXTERNAL_API}}", "Third-party integration")

    Rel(user, webApp, "Visits", "HTTPS")
    Rel(webApp, api, "Calls", "REST/HTTPS")
    Rel(api, database, "Reads/Writes", "TCP")
    Rel(api, cache, "Reads/Writes", "TCP")
    Rel(api, messageQueue, "Publishes events", "AMQP")
    Rel(workerService, messageQueue, "Consumes events", "AMQP")
    Rel(workerService, database, "Reads/Writes", "TCP")
    Rel(api, extApi, "Calls", "REST/HTTPS")

4. Component Overview

Component Responsibility Technology Owner Team
{{COMPONENT_1}} {{RESPONSIBILITY_1}} {{TECH_1}} {{TEAM_1}}
{{COMPONENT_2}} {{RESPONSIBILITY_2}} {{TECH_2}} {{TEAM_2}}
{{COMPONENT_3}} {{RESPONSIBILITY_3}} {{TECH_3}} {{TEAM_3}}

Component Descriptions

{{COMPONENT_1}}

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


5. Technology Stack

Layer Technology Version Rationale
Frontend Framework {{FE_FRAMEWORK}} {{VERSION}} {{RATIONALE}}
UI Component Library {{UI_LIB}} {{VERSION}} {{RATIONALE}}
Backend Language {{LANG}} {{VERSION}} {{RATIONALE}}
Backend Framework {{BE_FRAMEWORK}} {{VERSION}} {{RATIONALE}}
Primary Database {{DB}} {{VERSION}} {{RATIONALE}}
Cache {{CACHE}} {{VERSION}} {{RATIONALE}}
Message Queue {{QUEUE}} {{VERSION}} {{RATIONALE}}
Search Engine {{SEARCH}} {{VERSION}} {{RATIONALE}}
Object Storage {{STORAGE}} {{VERSION}} {{RATIONALE}}
Container Runtime {{CONTAINER}} {{VERSION}} {{RATIONALE}}
Orchestration {{ORCHESTRATION}} {{VERSION}} {{RATIONALE}}
API Gateway {{GATEWAY}} {{VERSION}} {{RATIONALE}}
Auth Provider {{AUTH}} {{VERSION}} {{RATIONALE}}
Observability {{OBSERVABILITY}} {{VERSION}} {{RATIONALE}}
CI/CD {{CICD}} {{VERSION}} {{RATIONALE}}

6. Data Flow Overview

6.1 Primary Write Flow

flowchart LR
    A([User]) -->|HTTPS POST| B[API Gateway]
    B -->|Authenticate| C[Auth Service]
    C -->|JWT validated| B
    B -->|Route request| D[Business Service]
    D -->|Validate input| D
    D -->|Write| E[(Database)]
    D -->|Publish event| F[Message Queue]
    F -->|Consume| G[Worker Service]
    G -->|Side effects| H[External APIs]
    G -->|Notify| I[Email/Push]
    D -->|Cache invalidate| J[(Cache)]
    D -->|Return 201| B
    B -->|Response| A

6.2 Primary Read Flow

flowchart LR
    A([User]) -->|HTTPS GET| B[API Gateway]
    B -->|Authenticate| C[Auth Service]
    B -->|Route| D[Business Service]
    D -->|Cache check| E[(Redis Cache)]
    E -->|Cache hit| D
    E -->|Cache miss| F[(Database)]
    F -->|Read| D
    D -->|Populate cache| E
    D -->|Return 200| A

7. Integration Points

7.1 External Integrations

System Direction Protocol Auth Data Exchanged SLA/Criticality
{{EXT_SYSTEM_1}} Outbound REST/HTTPS API Key {{DATA}} {{SLA}} / {{CRITICALITY}}
{{EXT_SYSTEM_2}} Inbound Webhooks HMAC {{DATA}} {{SLA}} / {{CRITICALITY}}
{{EXT_SYSTEM_3}} Bidirectional gRPC mTLS {{DATA}} {{SLA}} / {{CRITICALITY}}

7.2 Internal Service Integrations

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

8. Deployment Overview

flowchart TB
    subgraph Internet
        CDN[CDN / Edge Cache]
        DNS[DNS]
    end

    subgraph Cloud["Cloud Provider — {{CLOUD_PROVIDER}}"]
        subgraph LoadBalancer["Load Balancer Layer"]
            LB[Application Load Balancer]
        end

        subgraph AppTier["Application Tier — {{REGION}}"]
            direction LR
            API1[API Pod 1]
            API2[API Pod 2]
            API3[API Pod N]
        end

        subgraph WorkerTier["Worker Tier"]
            W1[Worker Pod 1]
            W2[Worker Pod N]
        end

        subgraph DataTier["Data Tier"]
            DB_PRIMARY[(DB Primary)]
            DB_REPLICA[(DB Replica)]
            REDIS[(Redis Cluster)]
            MQ[Message Queue]
        end

        subgraph Observability["Observability Stack"]
            LOGS[Log Aggregator]
            METRICS[Metrics / Prometheus]
            TRACES[Distributed Tracing]
        end
    end

    DNS --> CDN
    CDN --> LB
    LB --> API1 & API2 & API3
    API1 & API2 & API3 --> DB_PRIMARY
    API1 & API2 & API3 --> REDIS
    API1 & API2 & API3 --> MQ
    DB_PRIMARY --> DB_REPLICA
    MQ --> W1 & W2
    API1 & API2 & API3 --> LOGS & METRICS & TRACES

Environments

Environment URL Purpose Scale
Development http://localhost:{{PORT}} Local dev Single instance
Staging https://staging.{{DOMAIN}} Pre-prod testing Minimal (1 replica)
Production https://{{DOMAIN}} Live traffic Auto-scaled

9. Cross-Cutting Concerns

9.1 Authentication & Authorization

9.2 Logging

9.3 Error Handling

9.4 Caching

9.5 Rate Limiting

9.6 Secrets Management


10. Quality Attributes & Architectural Trade-offs

Quality Attribute Target Approach Trade-off
Availability {{SLA_PERCENT}} uptime Multi-AZ deployment, health checks, auto-restart Higher infrastructure cost
Performance (p99 latency) < {{P99_LATENCY}}ms Caching, query optimization, CDN Cache invalidation complexity
Scalability {{CONCURRENT_USERS}} concurrent users Horizontal scaling, stateless services Distributed state challenges
Security OWASP Top 10 compliant WAF, input validation, RBAC Added latency from security checks
Maintainability {{DEPLOY_FREQUENCY}} deploys/week CI/CD pipeline, test coverage > {{TEST_COVERAGE}}% Initial investment in tooling
Data Consistency {{CONSISTENCY_MODEL}} {{CONSISTENCY_APPROACH}} {{CONSISTENCY_TRADEOFF}}

11. Key Architectural Decisions

ADR Decision Status Date
ADR-001 {{DECISION_SUMMARY_1}} Accepted {{DATE}}
ADR-002 {{DECISION_SUMMARY_2}} Accepted {{DATE}}
ADR-003 {{DECISION_SUMMARY_3}} Proposed {{DATE}}

12. Constraints & Assumptions

12.1 Constraints

# Constraint Category Impact
C1 {{CONSTRAINT_1}} Technical/Regulatory/Business {{IMPACT}}
C2 {{CONSTRAINT_2}} Technical/Regulatory/Business {{IMPACT}}
C3 {{CONSTRAINT_3}} Technical/Regulatory/Business {{IMPACT}}

12.2 Assumptions

# Assumption Validation Method Risk if Wrong
A1 {{ASSUMPTION_1}} {{HOW_TO_VALIDATE}} {{RISK}}
A2 {{ASSUMPTION_2}} {{HOW_TO_VALIDATE}} {{RISK}}

13. Risks & Mitigations

Risk Likelihood Impact Score Mitigation Contingency
{{RISK_1}} {{1-5}} {{1-5}} {{L×I}} {{MITIGATION}} {{CONTINGENCY}}
{{RISK_2}} {{1-5}} {{1-5}} {{L×I}} {{MITIGATION}} {{CONTINGENCY}}
{{RISK_3}} {{1-5}} {{1-5}} {{L×I}} {{MITIGATION}} {{CONTINGENCY}}
Single database bottleneck 3 5 15 Read replicas, connection pooling Add read replicas, implement CQRS
Third-party API unavailability 4 3 12 Circuit breaker, cached fallback Fallback to cached data, async retry
Data breach via injection 2 5 10 Input validation, parameterized queries, WAF Incident response plan, GDPR notification

Approval

Role Name Date Signature
Author
Technical Lead
Security Review
Architect
Approver (CTO/Lead)

Module Design

Module Design Document

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

Document History

Version Date Author Changes
0.1 {{DATE}} {{AUTHOR}} Initial draft

1. Module Overview & Responsibility

Module: {{MODULE_NAME}} Layer: Domain | Application | Infrastructure | Presentation Repository: {{REPO_OR_MONOREPO_PATH}} Team Owner: {{TEAM_NAME}}

Single Responsibility Statement:

{{THE_MODULE_IS_RESPONSIBLE_FOR_ONE_THING}}

This module owns:

This module does NOT own:

Why this is a separate module: {{RATIONALE_FOR_SEPARATION}} — e.g., "Separate bounded context, different team ownership, different scaling requirements"


2. Interface Definition (Public API)

2.1 Exported Service Interface

// Public interface exported by this module
export interface I{{ModuleName}}Service {
  /**
   * {{METHOD_1_DESCRIPTION}}
   * @throws {ValidationError} if dto is invalid
   * @throws {ConflictError} if {{UNIQUE_FIELD}} already exists
   */
  create(dto: Create{{Entity}}Dto, context: RequestContext): Promise<{{Entity}}>;

  /**
   * {{METHOD_2_DESCRIPTION}}
   * @throws {NotFoundError} if not found
   */
  findById(id: string, context: RequestContext): Promise<{{Entity}}>;

  /**
   * {{METHOD_3_DESCRIPTION}}
   */
  findAll(filter: {{Filter}}Dto, context: RequestContext): Promise<PaginatedResult<{{Entity}}>>;

  /**
   * {{METHOD_4_DESCRIPTION}}
   * @throws {NotFoundError} if not found
   * @throws {ForbiddenError} if user lacks permission
   */
  update(id: string, dto: Update{{Entity}}Dto, context: RequestContext): Promise<{{Entity}}>;

  /**
   * {{METHOD_5_DESCRIPTION}}
   * @throws {NotFoundError} if not found
   */
  delete(id: string, context: RequestContext): Promise<void>;
}

// DTOs exported for consumers
export type Create{{Entity}}Dto = { /* ... */ };
export type Update{{Entity}}Dto = { /* ... */ };
export type {{Filter}}Dto = { /* ... */ };
export type {{Entity}} = { /* ... */ };

2.2 HTTP Endpoints (if applicable)

Method Path Auth Description
POST /api/v{{V}}/{{resource}} JWT Create {{entity}}
GET /api/v{{V}}/{{resource}} JWT List {{entities}}
GET /api/v{{V}}/{{resource}}/:id JWT Get by ID
PUT /api/v{{V}}/{{resource}}/:id JWT Full update
PATCH /api/v{{V}}/{{resource}}/:id JWT Partial update
DELETE /api/v{{V}}/{{resource}}/:id JWT Soft delete

2.3 Events Published

Event Topic/Queue Schema Triggered By
{{entity}}.created {{TOPIC}} See §5 POST endpoint
{{entity}}.updated {{TOPIC}} See §5 PUT/PATCH endpoint
{{entity}}.deleted {{TOPIC}} See §5 DELETE endpoint
{{entity}}.{{CUSTOM_EVENT}} {{TOPIC}} See §5 {{BUSINESS_TRIGGER}}

3. Internal Structure

{{MODULE_NAME}}/
├── controllers/
│   └── {{entity}}.controller.ts      # HTTP request handling, input parsing
├── services/
│   └── {{entity}}.service.ts         # Business logic
├── repositories/
│   ├── {{entity}}.repository.ts      # Data access interface
│   └── {{entity}}.repository.pg.ts   # PostgreSQL implementation
├── domain/
│   ├── {{entity}}.entity.ts          # Domain entity / value objects
│   ├── {{entity}}.events.ts          # Domain events
│   └── {{entity}}.errors.ts          # Domain-specific errors
├── dto/
│   ├── create-{{entity}}.dto.ts
│   ├── update-{{entity}}.dto.ts
│   └── {{entity}}-filter.dto.ts
├── mappers/
│   └── {{entity}}.mapper.ts          # DB record ↔ domain entity ↔ DTO
├── __tests__/
│   ├── unit/
│   └── integration/
└── {{entity}}.module.ts              # Module registration / DI wiring

Layer rules (enforced by linting):


4. Database Schema

Primary Table: {{table_name}}

Column Type Nullable Default Constraints Description
id UUID NO gen_random_uuid() PK Surrogate key
created_at TIMESTAMPTZ NO NOW() Immutable creation time
updated_at TIMESTAMPTZ NO NOW() Auto-updated on write
deleted_at TIMESTAMPTZ YES NULL Soft delete marker
version INTEGER NO 1 Optimistic lock version
{{FIELD_1}} {{TYPE}} {{YES/NO}} {{DEFAULT}} {{CHECK/UNIQUE/FK}} {{DESCRIPTION}}
{{FIELD_2}} {{TYPE}} {{YES/NO}} {{DEFAULT}} {{CHECK/UNIQUE/FK}} {{DESCRIPTION}}
{{FK_ID}} UUID NO FK → {{other_table}}(id) {{RELATIONSHIP}}

Indexes:

CREATE INDEX CONCURRENTLY idx_{{table_name}}_{{column}} ON {{table_name}}({{column}})
    WHERE deleted_at IS NULL;
-- Rationale: {{WHY_THIS_INDEX}}

CREATE UNIQUE INDEX idx_{{table_name}}_{{unique_column}} ON {{table_name}}({{unique_column}})
    WHERE deleted_at IS NULL;
-- Rationale: Enforce uniqueness for active records only

RLS (Row-Level Security):

-- TODO: Enable if multi-tenant
-- ALTER TABLE {{table_name}} ENABLE ROW LEVEL SECURITY;
-- CREATE POLICY {{table_name}}_tenant_isolation ON {{table_name}}
--     USING (tenant_id = current_setting('app.current_tenant_id')::uuid);

5. API Endpoints (Detailed)

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

Request:

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

Success 201:

{
  "id": "uuid",
  "{{field1}}": "value",
  "{{field2}}": 0,
  "createdAt": "ISO8601"
}

Event published: {{entity}}.created

{
  "specversion": "1.0",
  "type": "{{entity}}.created",
  "source": "/{{resource}}",
  "id": "{{EVENT_UUID}}",
  "time": "ISO8601",
  "data": {
    "entityId": "uuid",
    "tenantId": "uuid",
    "createdBy": "uuid",
    "{{field1}}": "value"
  }
}

6. Business Logic Specifications

6.1 Business Rules

Rule ID Rule Enforced In Error
BR-001 {{RULE_1_DESCRIPTION}} Service {{ERROR_CODE}}
BR-002 {{RULE_2_DESCRIPTION}} Domain Entity {{ERROR_CODE}}
BR-003 {{RULE_3_DESCRIPTION}} Service + DB constraint ConflictError

6.2 Validation Rules

Field Type Required Validation Error Message
{{field1}} string Yes Min 1, Max 255 chars "{{field1}} must be 1-255 characters"
{{field2}} number Yes Positive integer "{{field2}} must be a positive integer"
{{field3}} enum No One of: [A, B, C] "{{field3}} must be A, B, or C"

6.3 Authorization Rules

Operation Required Role Additional Conditions
Create {{ROLE}}
Read own Any authenticated user userId === resource.createdBy
Read any {{ADMIN_ROLE}}
Update {{ROLE}} userId === resource.createdBy OR isAdmin
Delete {{ADMIN_ROLE}} Soft delete only
Hard delete SUPER_ADMIN Requires 2FA confirmation

7. Event Publishing / Consuming

7.1 Events Published

Event When Payload Schema Idempotency Key
{{entity}}.created After successful create {entityId, tenantId, ...} entityId
{{entity}}.updated After successful update {entityId, changes: {...}} entityId + version
{{entity}}.deleted After soft delete {entityId, deletedAt} entityId

7.2 Events Consumed

Event Source Module Handler Processing Guarantee
{{other_entity}}.deleted {{OTHER_MODULE}} Cascade soft-delete related records At-least-once
{{other_entity}}.updated {{OTHER_MODULE}} Update denormalized cache At-least-once

Idempotency strategy: All consumers check processed_events table before processing. Duplicate events are logged and skipped.


8. Dependencies

8.1 Upstream (what this module depends on)

Dependency Type Coupling Reason
AuthModule Internal module Loose (interface) JWT validation, user context
{{OTHER_MODULE}} Internal module Loose (events) {{REASON}}
PostgreSQL Infrastructure Required Primary storage
Redis Infrastructure Optional Caching (degrades gracefully)
{{EXTERNAL_SDK}} External library Hard {{REASON}}

8.2 Downstream (what depends on this module)

Consumer What they use Notes
{{OTHER_MODULE}} {{entity}}.created events Read-only consumer
{{FRONTEND}} HTTP API Via API gateway

9. Error Handling & Recovery

Error Scenario Handling User Impact Recovery
DB connection lost Retry 3x with backoff, then 503 Request fails gracefully Auto-recover when DB reconnects
External API timeout Return cached data or 503 Degraded feature Async retry, alert on-call
Duplicate submission Detect via unique constraint, return 409 Clear error message None needed
Invalid state transition Return 422 with state machine error Clear error message User corrects input
Event publish failure Log to retry queue, return 202 Async delay Background retry

10. Configuration & Feature Flags

Environment Variables

Variable Type Default Description
{{MODULE_NAME}}_CACHE_TTL number 300 Cache TTL seconds
{{MODULE_NAME}}_MAX_LIST_SIZE number 100 Max items per page
{{MODULE_NAME}}_RATE_LIMIT_RPM number 60 Rate limit per minute

Feature Flags

Flag Type Default Description
{{MODULE_NAME}}_ENABLE_CACHE boolean true Toggle Redis caching
{{MODULE_NAME}}_ENABLE_{{FEATURE}} boolean false Gradual rollout of {{FEATURE}}

11. Monitoring & Health Checks

Health Check Endpoint

GET /health/{{module-name}}

{
  "status": "healthy | degraded | unhealthy",
  "checks": {
    "database": "healthy",
    "cache": "healthy | degraded",
    "externalApi": "healthy | degraded | unhealthy"
  },
  "latency": {
    "database_ms": 5,
    "cache_ms": 1
  }
}

Key Metrics

Metric Type Alert Threshold Dashboard
{{module}}_requests_total Counter {{DASHBOARD_LINK}}
{{module}}_request_duration_ms Histogram p99 > {{THRESHOLD}}ms {{DASHBOARD_LINK}}
{{module}}_errors_total Counter Error rate > {{THRESHOLD}}% {{DASHBOARD_LINK}}
{{module}}_cache_hit_rate Gauge < {{THRESHOLD}}% for 5min {{DASHBOARD_LINK}}
{{module}}_db_pool_exhausted Counter Any occurrence {{DASHBOARD_LINK}}

12. Primary Flow — Sequence Diagram

sequenceDiagram
    autonumber
    participant C as Controller
    participant S as Service
    participant V as Validator
    participant R as Repository
    participant DB as PostgreSQL
    participant EB as Event Bus
    participant Cache as Redis

    C->>V: validate(dto)
    alt Invalid input
        V-->>C: ValidationError
        C-->>Client: 400 Bad Request
    end
    V-->>C: Validated DTO

    C->>S: create(dto, context)
    S->>S: checkBusinessRules(dto)
    alt Business rule violation
        S-->>C: BusinessRuleError
        C-->>Client: 422 Unprocessable
    end

    S->>DB: BEGIN TRANSACTION
    S->>R: create(entityData)
    R->>DB: INSERT INTO {{table_name}}
    DB-->>R: Inserted record
    R-->>S: {{Entity}} domain object

    S->>DB: COMMIT

    S->>EB: publish("{{entity}}.created", event)
    Note over EB: Async — does not block response

    S->>Cache: INVALIDATE related keys
    S-->>C: {{Entity}} DTO
    C-->>Client: 201 Created

Approval

Role Name Date Signature
Author
Module Owner
Tech Lead
Reviewer

Integration Design

Integration Design Document

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

Document History

Version Date Author Changes
0.1 {{DATE}} {{AUTHOR}} Initial draft

1. Integration Overview & Context

Integration Name: {{INTEGRATION_NAME}} Type: Synchronous (REST/gRPC) | Asynchronous (Events/Queue) | Bidirectional | File-based

Business Purpose: {{WHY_THIS_INTEGRATION_EXISTS}}

Criticality: Critical | High | Medium | Low

Parties:

Party System Team Contact
Consumer (caller) {{CONSUMER_SYSTEM}} {{TEAM_A}} {{CONTACT_A}}
Provider (server) {{PROVIDER_SYSTEM}} {{TEAM_B}} {{CONTACT_B}}

2. Integration Topology Diagram

flowchart LR
    subgraph ConsumerSide["Consumer — {{CONSUMER_SYSTEM}}"]
        C_SVC[{{ConsumerService}}]
        C_CB[Circuit Breaker]
        C_RETRY[Retry Handler]
    end

    subgraph Integration["Integration Layer"]
        GW[API Gateway / Load Balancer]
        Q[Message Queue\n{{QUEUE_NAME}}]
        DLQ[Dead Letter Queue\n{{DLQ_NAME}}]
    end

    subgraph ProviderSide["Provider — {{PROVIDER_SYSTEM}}"]
        P_SVC[{{ProviderService}}]
        P_DB[(Provider DB)]
        P_WORKER[Event Worker]
    end

    C_SVC --> C_CB
    C_CB --> C_RETRY
    C_RETRY -->|HTTPS REST| GW
    GW --> P_SVC
    P_SVC --> P_DB

    P_WORKER -->|Publish| Q
    Q -->|Consume| C_SVC
    Q -->|Failed| DLQ
    DLQ -->|Alert| AlertSystem[PagerDuty]

3. Service Contracts

3.1 Integration: {{INTEGRATION_NAME_1}}

Protocol: REST/HTTPS | gRPC | GraphQL | WebSocket | AMQP Direction: {{CONSUMER}} → {{PROVIDER}} Idempotency: YES — use Idempotency-Key header | NO

Authentication

Method Details
Type Bearer JWT
Header Authorization: Bearer {{TOKEN}}
Key rotation Every {{ROTATION_PERIOD}} — coordinated via {{ROTATION_PROCESS}}
Token endpoint {{AUTH_ENDPOINT}} (if OAuth2)

Request Contract

Endpoint: {{HTTP_METHOD}} {{BASE_URL}}/{{PATH}}

Headers:

Authorization: Bearer {{JWT_OR_API_KEY}}
Content-Type: application/json
Accept: application/json
X-Request-ID: {{UUID}}
X-Idempotency-Key: {{IDEMPOTENCY_KEY}}

Request Body:

{
  "{{field1}}": "{{type}} — {{description}}",
  "{{field2}}": "{{type}} — {{description}}",
  "metadata": {
    "sourceSystem": "{{CONSUMER_SYSTEM_ID}}",
    "timestamp": "ISO8601"
  }
}

Successful Response 200 / 201:

{
  "{{responseField1}}": "{{type}}",
  "{{responseField2}}": "{{type}}",
  "requestId": "echo of X-Request-ID"
}

Error Handling

HTTP Status Error Code Consumer Action
400 VALIDATION_ERROR Log error, do NOT retry — fix request
401 UNAUTHORIZED Refresh token, retry once
403 FORBIDDEN Alert engineering, do NOT retry
404 NOT_FOUND Log, do NOT retry — check resource ID
409 CONFLICT Log, skip (idempotent)
422 BUSINESS_RULE Log error, do NOT retry — escalate
429 RATE_LIMITED Backoff per Retry-After header
500 INTERNAL_ERROR Retry with exponential backoff
502/503 UNAVAILABLE Circuit breaker — fail fast

Retry Policy

Max retries: {{MAX_RETRIES}} (retry only on 500, 502, 503, 429, network errors)
Strategy: Exponential backoff with jitter
Delays: [{{DELAY_1}}ms, {{DELAY_2}}ms, {{DELAY_3}}ms]
Timeout per attempt: {{TIMEOUT_MS}}ms

Circuit Breaker Configuration

Failure threshold: {{FAILURE_PERCENT}}% failures in {{WINDOW_SECONDS}}s window
Open duration: {{OPEN_DURATION_SECONDS}}s
Half-open test: 1 request
Alert on: Circuit open for > {{ALERT_THRESHOLD_SECONDS}}s

Rate Limiting

Limit Value Window Action when exceeded
Requests per minute {{RPM}} 60s sliding HTTP 429, Retry-After
Burst limit {{BURST}} 1s HTTP 429 immediately
Daily quota {{DAILY}} 24h HTTP 429, contact support

Timeout Configuration

Timeout Type Value Notes
Connection timeout {{CONN_TIMEOUT_MS}}ms Time to establish connection
Read timeout {{READ_TIMEOUT_MS}}ms Time to receive first byte
Total request timeout {{TOTAL_TIMEOUT_MS}}ms End-to-end budget

3.2 Integration: {{INTEGRATION_NAME_2}} (if applicable)

Protocol: gRPC Service definition:

service {{ServiceName}} {
  rpc {{MethodName}} ({{RequestMessage}}) returns ({{ResponseMessage}});
  rpc {{StreamMethodName}} ({{RequestMessage}}) returns (stream {{ResponseMessage}});
}

message {{RequestMessage}} {
  string id = 1;
  string tenant_id = 2;
  {{FieldType}} {{field_name}} = 3;
}

message {{ResponseMessage}} {
  string id = 1;
  {{FieldType}} {{field_name}} = 2;
  google.protobuf.Timestamp created_at = 3;
}

4. Event-Driven Integrations

4.1 Event Schemas (CloudEvents 1.0)

Event: {{entity}}.{{ACTION}}

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

{
  "specversion": "1.0",
  "type": "{{REVERSE_DNS_EVENT_TYPE}}",
  "source": "https://{{SYSTEM_DOMAIN}}/{{resource}}",
  "id": "{{UUID}}",
  "time": "2024-01-01T00:00:00Z",
  "datacontenttype": "application/json",
  "subject": "{{RESOURCE_ID}}",
  "data": {
    "entityId": "UUID of affected resource",
    "tenantId": "UUID of tenant",
    "actorId": "UUID of user who triggered event",
    "{{DOMAIN_FIELD_1}}": "domain-specific data",
    "{{DOMAIN_FIELD_2}}": "domain-specific data",
    "previousState": null,
    "newState": "{{STATE}}"
  }
}

4.2 Topics / Queues

Topic/Queue Partitions Retention Consumers Producer
{{TOPIC_NAME_1}} {{N}} {{RETENTION}} {{CONSUMER_GROUPS}} {{PRODUCER_SERVICE}}
{{TOPIC_NAME_2}} {{N}} {{RETENTION}} {{CONSUMER_GROUPS}} {{PRODUCER_SERVICE}}

4.3 Ordering Guarantees

Integration Ordering Scope Notes
{{INTEGRATION_1}} Strict order Per tenantId Kafka partition by tenantId
{{INTEGRATION_2}} Best-effort Global FIFO queue — no strict ordering
{{INTEGRATION_3}} No ordering N/A Independent events

4.4 Idempotency Strategy

For each consumed event:
1. Check processed_events table: SELECT 1 WHERE event_id = $1 AND consumer_group = $2
2. If found: log "Duplicate event skipped" and ACK (do not reprocess)
3. If not found: process event
4. On success: INSERT INTO processed_events (event_id, consumer_group, processed_at)
5. ACK message

Deduplication window: {{DEDUP_WINDOW}} (keep processed_events for this duration)

5. Data Consistency Patterns

5.1 Consistency Model

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

5.2 Saga Pattern (if used for distributed transactions)

sequenceDiagram
    autonumber
    participant O as Orchestrator
    participant S1 as {{SERVICE_1}}
    participant S2 as {{SERVICE_2}}
    participant S3 as {{SERVICE_3}}

    O->>S1: Execute Step 1
    S1-->>O: Step 1 succeeded {result1}
    O->>S2: Execute Step 2 (with result1)
    S2-->>O: Step 2 succeeded {result2}
    O->>S3: Execute Step 3 (with result2)
    S3-->>O: Step 3 FAILED

    Note over O: Compensating transactions (reverse order)
    O->>S2: Compensate Step 2
    S2-->>O: Compensated
    O->>S1: Compensate Step 1
    S1-->>O: Compensated
    O-->>Client: Transaction rolled back

Compensation strategies:

Step Compensation Notes
{{STEP_1}} {{COMPENSATION_1}} {{NOTES}}
{{STEP_2}} {{COMPENSATION_2}} {{NOTES}}

6. Integration Testing Strategy

6.1 Contract Testing (Pact)

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:

Error scenarios:


7. Monitoring & Alerting

7.1 Key Metrics

Metric Type Alert Condition Severity
integration_{{name}}_requests_total Counter
integration_{{name}}_error_rate Gauge > {{THRESHOLD}}% for 5m HIGH
integration_{{name}}_latency_p99_ms Histogram > {{THRESHOLD}}ms for 5m MEDIUM
integration_{{name}}_circuit_open Gauge == 1 CRITICAL
integration_{{name}}_dlq_depth Gauge > 0 HIGH
integration_{{name}}_consumer_lag Gauge > {{LAG_THRESHOLD}} HIGH

7.2 Distributed Tracing

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