Low-Level Design (LLD)

Detailed flow diagrams and implementation details

Login & Authentication Flow

Flow: Login & Authentication

Document: LLD-001 Version: 1.0 Date: 2026-02-21 Author: Frontend Architect (AI Agent) Status: Draft Scope: End-to-end login flow for web and mobile, including BankID OIDC, session management, demo mode, and error handling


1. Overview

Drop uses BankID OIDC as the sole production authentication method. Email/password login exists only in demo/dev mode. Authentication produces a JWT stored as an httpOnly cookie (web) or Bearer token (mobile). The login flow includes BankID redirect, loading states, token receipt, session persistence, and comprehensive error handling.

Key facts:


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

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

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

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

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

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

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

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

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

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

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

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

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