Low-Level Design (LLD)
Detailed flow diagrams and implementation details
Login & Authentication Flow
Flow: Login & Authentication
Document: LLD-001
Version: 1.0
Date: 2026-02-21
Author: Frontend Architect (AI Agent)
Status: Draft
Scope: End-to-end login flow for web and mobile, including BankID OIDC, session management, demo mode, and error handling
1. Overview
Drop uses BankID OIDC as the sole production authentication method. Email/password login exists only in demo/dev mode. Authentication produces a JWT stored as an httpOnly cookie (web) or Bearer token (mobile). The login flow includes BankID redirect, loading states, token receipt, session persistence, and comprehensive error handling.
Key facts:
BankID is mandatory for production (PSD2/SCA compliance)
Demo mode provides email/password fallback for development
JWT lifetime: 7d (all clients)
Session tracking via SHA-256 token hash in sessions table
Age verification (>= 18) enforced during BankID callback
2. Web Login Flow (BankID OIDC)
2.1 Sequence Diagram — Web BankID Login
sequenceDiagram
actor User
participant Browser as Browser (Next.js Client)
participant BFF as Next.js BFF (/api/auth/bankid)
participant BankID as BankID OIDC Provider
participant DB as SQLite/PostgreSQL
User->>Browser: Navigate to /login
Browser->>Browser: Render login page (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 (client_id, redirect_uri, state, nonce, scope=openid)
BankID->>User: BankID authentication UI (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 (/login page)
participant API as Next.js API (/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 (/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 (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 { 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 (Face ID / Touch ID)
4. Authentication State Diagram
stateDiagram-v2
[*] --> Unauthenticated: App launch
Unauthenticated --> BankIDRedirect: Click "BankID"
Unauthenticated --> DemoLogin: Enter credentials (dev mode)
BankIDRedirect --> BankIDAuthenticating: Browser opens BankID
BankIDAuthenticating --> CallbackProcessing: BankID returns code
BankIDAuthenticating --> BankIDError: Auth failed/cancelled/timeout
CallbackProcessing --> Authenticated: Valid token + session created
CallbackProcessing --> AgeRejected: User under 18
CallbackProcessing --> BankIDError: Token verification failed
DemoLogin --> Authenticated: Valid credentials
DemoLogin --> LoginError: Invalid credentials
Authenticated --> SessionActive: JWT valid + session not revoked
SessionActive --> TokenExpired: JWT expired (7d all platforms)
SessionActive --> SessionRevoked: Logout or admin revoke
SessionActive --> Authenticated: Token refresh
TokenExpired --> Unauthenticated: Redirect to /login
SessionRevoked --> Unauthenticated: Clear cookie/token
AgeRejected --> Unauthenticated: Show age error
BankIDError --> Unauthenticated: Show error + retry
LoginError --> Unauthenticated: Show error message
5. Error States
5.1 Error State Table
Error
Cause
User-Facing Message (Norwegian)
Recovery Action
BankID Unavailable
BankID service down
"BankID er midlertidig utilgjengelig. Prøv igjen senere."
Retry button, show status page link
BankID Timeout
User took too long (>5min)
"BankID-sesjonen utløp. Vennligst prøv igjen."
Auto-redirect back to login page
BankID Cancelled
User cancelled authentication
"Innlogging avbrutt. Trykk 'BankID' for å prøve igjen."
Show login page with BankID button
State Mismatch
CSRF attack or stale session
"Noe gikk galt. Vennligst prøv å logge inn på nytt."
Clear state cookie, redirect to /login
Token Verification Failed
Invalid/tampered id_token
"Autentisering mislyktes. Prøv igjen."
Redirect to /login
Age Under 18
User is younger than 18
"Du må være minst 18 år for å bruke Drop."
No retry — age requirement is firm
Rate Limited
Too many login attempts
"For mange forsøk. Vent litt og prøv igjen."
Wait and retry (10/min limit)
Invalid Credentials (Demo)
Wrong email or password
"Feil e-post eller passord."
Re-enter credentials
Session Expired
JWT expired
"Sesjonen din har utløpt. Logg inn igjen."
Redirect to /login
Session Revoked
Logout from another device
"Du har blitt logget ut."
Re-login via BankID
Network Error
No connectivity
"Ingen nettverkstilkobling. Sjekk internett."
Retry when connectivity restored
5.2 Error Handling by Platform
Platform
Error Display
Navigation
Web
Inline error message on login page, red text below form
router.push("/login") on session errors
Mobile
Alert dialog or inline error text
router.replace("/") (welcome screen) on session errors
6. Session Management
6.1 Token Storage
Platform
Storage
Token Name
Flags
Web
httpOnly cookie
drop_token
httpOnly, secure, sameSite=Lax
Mobile
AsyncStorage
Bearer token
In-memory variable + persistent storage
6.2 Session Lifecycle
Event
Action
Database
Login success
Create session record
INSERT INTO sessions (id, user_id, token_hash, expires_at)
Each request
Verify session valid
SELECT * FROM sessions WHERE token_hash = ? AND revoked = 0 AND expires_at > now()
Token refresh
New session, revoke old
INSERT new session , UPDATE old session SET revoked = 1
Logout
Revoke all sessions
UPDATE sessions SET revoked = 1 WHERE user_id = ?
Admin action
Revoke specific session
UPDATE sessions SET revoked = 1 WHERE id = ?
6.3 Token Refresh
POST /v1/auth/refresh refreshes the user's session:
Reads drop_token cookie / Bearer token
Verifies current JWT and session validity
Revokes old session ( UPDATE sessions SET revoked = 1 )
Creates new session + JWT
Sets new drop_token cookie (Max-Age=604800, HttpOnly, SameSite=Lax)
Returns new user data
6.4 Deprecated Endpoints
The following endpoints return 410 Gone and exist only for backward compatibility:
Endpoint
Status
Reason
POST /v1/auth/login
410 Gone
Replaced by BankID OIDC flow
POST /v1/auth/register
410 Gone
User creation is automatic on first BankID login
POST /v1/auth/verify-otp
410 Gone
OTP flow removed with BankID migration
6.5 useAuth Hook (Web)
The useAuth() hook on the web client:
Calls GET /api/auth/me on mount
If 401 → redirects to /login
Returns { user, loading } to the page component
Every authenticated page wraps content with if (loading) return
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
Email entry
Password input
Password entry
Login button
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 elements
Error announcements
Error messages use role="alert" for screen readers
Focus management
After error, focus returns to the first invalid field
Color contrast
Error red (#EF4444) on white background meets 4.5:1 ratio
Keyboard navigation
Tab order follows visual order: email → password → submit → social buttons
Password visibility
Toggle button has aria-label "Vis passord" / "Skjul passord"
Loading state
Submit button shows loading spinner and is disabled during request
9. Cross-References
BankID OIDC integration details: See Authentication
API endpoints: GET /v1/auth/bankid/initiate , POST /v1/auth/bankid/callback , POST /v1/auth/demo-login , POST /v1/auth/refresh , GET /api/auth/me — See API Reference
Session database schema: sessions table — See Database Schema
Component overview: See component-overview.md
Figma login screen: mockups/figma-make-export/src/app/screens/Login.tsx
Web login page: src/drop-app/src/app/login/page.tsx — See PAGES.md
Registration flow: See flow-registration-onboarding.md
Login & Authentication (Backend)
Login & Authentication — Backend Architecture
Backend-specific authentication details for the Drop fintech platform. Covers JWT token structure, token refresh mechanism, session revocation, rate limiting on auth endpoints, audit logging, demo mode implementation, and cookie security settings.
JWT Token Structure
Token Generation
Source: src/drop-api/src/lib/auth.ts:42-48
Library: jose (HS256 default, RS256 opt-in)
new jose.SignJWT({ userId, email, role })
.setProtectedHeader({ alg: "HS256" })
.setIssuedAt()
.setIssuer("drop-api")
.setAudience("drop")
.setExpirationTime("7d")
.sign(key);
JWT Claims
Claim
Type
Value
Source
userId
string
usr_<16 hex chars> (e.g., usr_a1b2c3d4e5f6g7h8 )
auth.ts:44 — from user record
email
string
usr_xxx@bankid.drop.local (BankID placeholder) or real email
auth.ts:44 — from user record
role
string
user or merchant
auth.ts:44 — from users.role column
iat
number
Unix timestamp of issuance
auth.ts:45 — setIssuedAt()
exp
number
iat + 7 days (604800 seconds)
auth.ts:45 — setExpirationTime("7d")
iss
string
drop-api
auth.ts:45 — setIssuer()
aud
string
drop
auth.ts:45 — setAudience()
Algorithm Selection
Algorithm
Condition
Key Source
Use Case
HS256 (default)
JWT_RS256_PRIVATE_KEY not set
JWT_SECRET env var → TextEncoder.encode()
Standard deployment
RS256 (opt-in)
Both JWT_RS256_PRIVATE_KEY and JWT_RS256_PUBLIC_KEY set
PEM-encoded RSA key pair
Multi-service verification (API gateway, microservices)
Source: auth.ts:12-34 — getAlgorithm() auto-detects based on available keys.
Token Verification
Source: auth.ts:50-66
Determine algorithm (HS256 or RS256)
Call jose.jwtVerify(token, key, { issuer: "drop-api", audience: "drop" })
Extract userId , email , role from payload
Type-check: both userId and email must be strings
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 Authorization: Bearer
API->>API: Extract token from Bearer header or cookie
API->>API: Verify JWT signature (jose)
API->>DB: SELECT session WHERE token_hash = SHA256(token) 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 WHERE user_id = ?
Note over API: Create new session
API->>API: Sign new JWT (7d expiry)
API->>DB: INSERT INTO sessions (id, user_id, token_hash, expires_at)
Note over API: Set cookie for web clients
API->>API: Set-Cookie: drop_token=; HttpOnly; Path=/; Max-Age=604800; SameSite=Lax
API-->>Client: { data: { id, email, firstName, ... }, token: "" }
Refresh Behavior
Source: routes/auth.ts:201-210
Auth middleware validates current token
All existing sessions revoked ( revokeAllSessions(user.id) )
New JWT signed with fresh iat and exp (7 days from now)
New session record created in sessions table
Cookie set for web clients ( Set-Cookie header)
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 Authorization: Bearer
API->>API: Verify token (authMiddleware)
API->>DB: UPDATE sessions SET revoked = 1 WHERE user_id = ?
Note over DB: ALL sessions for this user revoked
API->>DB: INSERT INTO audit_log (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 WHERE user_id = ?
API->>DB: INSERT INTO audit_log (action: 'security_revocation')
Note over Client: Next request fails auth check
Client->>API: Any authenticated request
API->>DB: SELECT session WHERE token_hash = ? 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 WHERE user_id = ?
Note over DB: Old sessions invalidated
API->>DB: INSERT INTO sessions (new session)
API-->>Client: { token: "" }
end
Session Verification on Every Request
Source: auth.ts:108-117
Every authenticated request performs these checks:
Token signature verification — JWT must be valid and not expired
Session lookup — SELECT id FROM sessions WHERE token_hash = SHA256(token) AND revoked = 0 AND expires_at > NOW()
Session count check — If user has any sessions in DB but none match the current token, reject (prevents use of tokens from before session tracking was enabled)
User existence check — SELECT * FROM users WHERE id = ? AND deleted_at IS NULL (soft-deleted users are blocked)
Session Table Schema
Column
Type
Description
id
TEXT PK
Format: ses_<16 hex chars>
user_id
TEXT FK
References users.id
token_hash
TEXT
SHA-256 hash of the JWT string
created_at
TEXT
ISO timestamp of session creation
expires_at
TEXT
ISO timestamp, 7 days from creation
revoked
INTEGER
0 = active, 1 = revoked
Indexes: idx_sessions_user (user_id), idx_sessions_token (token_hash)
Rate Limiting on Auth Endpoints
Rate Limit Configuration
Endpoint
Limit
Window
Source
GET /v1/auth/bankid/initiate
10 requests
60 seconds
routes/auth.ts:19
POST /v1/auth/bankid/callback
10 requests
60 seconds
routes/auth.ts:43
POST /v1/auth/demo-login
Inherits from service mode check
N/A
Only available in demo mode
GET /v1/auth/me
No additional rate limit
N/A
Auth required (implicit protection)
POST /v1/auth/logout
No additional rate limit
N/A
Auth required
POST /v1/auth/refresh
No additional rate limit
N/A
Auth required
Rate Limiting Implementation
Source: middleware/rate-limit.ts:7-23
Storage: rate_limits table (persistent across restarts)
Key: Client IP address
Algorithm: Fixed window counter
Cleanup: Every 100 requests, expired entries are deleted
Atomic: Uses runUpsert for race-condition-safe counter updates
The rate limiter uses the rate_limits database table:
Column
Type
Description
key
TEXT PK
Client IP address
count
INTEGER
Request count in current window
reset_at
INTEGER
Unix timestamp when window resets
Client IP Extraction
Source: middleware/rate-limit.ts:25-27
Priority order:
x-real-ip header (nginx/Cloudflare)
First IP in x-forwarded-for chain (proxy chain)
Fallback: 127.0.0.1
Audit Logging for Auth Events
Audit Actions
Source: src/drop-api/src/lib/audit.ts
Action
Trigger
Data Recorded
REGISTER
New user created via BankID
userId , method: bankid , isNewUser: true , IP, user agent
LOGIN
Existing user authenticated via BankID
userId , method: bankid , isNewUser: false , IP, user agent
LOGOUT
User calls /v1/auth/logout
userId , resourceType: session
REFRESH
Token refresh
userId , resourceType: session
Audit Log Schema
Table: audit_log
Column
Type
Auth-Specific Usage
id
TEXT PK
Format: aud_<16 hex chars>
timestamp
TEXT
ISO timestamp of event
user_id
TEXT FK
Authenticated user ID
action
TEXT
One of REGISTER , LOGIN , LOGOUT , REFRESH
resource_type
TEXT
auth or session
resource_id
TEXT
Session ID (for session events)
details
TEXT
JSON: { method, isNewUser, platform }
ip_address
TEXT
Client IP from middleware
user_agent
TEXT
User-Agent header value
request_id
TEXT
Correlation ID from x-request-id header
Audit Log Example
{
"id": "aud_a1b2c3d4e5f6g7h8",
"timestamp": "2026-02-21T12:00:00.000Z",
"user_id": "usr_f1e2d3c4b5a69788",
"action": "LOGIN",
"resource_type": "auth",
"details": "{\"method\":\"bankid\",\"isNewUser\":false}",
"ip_address": "203.0.113.42",
"user_agent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X)",
"request_id": "550e8400-e29b-41d4-a716-446655440000"
}
Demo Mode Implementation
Overview
Source: routes/auth.ts:131-159
Demo mode provides authentication without BankID for development and testing. API-side demo mode is controlled by DROP_MODE env var (checked via isDemoMode() in services/mode.ts ). NEXT_PUBLIC_SERVICE_MODE is the client-side equivalent (set to demo in docker-compose.yml).
Demo Login Endpoint
Endpoint: POST /v1/auth/demo-login
Aspect
Behavior
Availability
Only when isDemoMode() returns true
Authentication
None required
User
Fixed demo user: usr_demo1 (seeded in db.ts )
Response
JWT token + user data (same format as BankID callback)
Feature flag
Returns 404 when demo mode is disabled
Demo User Profile
Field
Value
Source
ID
usr_demo1
db.ts seed data
Email
demo@example.test
db.ts seed data
Name
Demo User
db.ts seed data
Phone
+4700000000
db.ts seed data
Role
merchant
Upgraded in initDb()
KYC Status
approved
Set on seed
Bank accounts
DNB (45,000 NOK), Nordea (12,350 NOK)
db.ts seed data
BankID Mock Mode
Source: bankid.ts:126-128
When BANKID_MOCK=true , the BankID OIDC flow is mocked:
exchangeAndVerify() skips token exchange and JWKS verification
Returns a mock user based on the auth code value:
Code starting with underage : returns user born 2010 (fails age check)
Default: returns Test Bankersen , born 1990 (passes age check)
Deprecated Endpoints
Source: routes/auth.ts:109-128
Endpoint
Status Code
Message
POST /v1/auth/login
410 Gone
"Email/password login is no longer supported. Please use BankID."
POST /v1/auth/register
410 Gone
"Email/password registration is no longer supported. Please use BankID."
POST /v1/auth/verify-otp
410 Gone
"OTP verification is no longer supported. Authentication is handled via BankID."
Cookie Security Settings
Cookie Configuration
Source: routes/auth.ts:195, 206
Property
Value
Purpose
Source
HttpOnly
true
Prevents JavaScript access — mitigates XSS token theft
auth.ts , cookie string
Secure
true (production)
Cookie only sent over HTTPS
Implied by deployment (Cloudflare enforces HTTPS)
SameSite
Lax
Prevents CSRF — cookie not sent on cross-origin POST requests
routes/auth.ts:206
Path
/
Cookie available to all routes
routes/auth.ts:206
Max-Age
604800 (7 days)
Session lifetime matching JWT expiry
routes/auth.ts:206
Domain
Not set (defaults to current domain)
Scoped to getdrop.no in production
Default browser behavior
Cookie Lifecycle
Event
Cookie Action
Source
BankID callback (web)
Set drop_token= with full security attributes
BFF redirect handler
Token refresh
Set new drop_token= , same attributes
routes/auth.ts:206
Logout
Clear cookie: drop_token=; Max-Age=0
routes/auth.ts:195
SameSite=Lax Behavior
Request Type
Cookie Sent?
Reason
Same-origin GET
Yes
Normal navigation
Same-origin POST
Yes
Form submissions
Cross-origin GET (top-level navigation)
Yes
Allows BankID redirect back
Cross-origin POST
No
CSRF protection
Cross-origin AJAX/fetch
No
CSRF protection
Subdomain requests
Depends on Domain setting
No Domain set = strict origin match
Web vs Mobile Token Strategy
Aspect
Web (Next.js)
Mobile (Expo)
Token delivery
httpOnly cookie ( drop_token )
JSON body ( { token } )
Token storage
Browser cookie jar (managed by browser)
AsyncStorage (React Native encrypted storage)
Token transmission
Automatic via Cookie header
Manual via Authorization: Bearer header
CSRF protection
SameSite=Lax + CORS origin validation
Not needed (no cookies, Bearer token)
Token extraction
auth.ts:96-99 — parse from cookie header
auth.ts:93-94 — extract from Authorization header
Cross-References
BankID OIDC flow: AUTHENTICATION.md — Full BankID authentication sequence
Auth source: src/drop-api/src/lib/auth.ts — JWT signing, verification, session management
Auth routes: src/drop-api/src/routes/auth.ts — Endpoint handlers
Auth middleware: src/drop-api/src/middleware/auth.ts — Request authentication
Rate limiter: src/drop-api/src/middleware/rate-limit.ts — IP-based rate limiting
BankID library: src/drop-api/src/lib/bankid.ts — OIDC flow, pid parsing, user creation
Security architecture: SECURITY-ARCHITECTURE.md — Cookie settings, JWT configuration
API reference: API-REFERENCE.md — Full endpoint documentation
Database schema: DATABASE-SCHEMA.md — sessions , users , audit_log tables
Registration & Onboarding Flow
Registration & Onboarding Flow -- Low-Level Design
Document: LLD-REGISTRATION
Status: Approved
Last updated: 2026-02-21
Author: Standards Architect
Applies to: Drop v1.0 (PSD2 pass-through model)
User requirements: Minimum age 18, Norwegian residency, valid BankID (from vilkar.html)
Overview
Drop's registration is simplified by using BankID as the sole authentication provider. There is no separate registration form -- user accounts are created automatically on first BankID login. The onboarding flow then guides the user through consent collection, KYC verification, and first bank account linking.
Key principle: Progressive disclosure. Users see only what they need at each step. Heavy verification happens in the background while the user explores the app.
Complete Registration Flow
sequenceDiagram
participant User
participant App as Drop App
participant BFF as Next.js BFF / Hono API
participant BankID as BankID OIDC
participant Sumsub
participant Bank as Nordic Bank (Open Banking)
participant DB as PostgreSQL
Note over User,DB: Step 1 -- BankID Authentication + Auto-Registration
User->>App: Tap "Logg inn med BankID"
App->>BFF: GET /api/auth/bankid (or /v1/auth/bankid/initiate)
BFF->>BFF: Generate state + nonce
BFF->>BFF: Rate limit check (10/min per IP)
BFF->>App: { redirectUrl }
App->>BankID: Open BankID authorize URL
User->>BankID: Authenticate (BankID app/code device)
Note over User,BankID: SCA: possession (device) + knowledge (PIN)
BankID->>App: Redirect with ?code=&state=
App->>BFF: GET /callback?code=&state= (or POST /callback)
BFF->>BFF: Verify state vs cookie/session
BFF->>BankID: POST /token (exchange code)
BankID->>BFF: { id_token, access_token }
BFF->>BFF: Verify ID token signature (JWKS)
BFF->>BFF: Extract pid (fodselsnummer, 11 digits)
BFF->>BFF: Parse DOB from pid
BFF->>BFF: Verify age >= 18
alt Age < 18
BFF->>App: Error: "Du ma vaere minst 18 ar"
App->>User: Show age restriction message
else Age >= 18
BFF->>BFF: SHA-256 hash pid -> national_id_hash
BFF->>DB: SELECT user WHERE national_id_hash = ?
alt Existing user
BFF->>DB: Create session + JWT
BFF->>App: Set cookie / return Bearer token
App->>User: Redirect to /dashboard
else New user (first login)
BFF->>DB: INSERT user (kyc_status='approved', kyc_method='bankid', auth_provider='bankid', password_hash='EIDONLY')
BFF->>DB: Create session + JWT
BFF->>App: Set cookie / return Bearer token
App->>User: Redirect to /onboarding
end
end
Note over User,DB: Step 2 -- Consent Collection (Onboarding Screen 1)
App->>User: Show consent checkboxes
User->>App: Accept terms + privacy + data processing
App->>BFF: POST /api/consents (type: 'terms', granted: true)
BFF->>DB: INSERT consents (terms, ip_address, granted_at)
App->>BFF: POST /api/consents (type: 'privacy', granted: true)
BFF->>DB: INSERT consents (privacy, ip_address, granted_at)
App->>User: Optional: marketing consent checkbox
Note over User,App: Marketing consent is OPTIONAL per GDPR
Note over User,DB: Step 3 -- KYC Trigger (Background)
BFF->>Sumsub: Create applicant (name, DOB from BankID)
Sumsub->>BFF: applicant_id
BFF->>DB: Store applicant_id
Sumsub->>Sumsub: PEP + sanctions screening
Sumsub->>BFF: Webhook: screening results
BFF->>DB: INSERT screening_results
Note over User,DB: Step 4 -- Bank Account Linking (Onboarding Screen 2)
App->>User: "Koble til bankkonto"
User->>App: Select bank (DNB, SpareBank1, Nordea...)
App->>BFF: POST /api/bank-accounts/link
BFF->>Bank: Open Banking: AISP consent request
Bank->>User: Authorize AISP access (SCA)
User->>Bank: Approve
Bank->>BFF: AISP access token + account list
BFF->>DB: INSERT bank_accounts (from AISP response)
BFF->>Bank: GET /accounts/{id}/balances
Bank->>BFF: { balance, currency }
BFF->>DB: UPDATE bank_accounts SET balance = ?, is_primary = 1
BFF->>App: { bankAccounts: [...] }
App->>User: Show linked account with balance
Note over User,DB: Step 5 -- Onboarding Complete
App->>User: "Velkommen til Drop!"
App->>User: Redirect to /dashboard
Onboarding States
stateDiagram-v2
[*] --> bankid_redirect : User taps "Logg inn med BankID"
bankid_redirect --> bankid_auth : BankID authorize page
bankid_auth --> age_check : BankID callback received
age_check --> rejected_underage : Age < 18
age_check --> user_lookup : Age >= 18
rejected_underage --> [*]
user_lookup --> existing_user : national_id_hash found
user_lookup --> new_user : national_id_hash not found
existing_user --> dashboard : Session created, redirect
new_user --> user_created : Auto-register from BankID data
user_created --> consent_collection : Redirect to /onboarding
consent_collection --> consents_granted : Terms + privacy accepted
consents_granted --> kyc_background : Sumsub screening starts
kyc_background --> bank_linking : Screening runs in background
bank_linking --> bank_consent : User selects bank
bank_consent --> bank_authorized : AISP consent granted
bank_authorized --> account_linked : Balance fetched
account_linked --> onboarding_complete : First bank account linked
onboarding_complete --> dashboard : Redirect to /dashboard
state kyc_background {
[*] --> sumsub_pending
sumsub_pending --> screening
screening --> kyc_approved : All clear
screening --> kyc_review : Match found
kyc_review --> kyc_approved : Cleared by compliance
kyc_review --> kyc_rejected : Confirmed risk
}
dashboard --> [*]
Age Verification (18+)
Norwegian fodselsnummer (11-digit personal identification number) encodes the date of birth:
Digits
Meaning
Example
1-2
Day of birth (DD)
15
3-4
Month of birth (MM)
03
5-6
Year of birth (YY)
95
7-9
Individual number
123
10-11
Check digits
45
Century determination (from individual number, digits 7-9):
000-499: born 1900-1999
500-749: born 1854-1899 (historical) or 2000-2039
750-899: born 1854-1899 (historical)
900-999: born 1940-1999
Verification logic:
1. Extract DD, MM, YY from pid[0..5]
2. Determine century from pid[6..8]
3. Construct full birthdate
4. Calculate age = today - birthdate
5. If age < 18: reject with "Du ma vaere minst 18 ar for a bruke Drop"
Source: vilkar.html section 3 -- "Du ma vaere minst 18 ar og bosatt i Norge for a bruke Drop."
Norwegian Residency Check
BankID issuance inherently confirms Norwegian residency:
BankID is issued only by Norwegian banks to their customers
Norwegian bank accounts require Norwegian national ID (fodselsnummer or D-number)
D-numbers are issued to foreign nationals with legitimate ties to Norway
Additional signals:
Phone number prefix: +47 (Norwegian)
BankID issuer: Norwegian bank (from ID token amr claim)
Signal
Check
Enforcement
BankID ownership
Implicit -- only Norwegian residents have BankID
At authentication
Phone number
+47 prefix
Optional validation at profile update
Postal address
Norwegian postal code
At bank account linking (from AISP data)
Consent Collection
Required Consents
Per GDPR Article 6(1)(a) and Article 7, explicit consent must be collected before processing personal data. The following consents are collected during onboarding:
Consent Type
Required
Legal Basis
Description
Withdrawable
terms
Yes (mandatory)
Contract (Art. 6(1)(b))
Terms of service acceptance
Account deletion required
privacy
Yes (mandatory)
Consent (Art. 6(1)(a))
Privacy policy acknowledgment
Account deletion required
data_processing
Yes (mandatory)
Consent (Art. 6(1)(a))
PSD2 AISP/PISP data processing consent
Revokes bank access
marketing
No (optional)
Consent (Art. 6(1)(a))
Marketing communications
Yes, at any time
cookies_analytics
No (optional)
Consent (Art. 6(1)(a))
Analytics cookies
Yes, at any time
cookies_marketing
No (optional)
Consent (Art. 6(1)(a))
Marketing/tracking cookies
Yes, at any time
Consent Checklist
#
Consent
Checkbox Text (Norwegian)
Default
Validation
1
Terms of service
"Jeg godtar Drop sine brukervilkar"
Unchecked
Must be checked to proceed
2
Privacy policy
"Jeg har lest og godtar personvernerklaringen"
Unchecked
Must be checked to proceed
3
PSD2 data access
"Jeg godtar at Drop leser kontoinformasjon og initierer betalinger via Open Banking"
Unchecked
Must be checked to proceed
4
Marketing
"Jeg onsker a motta nyheter og tilbud fra Drop"
Unchecked
Optional
Consent Storage
Each consent is stored in the consents table:
Column
Value
Purpose
id
con_
Unique consent record ID
user_id
usr_
References the user
consent_type
terms , privacy , marketing , etc.
Type of consent
granted
1 (true) or 0 (false)
Current consent state
granted_at
ISO timestamp
When consent was granted
withdrawn_at
ISO timestamp or NULL
When consent was withdrawn
ip_address
Client IP
Proof of consent action (GDPR Art. 7(1))
Consent API
Endpoint
Method
Purpose
GET /api/consents
GET
List all user consents
POST /api/consents
POST
Grant or withdraw consent
Consent withdrawal flow:
User navigates to Settings > Privacy
Toggles marketing consent off
POST /api/consents with { consentType: "marketing", granted: false }
Record updated: granted = 0 , withdrawn_at = now()
User can re-grant at any time
First Bank Account Linking
After consent collection, the user links their first bank account via AISP:
sequenceDiagram
participant User
participant App as Drop App
participant BFF as Drop API
participant Bank as Nordic Bank
User->>App: Select bank from list (DNB, SpareBank1, etc.)
App->>BFF: POST /api/bank-accounts/link { bankId: "dnb" }
BFF->>Bank: Open Banking: GET /authorize (AISP scope)
Bank->>User: Show consent screen (SCA required)
Note over User,Bank: "Drop vil lese kontosaldo og transaksjonshistorikk"
User->>Bank: Approve (BankID in banking app)
Bank->>BFF: Authorization code
BFF->>Bank: POST /token (exchange code)
Bank->>BFF: AISP access token (90-day validity)
BFF->>Bank: GET /accounts (list user accounts)
Bank->>BFF: [{ accountId, iban, name, type }]
BFF->>Bank: GET /accounts/{id}/balances
Bank->>BFF: { balance: 45230.00, currency: "NOK" }
BFF->>BFF: INSERT bank_accounts
BFF->>BFF: Set first account as is_primary = 1
BFF->>App: { bankAccounts: [{ bankName: "DNB", balance: 45230, isPrimary: true }] }
App->>User: "DNB koblet! Saldo: 45 230 kr"
Progressive Disclosure UX
The onboarding flow uses progressive disclosure to minimize upfront friction:
Step
Screen
User Action
Background Action
1
BankID login
Tap "Logg inn med BankID"
Auto-create account from BankID data
2
Consent
Check 3 mandatory boxes + optional marketing
Record consents with IP + timestamp
3
Link bank
Select bank, approve AISP
Sumsub KYC screening runs in parallel
4
Dashboard
Explore the app
KYC result webhook updates user status
Time to first value: ~2 minutes (BankID auth + consents + bank linking)
Deferred actions (not required during onboarding):
Add remittance recipients (done when user first sends money)
Merchant registration (done from Settings if user is a business)
Notification preferences (defaults: push enabled, email enabled)
Profile completion (BankID provides name and DOB; address is optional)
Error Handling
Error
Screen
User Message
Action
BankID timeout
Login
"BankID-tidsavbrudd. Prov igjen."
Retry button
Age < 18
Login
"Du ma vaere minst 18 ar for a bruke Drop."
No retry -- explain policy
BankID state mismatch
Login
"Noe gikk galt. Prov igjen."
Redirect to login
Consent not accepted
Onboarding
Cannot proceed without mandatory consents
Highlight unchecked boxes
Bank linking failed
Onboarding
"Kunne ikke koble banken. Prov igjen."
Retry or skip (link later)
Sumsub KYC rejected
Background
"Identitetsbekreftelse mislyktes. Kontakt oss."
Account limited until resolved
Cross-References
KYC/AML Flow -- Detailed KYC verification and AML monitoring
Login Authentication Flow -- BankID login implementation details
Bank Account Linking Flow -- AISP integration details
Authentication System -- JWT, sessions, BankID OIDC
BankID OIDC Integration -- BankID technical specification
API Reference -- Consent and auth endpoints
Database Schema -- users, consents, bank_accounts tables
ADR-007: BankID OIDC Auth -- Authentication provider decision
ADR-003: PSD2 Pass-through -- Pass-through model context
KYC & AML Flow
KYC/AML Flow -- Low-Level Design
Document: LLD-KYC-AML
Status: Approved
Last updated: 2026-02-21
Author: Standards Architect
Applies to: Drop v1.0 (PSD2 pass-through model)
Regulatory basis: Hvitvaskingsloven (LOV-2018-06-01-23), GDPR (Personopplysningsloven)
Overview
Drop's KYC (Know Your Customer) and AML (Anti-Money Laundering) system ensures compliance with Norwegian anti-money laundering law (hvitvaskingsloven). The system has three phases:
Onboarding KYC -- Identity verification at registration via BankID + Sumsub document verification
Transaction monitoring -- Real-time and periodic analysis of transaction patterns
Ongoing due diligence -- Periodic re-screening of PEP/sanctions lists and adverse media
Drop uses a risk-based approach per hvitvaskingsloven section 4-6: higher-risk customers and transactions receive enhanced scrutiny.
KYC Verification Flow
sequenceDiagram
participant User
participant Drop as Drop API
participant BankID
participant Sumsub
participant DB as PostgreSQL
Note over User,DB: Phase 1 -- BankID Identity Verification
User->>Drop: Login via BankID OIDC
Drop->>BankID: Exchange auth code for ID token
BankID->>Drop: ID token (pid, name, DOB)
Drop->>Drop: Parse pid (fodselsnummer)
Drop->>Drop: Verify age >= 18
Drop->>Drop: Hash pid with SHA-256
Drop->>DB: Find/create user (national_id_hash)
Drop->>DB: Set kyc_status = 'approved', kyc_method = 'bankid'
Note over User,DB: Phase 2 -- Sumsub Enhanced Verification
Drop->>Sumsub: Create applicant (user_id, name, DOB)
Sumsub->>Drop: applicant_id
Drop->>DB: Store applicant_id in users table
Drop->>User: Request document upload (if EDD triggered)
alt Standard CDD (low risk)
Note over User,Sumsub: BankID sufficient -- no document upload
Sumsub->>Sumsub: Auto-approve based on BankID data
else Enhanced CDD (high risk)
User->>Sumsub: Upload ID document + selfie
Sumsub->>Sumsub: Document verification + liveness check
end
Note over User,DB: Phase 3 -- PEP/Sanctions Screening
Sumsub->>Sumsub: Screen against PEP lists
Sumsub->>Sumsub: Screen against sanctions (OFAC, UN, EU, Norway)
Sumsub->>Sumsub: Screen adverse media
Sumsub->>Drop: Webhook: verification result
Drop->>DB: Update kyc_status (approved/rejected)
Drop->>DB: Insert screening_results (pep, sanctions, adverse_media)
Drop->>DB: Update users.risk_level, pep_status, sanctions_cleared
alt Screening match found
Drop->>DB: Create aml_alert (severity based on match type)
Drop->>Drop: Block user from transactions
end
KYC Applicant States
stateDiagram-v2
[*] --> bankid_verified : BankID login successful
bankid_verified --> sumsub_pending : Sumsub applicant created
sumsub_pending --> document_requested : EDD required (high risk)
sumsub_pending --> screening : CDD sufficient (low risk)
document_requested --> document_uploaded : User uploads ID + selfie
document_uploaded --> document_review : Sumsub processes documents
document_review --> screening : Documents verified
document_review --> document_rejected : Documents invalid
document_rejected --> document_requested : User retries
screening --> approved : All clear (PEP, sanctions, media)
screening --> manual_review : Potential match found
manual_review --> approved : Compliance officer clears
manual_review --> rejected : Confirmed match or fraud
approved --> ongoing_monitoring : Periodic re-screening
ongoing_monitoring --> manual_review : Re-screening match
ongoing_monitoring --> approved : Re-screening clear
rejected --> [*]
Document Verification Steps
When Enhanced Due Diligence (EDD) is triggered, Sumsub performs multi-step document verification:
Step
Check
Provider
Pass Criteria
1. Document quality
Image clarity, glare, blur
Sumsub AI
Readable text, clear photo
2. Document authenticity
Hologram detection, font analysis, template matching
Sumsub AI
Matches known document templates
3. Data extraction
OCR: name, DOB, document number, expiry
Sumsub OCR
All fields extracted successfully
4. Cross-reference
Extracted data vs BankID data (name, DOB)
Drop API
Name and DOB match within tolerance
5. Liveness check
Selfie vs document photo, anti-spoofing
Sumsub AI
Face match > 80%, liveness confirmed
6. Expiry check
Document expiration date
Sumsub
Document not expired
Accepted documents (Norway-specific):
Norwegian passport (preferred)
Norwegian national ID card
Norwegian driver's license (with photo)
EEA passport or national ID card (for EEA residents)
PEP/Sanctions Screening
Screening Sources
List
Source
Update Frequency
Scope
Norwegian PEP list
Finanstilsynet
Real-time
Norwegian politically exposed persons
OFAC SDN
US Treasury
Daily
Global sanctions
UN Consolidated
UN Security Council
Real-time
Global sanctions
EU Consolidated
European Commission
Daily
EU sanctions
Norwegian sanctions
Utenriksdepartementet
As published
Norway-specific restrictions
Adverse media
Sumsub media monitoring
Continuous
Negative news, legal proceedings
Screening Triggers
Trigger
Type
Screening Scope
New user registration
Initial CDD
Full: PEP + sanctions + adverse media
Transaction > 10,000 NOK
Transaction monitoring
Sanctions only (real-time)
Cumulative 30-day > 50,000 NOK
Periodic monitoring
Full re-screening
Quarterly schedule
Ongoing due diligence
Full re-screening of all active users
User data change
Event-driven
Full re-screening
Database: screening_results table
Column
Type
Values
screening_type
TEXT
pep , sanctions , adverse_media
provider
TEXT
sumsub (or future: refinitiv , dow_jones )
result
TEXT
clear , match , potential_match , error
match_details
TEXT (JSON)
Match metadata (name similarity, list source, entry details)
Risk Scoring Algorithm
Drop assigns a risk level to each user based on multiple factors. The risk score determines the level of due diligence applied.
Risk Factor Matrix
Factor
Low Risk (1 pt)
Medium Risk (3 pts)
High Risk (5 pts)
Country of origin
Norway, Sweden, Denmark, Finland
EU/EEA countries
Non-EEA, high-risk jurisdictions (FATF grey/black list)
Remittance corridor
SEPA (intra-EEA)
Non-EEA low-risk
Pakistan, Turkey, non-EEA high-risk
Transaction volume (30-day)
< 10,000 NOK
10,000-50,000 NOK
> 50,000 NOK
Transaction frequency (30-day)
< 5 transactions
5-20 transactions
> 20 transactions
PEP status
Not PEP
PEP family member
PEP (direct)
Sanctions screening
Clear
Potential match (resolved)
Active match
Account age
> 12 months
3-12 months
< 3 months
Adverse media
None
Resolved/historical
Active negative coverage
Risk Level Classification
Total Score
Risk Level
Due Diligence
Monitoring
Transaction Limits
8-12
Low
Standard CDD (BankID only)
Quarterly re-screening
50,000 NOK/month
13-20
Medium
Enhanced CDD (BankID + document)
Monthly re-screening
25,000 NOK/month
21-30
High
Enhanced CDD + source of funds
Weekly re-screening
10,000 NOK/month
31+
Prohibited
Account blocked
Continuous
0 (blocked)
Database: users risk fields
Column
Type
Purpose
risk_level
TEXT
low , medium , high , prohibited
pep_status
TEXT
none , pep_family , pep_direct
sanctions_cleared
INTEGER
0 = not cleared, 1 = cleared
AML Transaction Monitoring
Alert Rules
Rule ID
Alert Type
Trigger
Severity
Description
AML-001
structuring
Multiple transactions just below 10,000 NOK threshold
high
Potential structuring to avoid reporting
AML-002
velocity
> 5 transactions in 1 hour
medium
Unusual transaction velocity
AML-003
high_value
Single transaction > 25,000 NOK
medium
Large transaction review
AML-004
cumulative
30-day total > 50,000 NOK
high
Cumulative volume threshold
AML-005
corridor_risk
Transfer to FATF grey/black list country
high
High-risk corridor
AML-006
new_account_high_value
Account < 30 days + transaction > 5,000 NOK
medium
New account with large transfer
AML-007
round_amounts
Multiple transactions with round amounts (1000, 5000, 10000)
low
Potential structuring pattern
AML-008
rapid_recipient_add
> 3 new recipients in 24 hours
medium
Unusual recipient creation pattern
Database: aml_alerts table
Column
Type
Values
alert_type
TEXT
structuring , velocity , high_value , cumulative , corridor_risk , etc.
severity
TEXT
low , medium , high , critical
status
TEXT
open , investigating , resolved , escalated , filed
reviewed_by
TEXT
Compliance officer identifier
Alert Lifecycle
stateDiagram-v2
[*] --> open : Rule triggered
open --> investigating : Compliance officer reviews
investigating --> resolved : False positive confirmed
investigating --> escalated : Suspicious activity confirmed
escalated --> filed : STR filed with Okokrim
resolved --> [*]
filed --> [*]
note right of escalated
Auto-block user transactions
until STR processed
end note
SAR/STR Filing
When an AML alert is escalated, a Suspicious Transaction Report (STR) is filed with Okokrim/EFE (Norwegian Financial Intelligence Unit) per hvitvaskingsloven section 4-26.
STR Filing Trigger Conditions
Condition
Action
Timeline
AML alert severity = critical
Automatic STR draft + compliance notification
Immediately
AML alert escalated to filed
STR submitted to Okokrim
Within 24 hours of escalation
Compliance officer judgment
Manual STR creation
As determined
User account matches sanctions list
Immediate freeze + STR
Immediately
Database: str_reports table
Column
Type
Values
report_type
TEXT
suspicious_transaction , sanctions_match , terrorism_financing
status
TEXT
draft , submitted , acknowledged
filed_at
TEXT
Timestamp of submission to Okokrim
reference_number
TEXT
Okokrim reference (returned on submission)
STR Content (per hvitvaskingsloven section 4-26)
Field
Source
Description
Reporter details
Drop company info
ALAI Holding AS, org number, contact
Subject details
users table
Name, DOB, national_id_hash, address
Transaction details
transactions table
Amount, currency, date, recipient, corridor
Suspicious indicators
aml_alerts table
Alert type, pattern description, severity
Supporting evidence
audit_log table
Login history, transaction history, behavioral anomalies
Reporter assessment
Compliance officer
Narrative summary of suspicion basis
Ongoing Monitoring Schedule
Activity
Frequency
Scope
Automated
PEP/sanctions re-screening
Quarterly (low risk), Monthly (medium), Weekly (high)
All active users at applicable risk level
Yes (Sumsub batch API)
Transaction pattern analysis
Real-time
All transactions
Yes (AML rule engine)
Cumulative volume check
Daily
All users with transactions in last 30 days
Yes (scheduled job)
High-risk corridor review
Weekly
Users with transfers to FATF grey/black list countries
Yes (automated report)
Dormant account review
Monthly
Accounts with no activity for 6+ months then sudden activity
Yes (scheduled job)
Full compliance audit
Annually
All users, all transactions, all alerts
Manual + automated
GDPR Data Minimization for KYC Data
Per GDPR Article 5(1)(c) and Article 25 (data protection by design), KYC data collection and retention must be minimized.
Data Retention Table
Data Category
Data Elements
Retention Period
Legal Basis
Deletion Method
Identity (BankID)
national_id_hash, name, DOB
5 years after account closure
Hvitvaskingsloven s. 4-18 (AML record keeping)
Hard delete after retention
KYC documents
Passport/ID images, selfie
5 years after account closure
Hvitvaskingsloven s. 4-18
Sumsub retention + local reference deletion
Screening results
PEP/sanctions/media results
5 years after last screening
Hvitvaskingsloven s. 4-18
Hard delete after retention
AML alerts
Alert details, investigation notes
5 years after alert closure
Hvitvaskingsloven s. 4-18
Hard delete after retention
STR reports
Filed reports, evidence packages
10 years after filing
Hvitvaskingsloven s. 4-19 (STR records)
Hard delete after retention
Transaction records
Amount, currency, parties, timestamps
5 years after transaction
Bokforingsloven (accounting law)
Hard delete after retention
Consent records
Consent type, granted/withdrawn timestamps
Duration of relationship + 3 years
GDPR Art. 7(1) (proof of consent)
Hard delete after retention
Audit logs
User actions, IP addresses, user agents
2 years
Legitimate interest (security)
Hard delete after retention
Data Minimization Controls
Control
Implementation
GDPR Article
Collect only necessary data
BankID provides verified name + DOB; no separate address collection until needed
Art. 5(1)(c)
Purpose limitation
KYC data used only for AML compliance, not marketing
Art. 5(1)(b)
Storage limitation
Automated retention policies with scheduled deletion jobs
Art. 5(1)(e)
Pseudonymization
National ID stored as SHA-256 hash, not plaintext
Art. 25
Access control
KYC data accessible only to compliance role
Art. 25
Right to erasure
Soft delete (set deleted_at ) but retain AML-required data for legal period
Art. 17(3)(b)
Data portability
GET /api/user/data-export exports all personal data as JSON
Art. 20
Conflict: GDPR Erasure vs AML Retention
When a user requests account deletion ( DELETE /api/user/account ):
User record is soft-deleted ( deleted_at timestamp set)
Active sessions are revoked
A data_access_request record is created (type: erasure , status: completed )
AML-required data is RETAINED for 5 years per hvitvaskingsloven s. 4-18
Response includes: "retentionNote": "Data retained for 5 years per AML requirements"
This is legally permitted under GDPR Article 17(3)(b): "compliance with a legal obligation which requires processing by Union or Member State law."
Cross-References
Registration & Onboarding Flow -- User registration with KYC trigger
System Context (C4 Level 1) -- Sumsub and Okokrim external actors
Sumsub KYC Integration -- Technical integration specification
Database Schema -- Compliance tables (aml_alerts, str_reports, screening_results, consents)
Compliance Status -- Current compliance readiness
ADR-003: PSD2 Pass-through -- Regulatory context
Data Lifecycle -- Full data retention and deletion policies
Bank Account Linking Flow
Low-Level Design: Bank Account Linking Flow
Version: 1.0
Date: 2026-02-21
Author: Banking Architecture Team
Status: Approved
Applies to: Drop — AISP Consent & Bank Account Linking
1. Overview
Bank account linking is the process where a Drop user connects their bank account via Open Banking (PSD2 AISP). This enables Drop to:
Read the user's bank account balance (displayed on Dashboard)
Verify sufficient funds before initiating PISP payments
Display linked accounts in the Bank Accounts screen ( /accounts )
The linking flow requires AISP consent from the user's bank (ASPSP), authenticated via BankID SCA at the bank. Drop stores the consent reference and caches the balance in the bank_accounts table.
Key principle: Drop never holds money. The bank_accounts.balance column is a cached read from the user's real bank account via AISP.
2. Complete Bank Linking Flow
sequenceDiagram
participant U as User
participant UI as Drop UI (/accounts)
participant API as Drop API
participant DB as Drop DB
participant ASPSP as User's Bank (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 (DNB, SpareBank 1, Nordea, ...)
U->>UI: Select "DNB"
Note over U,ASPSP: Step 2: AISP Consent Request
UI->>API: POST /api/accounts/link {bankId: "dnb"}
API->>API: Verify user authenticated (JWT from drop_token cookie)
API->>ASPSP: POST /v1/consents {access: {balances: ["allAccounts"], transactions: ["allAccounts"]}, recurringIndicator: true, validUntil: "2026-05-22", frequencyPerDay: 4, combinedServiceIndicator: false}
ASPSP-->>API: 201 Created {consentId: "cons_abc123", consentStatus: "received", _links: {scaRedirect: "https://dnb.no/psd2/consent/authorize?id=..."}}
API->>DB: INSERT INTO consents (consent_type: 'psd2_aisp', 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 "Drop requests access to your account balances and transactions"
U->>ASPSP: Authenticate with BankID (possession + knowledge/inherence)
ASPSP-->>U: Consent granted Redirect to Drop callback
Note over U,ASPSP: Step 4: Callback & Account Retrieval
U->>API: GET /api/accounts/link/callback ?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, granted_at = now
API->>ASPSP: GET /v1/accounts (with consentId header)
ASPSP-->>API: {accounts: [ {resourceId: "acc_1", iban: "NO1234567890123", currency: "NOK", name: "Brukskonto"}, {resourceId: "acc_2", iban: "NO9876543210987", 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", balanceAmount: {currency: "NOK", amount: "45230.00"}}]}
API->>DB: INSERT INTO bank_accounts (bank_name: "DNB", account_number: "NO12...0123", iban: "NO1234567890123", balance: 4523000, balance_synced_at: now, is_primary: first ? 1 : 0)
end
API-->>UI: {success: true, accounts: [{bankName: "DNB", balance: 45230.00, currency: "NOK"}]}
UI-->>U: "DNB koblet til!" (DNB linked!) Show accounts with balances
3. Linked Account States
stateDiagram-v2
[*] --> Unlinked: User has no linked bank accounts
Unlinked --> ConsentRequested: User taps "Koble til bank" POST /v1/consents to ASPSP
ConsentRequested --> ScaPending: ASPSP returns scaRedirect
ScaPending --> Active: User completes BankID SCA consentStatus = "valid"
ScaPending --> Failed: SCA timeout (5 min)
ScaPending --> Failed: User cancels BankID
ScaPending --> Failed: Bank rejects consent
Active --> Active: Balance refresh (on-demand or scheduled, max 4x/day TPP-initiated)
Active --> SyncError: ASPSP returns error on balance read Show last cached balance
SyncError --> Active: Next successful balance read
Active --> ConsentExpiring: 30 days before consent expiry Notify user to renew
ConsentExpiring --> RenewalPending: User taps "Forny tilgang" (Renew access)
RenewalPending --> ScaPending: New consent + SCA
ConsentExpiring --> Expired: User ignores renewal
Active --> Unlinked: User taps "Fjern konto" (Remove account) DELETE /v1/consents/{id}
Active --> Suspended: ASPSP revokes consent
Expired --> Unlinked: Consent expired, balance zeroed, notify user
Suspended --> Unlinked: User must re-link
Failed --> Unlinked: User can retry
Unlinked --> [*]
4. Detailed Steps
4.1 Step 1: Bank Selection
UI: Bank Accounts screen ( /accounts ) shows a "Link bank account" button. Tapping it displays a list of supported Norwegian banks.
Supported banks (initial):
Bank
Berlin Group API
Logo
DNB
https://api.dnb.no/psd2/
DNB logo asset
SpareBank 1
https://api.sparebank1.no/open-banking/
SB1 logo asset
Nordea
https://api.nordeaopenbanking.com/
Nordea logo asset
Sbanken
Via SpareBank 1 API
Sbanken logo asset
4.2 Step 2: AISP Consent Creation
Berlin Group consent request:
{
"access": {
"balances": [{"iban": "allAccounts"}],
"transactions": [{"iban": "allAccounts"}]
},
"recurringIndicator": true,
"validUntil": "2026-05-22",
"frequencyPerDay": 4,
"combinedServiceIndicator": false
}
Field
Value
Reason
balances
allAccounts
User picks which accounts to display after consent
transactions
allAccounts
Future: transaction history from bank
recurringIndicator
true
Ongoing access (not one-time)
validUntil
Now + 90 days
PSD2 maximum consent duration (RTS Art. 10)
frequencyPerDay
4
Max TPP-initiated reads per day (PSD2 RTS Art. 36(6))
combinedServiceIndicator
false
AISP consent only (PISP is separate per transaction)
4.3 Step 3: SCA at Bank
The user is redirected to their bank's consent page where they:
See what data Drop is requesting (balances, transactions)
Authenticate with BankID (SCA: 2 of 3 factors)
Approve or deny the consent
Get redirected back to Drop
4.4 Step 4: Account Retrieval
After consent is confirmed, Drop calls the ASPSP's account list endpoint to discover which accounts the user has. Then it fetches the balance for each account.
4.5 Step 5: Data Storage
Each linked account is stored in the bank_accounts table:
Column
Value
Source
id
ba_
Generated by randomId("ba")
user_id
Current user ID
From JWT
bank_name
"DNB"
From bank selection
account_number
Domestic format (masked in API)
From ASPSP account details
iban
Full IBAN
From ASPSP
balance
Balance in oere (e.g., 4523000 = 45,230 NOK)
From ASPSP balance endpoint
balance_synced_at
Current timestamp
Set on each refresh
currency
"NOK"
From ASPSP
is_primary
1 for first account, 0 for others
Auto-set
connected_at
Current timestamp
Set on linking
5. Balance Refresh Strategy
Trigger
Frequency
ASPSP Call
UI Behavior
User opens Dashboard
On demand
GET /v1/accounts/{id}/balances
Show spinner, then updated balance
User pulls to refresh
On demand
GET /v1/accounts/{id}/balances
Pull-to-refresh animation
Background sync
Every 6 hours
GET /v1/accounts/{id}/balances
Silent update
Pre-payment check
Before PISP
GET /v1/accounts/{id}/balances
Inline balance verification
User views /accounts
On demand
GET /v1/accounts/{id}/balances
Show spinner per account
PSD2 constraint: TPP-initiated requests (background sync) are limited to 4 per day per account (RTS Art. 36(6)). User-initiated requests (opening app, pull-to-refresh) are unlimited .
6. Error Handling
6.1 Error Table
Error
Stage
HTTP Status
User Message (Norwegian)
Recovery
Bank not supported
Bank selection
400
"Denne banken stottes ikke ennaa."
Show supported banks
ASPSP API unreachable
Consent creation
502
"Kunne ikke koble til banken. Prv igjen senere."
Retry after 30s
SCA timeout
SCA at bank
408
"BankID-sesjonen utlp. Prv igjen."
Restart linking flow
SCA cancelled
SCA at bank
400
"Du avbrt tilkoblingen."
Offer retry button
SCA rejected
SCA at bank
403
"Banken avviste tilgangen."
Contact bank support
Consent invalid
Callback
403
"Tilgangsforesprselen var ugyldig."
Restart flow
State mismatch
Callback
403
"Sikkerhetssjekk feilet. Prv igjen."
Restart flow
No accounts found
Account retrieval
404
"Fant ingen kontoer hos denne banken."
Verify correct bank selected
Balance read failed
Balance fetch
502
"Kunne ikke hente saldo. Viser sist kjente."
Show cached balance with timestamp
Consent expired
Any balance read
403
"Tilgangen til banken din er utlpt. Koble til paa nytt."
Re-link flow
Rate limit exceeded
Balance read
429
"For mange foresprsler. Viser sist kjente saldo."
Show cached balance
6.2 Fallback Behavior
When a balance read fails, Drop shows the last cached balance with the balance_synced_at timestamp:
DNB Brukskonto
45 230,00 kr
Sist oppdatert: 2 timer siden
If the consent is expired or revoked, the balance is zeroed and the user sees:
DNB Brukskonto
-- kr
Tilgangen er utlopt. Koble til paa nytt.
[Koble til] button
7. Consent Renewal
AISP consents have a maximum validity of 90 days (PSD2 RTS Art. 10). Drop proactively prompts renewal:
Timeline
Action
Day 0
Consent created, validUntil = Day 90
Day 60
Push notification: "Din banktilgang utlper snart. Forny tilgangen."
Day 80
In-app banner on Dashboard: "Tilgangen til DNB utlper om 10 dager."
Day 85
Push notification: "Tilgangen utlper om 5 dager. Forny naa."
Day 90
Consent expires. Balance zeroed. User must re-link.
Renewal flow: Same as initial linking — new consent + BankID SCA at bank. The old consent is deleted after the new one is active.
8. Account Unlinking
When the user taps "Fjern konto" (Remove account):
DELETE /v1/consents/{consentId} at the ASPSP (revoke consent)
DELETE FROM bank_accounts WHERE id = ? in Drop DB
UPDATE consents SET withdrawn_at = now WHERE aspsp_consent_id = ?
If the removed account was primary, promote another linked account to primary
If no accounts remain, Dashboard shows "Koble til bank" prompt
9. Multi-Bank Support
Users can link accounts from multiple banks. Each linked account has its own AISP consent with its own lifecycle.
Dashboard display:
Dine bankkontoer (Your bank accounts)
DNB Brukskonto 45 230,00 kr (primary)
DNB Sparekonto 12 800,00 kr
Nordea Brukskonto 8 450,00 kr
Totalt 66 480,00 kr
[Koble til ny bank]
Drop API: GET /api/auth/me returns totalBalance (sum of all linked account balances) and bankAccounts[] array.
10. Database Impact
10.1 Tables Affected
Table
Operation
When
bank_accounts
INSERT
Account linked
bank_accounts
UPDATE (balance, balance_synced_at)
Balance refresh
bank_accounts
DELETE
Account unlinked
consents
INSERT (consent_type: 'psd2_aisp')
Consent created
consents
UPDATE (granted, granted_at)
SCA completed
consents
UPDATE (withdrawn_at)
Consent revoked
audit_log
INSERT
Every linking/unlinking action
notifications
INSERT
Consent expiry reminders
10.2 Index Usage
Index
Used By
idx_bank_accounts_user on user_id
All account queries (scoped to user)
idx_consents_user on user_id
Consent lookups for renewal checks
11. Cross-References
Open Banking AISP/PISP: ../integration/open-banking-aisp-pisp.md — Berlin Group API details, consent properties
BankID OIDC: ../integration/bankid-oidc-integration.md — Drop authentication (separate from bank SCA)
Security Architecture: ../hld/security-architecture.md — Trust boundaries, data classification
Remittance Flow: flow-remittance.md — Uses linked bank account for PISP
Database Schema: ../../backend/DATABASE-SCHEMA.md — bank_accounts , consents tables
API Reference: ../../backend/API-REFERENCE.md — GET /api/auth/me returns bank accounts
QR Payment Flow
Flow: QR Payment
Document: LLD-002
Version: 1.0
Date: 2026-02-21
Author: Frontend Architect (AI Agent)
Status: Draft
Scope: End-to-end QR payment flow covering camera handling, merchant resolution, payment confirmation, SCA, and error states for both web and mobile
1. Overview
QR payments allow Drop users to pay in-store by scanning a merchant's QR code. The payment is initiated via PISP (Payment Initiation Service Provider) directly from the user's bank account under the PSD2 pass-through model. Drop never holds customer funds.
QR Code Format: drop://pay/{merchantId}
Payment Fee: 1% of transaction amount (fee is deducted from user's balance: totalOre = amountOre + feeOre . Settlement to merchant is separate.)
Note: Database stores amounts in ore (minor units). API accepts NOK and converts internally using nokToOre() . Example: 129 NOK = 12900 ore in DB.
Flow summary: Camera permission → QR scan → decode merchant ID → fetch merchant details → amount entry → SCA trigger → payment confirmation → receipt display
2. QR Payment Sequence Diagram
sequenceDiagram
actor User
participant App as Drop App (Web/Mobile)
participant Camera as Camera API (Browser/Native)
participant API as Drop API (/api/transactions)
participant Bank as User's Bank (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 with scan frame brackets
else Camera denied / unavailable
App->>App: Show "Simuler skanning" button (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 (amount, fee, total, source account)
User->>App: Tap "Betal nå" (confirm)
App->>API: POST /api/transactions/qr-payment { 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 payment from user's bank. 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 (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:
Web: Shows "Simuler skanning" button that triggers a demo merchant scan (Ahmetov Kebab, merchant_001)
Mobile: Shows camera placeholder (gray box with QR icon) + "Simuler skanning" button + nearby merchants list (hardcoded: Ahmetov Kebab, Kafe Oslo, Narvesen)
5. QR Code Format and Validation
Field
Value
Validation
URI scheme
drop://
Must match exactly
Path
pay/{merchantId}
Must start with pay/
Merchant ID format
mer_ prefix + flexible identifier
No strict regex enforced (e.g., mer_demo1 is valid)
Example
drop://pay/mer_demo1
Validated by DB lookup
HMAC verification: QR codes may optionally include timestamp and signature parameters for HMAC-SHA256 verification using the merchant's qr_hmac_key . Verification is performed only when both qrTimestamp and qrSignature are present in the request. If omitted, the payment proceeds without cryptographic QR verification.
Invalid QR handling:
Non-Drop QR codes: Show "Ugyldig QR-kode. Vennligst skann en Drop-butikks QR-kode."
Malformed merchant ID: Show "Ugyldig betalingskode."
Empty scan result: Continue scanning (do not trigger error)
6. Error States
Error
HTTP Status
Cause
User-Facing Message (Norwegian)
Recovery
Invalid QR
N/A (client)
Not a drop://pay/ URI
"Ugyldig QR-kode. Skann en Drop-butikks QR-kode."
Retry scan
Merchant Not Found
404
Merchant ID not in database
"Butikken ble ikke funnet. QR-koden kan være utdatert."
Scan different QR
Insufficient Funds
402
Bank balance < amount + fee
"Ikke nok penger på kontoen. Saldo: {balance} NOK."
Reduce amount or top up bank
No Bank Account
400
No linked bank account
"Ingen bankkonto koblet. Koble en konto først."
Navigate to /accounts
Rate Limited
429
>10 payments/min
"For mange betalinger. Vent litt."
Wait and retry
Network Error
N/A
No connectivity
"Ingen nettverkstilkobling."
Retry when online
Server Error
500
Internal error
"Noe gikk galt. Prøv igjen."
Retry
Camera Error
N/A
Camera hardware failure
"Kameraet fungerer ikke. Bruk 'Simuler skanning'."
Use simulation mode
7. UI Components
7.1 Web — Scan Page ( /scan )
State
UI Elements
Components Used
Scanning
Dark background (#0F172A), camera viewfinder with gold corner brackets (#D4A017), instruction text, "Simuler skanning" button, BankID/Vipps badges
BottomNav, Button, ArrowLeft/Camera (lucide)
Payment
White background, merchant icon (gold gradient circle), merchant name (Fraunces font), amount display (4xl), source account info, "Betal nå" button (green), "Avbryt" button
BottomNav, Button, ChevronLeft (lucide)
Paying
Loading spinner overlay
Spinner component
Success
Checkmark icon (green), transaction details, merchant name, amount, fee
BottomNav, Check/Store (lucide)
7.2 Mobile — Scan Screen ( (tabs)/scan.js )
State
UI Elements
Scanning
Gray camera placeholder, QR icon, "Skann QR-kode" text, "Simuler skanning" button, nearby merchants list
Payment
Merchant info, amount input, confirm button
Success
Confirmation with "Tilbake til hjem" button
7.3 Figma Reference
Source of truth: mockups/figma-make-export/src/app/screens/ScanQR.tsx
Dark scanning mode with gold bracket viewfinder
Payment confirmation with merchant details and amount
Green "Betal nå" CTA button
8. Data Flow
8.1 Request: POST /api/transactions/qr-payment
{
"merchantId": "mer_a1b2c3d4e5f6g7h8",
"amount": 129
}
8.2 Response: 201 Created
{
"data": {
"id": "tx_qr_a1b2c3d4e5f6g7h8",
"type": "qr_payment",
"status": "completed",
"amount": 129,
"currency": "NOK",
"fee": 1.29,
"feePercent": 1,
"merchantName": "Ahmetov Kebab",
"merchantId": "mer_1",
"fromAccount": "DNB",
"createdAt": "2026-02-21T14:30:00.000Z"
}
}
8.3 Database Operations (Atomic Transaction)
BEGIN;
UPDATE bank_accounts SET balance = balance - 130.29 WHERE id = ? AND user_id = ?;
INSERT INTO transactions (id, user_id, type, status, amount, currency, fee, merchant_id, created_at, completed_at)
VALUES (?, ?, 'qr_payment', 'completed', 129, 'NOK', 1.29, ?, datetime('now'), datetime('now'));
COMMIT;
9. Production vs Demo Differences
Aspect
Demo (Current)
Production (Phase 2+)
Camera
Simulated scan button
Real camera scanning via expo-camera or getUserMedia()
Payment execution
Direct DB balance debit
PISP initiation via Open Banking API
SCA
Not implemented
BankID SCA required for each payment
Merchant verification
Static seed data (Ahmetov Kebab)
Live Brønnøysund org number verification
Fee handling
Fee deducted from user's balance ( totalOre = amountOre + feeOre )
Merchant settlement is separate from user debit
Settlement
Instant (DB update)
T+1 or T+2 settlement to merchant bank account
10. Accessibility Considerations (WCAG 2.1 AA)
Requirement
Implementation
Camera alternative
"Simuler skanning" button provides non-camera path
Amount input
Labeled with "Beløp" and suffixed with "NOK"
Confirmation
"Betal nå" button clearly labeled; "Avbryt" provides escape
Success feedback
Visual checkmark + text confirmation of payment
Color contrast
Gold (#D4A017) on dark (#0F172A) = 5.2:1 ratio (passes AA)
Screen reader
Merchant name and amount announced on payment confirmation
11. Cross-References
QR payment API: POST /api/transactions/qr-payment — See API Reference
Merchant registration: POST /api/merchants/register — See API Reference
Transaction schema: transactions table — See Database Schema
Merchant schema: merchants table — See Database Schema
Component overview: See component-overview.md
Figma scan screen: mockups/figma-make-export/src/app/screens/ScanQR.tsx
Web scan page: src/drop-app/src/app/scan/page.tsx — See PAGES.md
Mobile scan screen: src/drop-mobile/app/(tabs)/scan.js — See MOBILE-APP.md
Merchant onboarding flow: See flow-merchant-onboarding.md
PSD2 PISP details: See open-banking-aisp-pisp.md
Remittance Flow
Low-Level Design: Remittance Flow
Version: 1.0
Date: 2026-02-21
Author: Banking Architecture Team
Status: Approved
Applies to: Drop — Cross-Border Money Transfer (PISP)
1. Overview
Remittance is Drop's core feature — sending money from a Norwegian bank account to a recipient abroad. The flow has 4 user-facing steps:
Select recipient (or add new)
Enter amount (see FX rate + fee in real-time)
Review (PSD2 Art. 45 pre-payment disclosure)
Confirm (SCA via BankID at user's bank)
Drop uses PISP (Payment Initiation Service) to initiate the transfer directly from the user's bank account. Drop never touches the money.
API endpoint: POST /api/transactions/remittance
Fee: 0.5% of send amount
Amount range: 100 - 50,000 NOK
KYC required: Yes ( kyc_status = 'approved' )
Supported corridors: Serbia (RSD), Bosnia (BAM), Poland (PLN), Pakistan (PKR), Turkey (TRY), EU (EUR)
2. End-to-End Remittance Flow
sequenceDiagram
participant U as User
participant UI as Drop App (/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 WHERE user_id = ? ORDER BY created_at DESC
DB-->>API: [{id: "rec_1", name: "Marko Petrovic", 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 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: Send: 2,000 NOK Fee: 10 NOK (0.5%) Rate: 1 NOK = 10.17 RSD 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 {type: "remittance", amount: 2000, recipientId: "rec_1"}
API->>DB: Lookup recipient currency, exchange rate
API->>API: Calculate fee (2000 * 0.005 = 10) Calculate receive (2000 * 10.17 = 20340) Determine delivery (non-EEA: 2-4 days)
API-->>UI: {amount: 2000, fee: 10, feePercentage: 0.5, exchangeRate: 10.17, receiveAmount: 20340, receiveCurrency: "RSD", estimatedDelivery: "2-4 business days", totalCost: 2010}
UI->>UI: Display disclosure screen: "Du sender 2 000 kr til Marko Petrovic Gebyr: 10 kr (0,5%) Vekslingskurs: 1 NOK = 10,17 RSD Marko mottar: 20 340 RSD Total kostnad: 2 010 kr 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 {recipientId: "rec_1", amount: 2000, 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 BEGIN TRANSACTION UPDATE bank_accounts SET balance = balance - 201000 INSERT INTO transactions (status: 'processing')
API->>DB: COMMIT
API->>ASPSP: POST /v1/payments/cross-border-credit-transfers {debtorAccount: {iban: user_iban}, instructedAmount: {currency: "NOK", amount: "2010.00"}, creditorName: "Marko Petrovic", creditorAccount: {bban: "265-1234567-89"}, remittanceInformationUnstructured: "Drop remittance tx_rem_xxx"}
ASPSP-->>API: {paymentId: "pay_xyz", transactionStatus: "RCVD", scaRedirect: "https://dnb.no/sca/pay/..."}
API-->>UI: {transactionId: "tx_rem_xxx", 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 (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 SET status = 'completed', completed_at = now WHERE id = 'tx_rem_xxx'
API->>DB: INSERT INTO audit_log (action: 'payment.completed')
API->>DB: INSERT INTO notifications (title: 'Overfoering sendt', body: '2 000 kr sendt til Marko Petrovic')
API-->>UI: {status: "completed"}
UI-->>U: Success screen: "2 000 kr sendt til Marko Petrovic! Estimert levering: 2-4 virkedager"
Note over ASPSP,RB: Settlement (Drop is not involved)
ASPSP->>RB: SWIFT gpi / correspondent banking NOK converted to RSD
RB->>RB: Credit Marko's account: 20,340 RSD
3. Transaction States
stateDiagram-v2
[*] --> Draft: User on review screen (disclosure shown, not yet confirmed)
Draft --> Initiated: User taps "Bekreft og send" Transaction record created (status: processing)
Draft --> Abandoned: User navigates away (no record created)
Initiated --> ScaPending: ASPSP returns scaRedirect User redirected to bank
Initiated --> Failed: ASPSP rejects initiation (invalid IBAN, bank error)
ScaPending --> Completed: User completes BankID SCA ASPSP status: ACCP/ACSC
ScaPending --> Failed: SCA timeout (5 min)
ScaPending --> Failed: User cancels SCA
ScaPending --> Failed: Bank rejects payment (insufficient funds at bank)
Completed --> Settled: Funds credited to recipient (tracked via ASPSP status polling)
Completed --> RefundPending: Settlement failed (correspondent bank error)
Failed --> [*]: User sees error, can retry from Step 1
RefundPending --> Refunded: Refund processed Balance restored
Refunded --> [*]
Settled --> [*]
Abandoned --> [*]
Database status values ( transactions.status CHECK constraint):
processing — Transaction created, awaiting SCA or settlement
completed — ASPSP accepted payment, settlement in progress or done
failed — Payment rejected, SCA failed, or settlement error
4. Pre-Payment Disclosure (PSD2 Art. 45)
Before the user confirms a remittance, Drop must show a complete cost breakdown . This is a legal requirement under PSD2 Art. 45 (Betalingstjenesteloven in Norwegian law).
4.1 Disclosure Checklist
Item
PSD2 Reference
Drop Field
Example
Amount to be transferred
Art. 45(1)(a)
amount
2,000 NOK
All fees/charges
Art. 45(1)(b)
fee , feePercentage
10 NOK (0.5%)
Exchange rate used
Art. 45(1)(c)
exchangeRate
1 NOK = 10.17 RSD
Amount after conversion
Art. 45(1)(d)
receiveAmount , receiveCurrency
20,340 RSD
Total cost to payer
Art. 45(1)(e)
totalCost
2,010 NOK
Estimated delivery time
Art. 45(1)(f)
estimatedDelivery
2-4 business days
Currency of debit
Implicit
Send currency
NOK
Currency of credit
Implicit
Receive currency
RSD
4.2 Disclosure Screen Content (Norwegian)
Bekreft overfoering
Til: Marko Petrovic
Land: Serbia
Bankkonto: *****567-89 (Banca Intesa)
Du sender: 2 000,00 kr
Gebyr (0,5%): 10,00 kr
Totalt belop: 2 010,00 kr
Vekslingskurs: 1 NOK = 10,17 RSD
Marko mottar: 20 340,00 RSD
Estimert levering: 2-4 virkedager
Pengene trekkes fra: DNB Brukskonto
[Bekreft og send] [Avbryt]
5. FX Rate Lock & Expiry
5.1 Rate Lock Window
Event
Time
Action
User views disclosure
T+0
Rate displayed from exchange_rates table
User confirms payment
T+0 to T+15min
Rate locked in transactions.exchange_rate
SCA completes
T+0 to T+15min
Locked rate applies to settlement
SCA not completed
T+15min
Rate expires, transaction fails, user must re-quote
5.2 Rate Lock Implementation
When POST /api/transactions/remittance is called, the current rate is read from exchange_rates
The rate is stored in transactions.exchange_rate at insert time
If the SCA takes longer than 15 minutes, the reconciliation job detects the stale transaction and marks it failed
User is notified to retry (with a new, current rate)
6. Validation Rules
6.1 Pre-Flight Checks (Before Transaction Creation)
Check
Source
Error if Failed
User authenticated
JWT from cookie/header
401 unauthorized
KYC approved
users.kyc_status = 'approved'
403 kyc_required
Recipient exists
recipients.id WHERE user_id = ?
404 not_found
Recipient belongs to user
recipients.user_id = jwt.userId
404 not_found
Bank account exists
bank_accounts.id WHERE user_id = ?
400 no_bank_account
Amount in range
100 to 50,000 NOK
422 validation_error
Amount valid
Number.isFinite() , max 2 decimals
422 validation_error
Balance sufficient
bank_accounts.balance >= amount + fee
402 insufficient_balance
Currency corridor supported
exchange_rates.to_currency exists
422 validation_error
Not duplicate
idempotency_key unique
Return existing transaction
Rate limit
< 10 requests/min per IP
429 rate_limited
6.2 Amount Validation
Minimum: 100 NOK (protect against micro-transaction abuse)
Maximum: 50,000 NOK (regulatory limit for simplified CDD)
Decimals: max 2 (validated by validateAmount())
Type: Number.isFinite() (prevents NaN, Infinity injection)
7. Error Scenarios & User Messages
Scenario
API Response
User Message (Norwegian)
Next Step
KYC not approved
403 kyc_required
"Du maa fullfoere identitetsverifisering for aa sende penger."
Redirect to KYC flow
No linked bank account
400 no_bank_account
"Du har ingen tilkoblet bankkonto. Koble til en bank foerst."
Redirect to /accounts
Insufficient balance
402 insufficient_balance
"Ikke nok penger paa kontoen. Saldo: 1 200 kr, totalt belop: 2 010 kr."
Show balance, suggest lower amount
Unsupported corridor
422 validation_error
"Vi stoetter ikke overfoering til dette landet ennaa."
Show supported countries
Amount too low
422 validation_error
"Minimumsbelopet er 100 kr."
Adjust amount
Amount too high
422 validation_error
"Maksimumsbelopet er 50 000 kr."
Adjust amount
SCA timeout
(callback timeout)
"BankID-sesjonen utlop. Overforingen ble ikke gjennomfoert."
Retry button
SCA cancelled
(callback cancelled)
"Du avbrot betalingen. Ingen penger er trukket."
Retry button
Bank rejected
ASPSP RJCT
"Banken avviste overforingen. Kontakt banken din."
Show bank support info
Rate expired
(rate > 15min old)
"Vekslingskursen har utlopt. Vennligst bekreft ny kurs."
Re-show disclosure with new rate
Network error
502/503
"Teknisk feil. Proev igjen om noen minutter."
Retry after 30s
Duplicate detected
200 (existing tx)
"Denne overforingen er allerede registrert."
Show existing transaction
8. Post-Transaction
8.1 Confirmation Screen
After successful SCA:
Overfoering sendt!
2 000 kr sendt til Marko Petrovic
Marko mottar 20 340 RSD
Referanse: tx_rem_a1b2c3d4...
Status: Under behandling
Estimert levering: 2-4 virkedager
[Se detaljer] [Send til en annen]
8.2 Transaction Tracking
Users can track their remittance in the Transaction History ( /transactions ):
Status
Display
Icon
processing
"Under behandling"
Spinner
completed
"Fullfoert"
Green checkmark
failed
"Feilet"
Red X
8.3 Transaction Summary
GET /api/transactions/summary returns aggregated transaction statistics for the authenticated user (total sent, total fees, transaction count, breakdown by corridor).
8.4 Receipt
GET /api/transactions/{id}/receipt returns a detailed receipt:
{
"transactionId": "tx_rem_xxx",
"date": "2026-02-21T14:30:00Z",
"type": "remittance",
"amount": 2000,
"currency": "NOK",
"fee": 10,
"exchangeRate": 10.17,
"receiveAmount": 20340,
"receiveCurrency": "RSD",
"recipient": {"name": "Marko Petrovic", "country": "RS"},
"reference": "tx_rem_xxx",
"status": "completed",
"completedAt": "2026-02-21T14:35:00Z"
}
8.5 Notifications
On completion/failure, a notification is created:
Event
Notification Title
Notification Body
Payment sent
"Overfoering sendt"
"2 000 kr sendt til Marko Petrovic"
Payment completed
"Overfoering fullfoert"
"20 340 RSD mottatt av Marko Petrovic"
Payment failed
"Overfoering feilet"
"Overfoering til Marko Petrovic ble avvist. Kontakt oss for hjelp."
9. Refund Handling
If a remittance fails after funds were debited (e.g., correspondent bank rejects, recipient IBAN invalid):
Step
Action
Timeline
1
ASPSP reports RJCT or CANC status
1-5 business days
2
Drop detects via reconciliation job
Within 1 hour of status change
3
Drop creates refund record in audit_log
Immediate
4
ASPSP reverses the debit (automatic for SEPA)
1-3 business days
5
Drop updates bank_accounts.balance on next AISP sync
Next balance refresh
6
User notified via push notification
Immediate
Note: For SWIFT transfers, refund timing depends on correspondent banks and may take 5-10 business days. Drop sends a notification with estimated refund timeline.
10. AML/Compliance Checks
Each remittance triggers compliance checks before PISP initiation:
Check
Implementation
Action on Trigger
Velocity limit
> 5 remittances/hour or > 20/day
aml_alerts record (medium severity), continue
Structuring detection
Multiple amounts just below 25,000 NOK
aml_alerts record (high severity), review queue
High-risk corridor
FATF grey/black list country
Enhanced due diligence flag
Single large transfer
> 25,000 NOK
Enhanced monitoring
Total daily volume
> 100,000 NOK cumulative
aml_alerts record, may require manual approval
Sanctions screening
Recipient name vs sanctions lists
Block if match, screening_results record
11. Database Impact
11.1 Tables Written
Table
Operation
When
transactions
INSERT
Payment initiated (Step 4)
transactions
UPDATE
Status change (processing to completed/failed)
bank_accounts
UPDATE (balance)
Atomic debit during transaction creation
audit_log
INSERT
Every payment action
notifications
INSERT
Payment sent / completed / failed
aml_alerts
INSERT
If AML rule triggered
11.2 Tables Read
Table
Operation
When
users
SELECT (kyc_status)
Pre-flight KYC check
recipients
SELECT
Recipient lookup (Step 1)
bank_accounts
SELECT (balance)
Balance check (Step 4)
exchange_rates
SELECT (rate)
FX rate lookup (Step 2, 3, 4)
transactions
SELECT (idempotency_key)
Duplicate detection
12. Cross-References
Payment Processing: ../integration/payment-processing.md — SEPA/SWIFT settlement, FX, fees
Open Banking AISP/PISP: ../integration/open-banking-aisp-pisp.md — PISP API details
BankID OIDC: ../integration/bankid-oidc-integration.md — Drop authentication
Security Architecture: ../hld/security-architecture.md — Fraud detection, AML pipeline
Bank Account Linking: flow-bank-account-linking.md — Prerequisite: linked bank account
Database Schema: ../../backend/DATABASE-SCHEMA.md — transactions , recipients , exchange_rates tables
API Reference: ../../backend/API-REFERENCE.md — Remittance, disclosure, receipt, rate endpoints
Merchant Onboarding Flow
Flow: Merchant Onboarding
Document: LLD-006
Version: 1.0
Date: 2026-02-21
Author: Frontend Architect (AI Agent)
Status: Draft
Scope: Merchant registration, business verification, QR code generation, merchant dashboard, transaction monitoring, and settlement
1. Overview
Drop supports merchant onboarding for in-store QR payments. Any authenticated user can register as a merchant by providing business details and a valid Norwegian organization number. Upon registration, the user's role is upgraded from user to merchant , granting access to the merchant dashboard with transaction monitoring, daily summaries, and settlement views.
Key facts:
Merchant registration requires authenticated BankID login
Organization number verified against Brønnøysundregistrene (production; format validation in demo)
QR code format: drop://pay/{merchantId}
Merchant fee: 1% per QR transaction (lower than card terminal 1.75-2.75%)
Settlement: T+1 (planned) — funds from transactions deposited to merchant's bank account
2. Merchant Registration Flow
2.1 Sequence Diagram — Registration to Active Merchant
sequenceDiagram
actor User
participant App as Drop App
participant API as Drop API (/api/merchants)
participant Brreg as Brønnøysund Register (prod)
participant DB as Database
User->>App: Navigate to merchant registration
App->>App: useAuth() — verify authenticated
User->>App: Fill registration form (businessName, orgNumber, address, bankAccount)
App->>App: Client-side validation (name: validateName, orgNumber: 9 digits)
App->>API: POST /api/merchants/register { 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 (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 'VELKOMMEN' + business name + Settings button"]
PeriodFilter["PeriodFilter (I dag / Uke / Maaned)"]
RevenueCard["RevenueCard (green gradient)"]
QRButton["QRButton 'Vis min QR-kode'"]
TransactionList["TransactionList 'Dagens transaksjoner'"]
BottomNav["BottomNav"]
end
subgraph "Revenue Card"
TotalRevenue["Total omsetning (4xl Fraunces font)"]
StatsGrid["Stats Grid (2 cols)"]
TxCount["Transaksjoner (count)"]
FeesInfo["Gebyrer betalt (NOK amount)"]
end
subgraph "Transaction Item"
CustomerIcon["CheckCircle2 (green)"]
CustomerName["Customer Name (partially anonymized)"]
TxTime["Timestamp"]
TxAmount["Amount (+NOK, green)"]
end
RevenueCard --> TotalRevenue
RevenueCard --> StatsGrid
StatsGrid --> TxCount
StatsGrid --> FeesInfo
TransactionList --> CustomerIcon
TransactionList --> CustomerName
TransactionList --> TxTime
TransactionList --> TxAmount
4. Business Verification Checklist
4.1 Registration Requirements
Requirement
Field
Validation
Demo
Production
Business name
businessName
validateName() — 1-100 chars, at least one letter, no HTML/script
Format check only
Format check
Organization number
orgNumber
Exactly 9 digits, unique in DB
Format + uniqueness
Brønnøysund API lookup
Business address
address
Optional, sanitized to 300 chars
Optional
Required for settlement
Payout bank account
bankAccount
Required, non-empty
Format check
IBAN/account validation
User authentication
JWT
Valid BankID session
Required
Required
KYC status
user.kycStatus
Must be approved
Auto-approved via BankID
BankID verification
4.2 Brønnøysundregistrene Verification (Production)
Check
API
Response Field
Pass Criteria
Business exists
GET /enhetsregisteret/api/enheter/{orgNr}
organisasjonsnummer
Matches input
Business is active
Same
registreringsdatoEnhetsregisteret
Not null
Business type
Same
organisasjonsform.kode
AS, ENK, NUF, DA, ANS
Business name match
Same
navn
Approximate match to submitted name
5. Settlement Schedule
5.1 Settlement Schedule Table
Period
Settlement Day
Payout Time
Details
Daily transactions
T+1
08:00 CET
Next business day after transaction
Weekend transactions
Monday
08:00 CET
Batched for Monday payout
Holiday transactions
Next business day
08:00 CET
Following Norwegian business day
5.2 Settlement Calculation
Field
Formula
Example
Gross revenue
Sum of all QR payment amounts
4 350 NOK
Merchant fee
Gross x 1% (fee_rate)
43.50 NOK
Net payout
Gross - fee
4 306.50 NOK
Payout account
merchant.bankAccount
IBAN or Norwegian account
5.3 Merchant Dashboard API
Endpoint: GET /api/merchants/dashboard?period={today|week|month}
Response:
{
"data": {
"revenue": 4350,
"transactionCount": 12,
"fees": 43.5,
"netRevenue": 4306.5,
"nextPayout": "2026-02-22T07:00:00.000Z",
"payoutTime": "Neste virkedag kl. 08:00"
}
}
6. QR Code Generation
Property
Value
Format
URI: drop://pay/{merchantId}
Encoding
Standard QR code (alphanumeric)
Generation
Client-side (from returned qrUri )
Display
"Vis min QR-kode" button on merchant dashboard
Printing
Merchant can screenshot or print for in-store display
QR endpoint: GET /api/merchants/qr
{
"data": {
"merchantId": "mer_a1b2c3d4e5f6g7h8",
"businessName": "Ahmetov Kebab",
"qrValue": "drop://pay/mer_a1b2c3d4e5f6g7h8",
"address": "Gronlandsleiret 44, 0190 Oslo"
}
}
7. Merchant Transaction Monitoring
7.1 Transaction List
Endpoint: GET /api/merchants/transactions?page=1&limit=20
Field
Value
Privacy
Customer name
First name + last initial
Partially anonymized (e.g., "Ola N.")
Amount
Positive NOK value
Full amount shown
Status
"Vellykket" (green)
Color-coded
Timestamp
HH:MM format
Time only for today's transactions
7.2 Period Filtering
Period
API Value
Dashboard Label
Aggregation
Today
today
I dag
Sum of today's transactions
This week
week
Uke
Mon-Sun aggregation
This month
month
Maaned
Calendar month aggregation
8. UI Components (Web)
8.1 Merchant Dashboard Layout
Section
Component
Description
Header
Business name (Fraunces) + Settings icon
Welcome greeting + gear icon
Period tabs
Button group (I dag, Uke, Maaned)
Green active, gray inactive
Revenue card
Green gradient card (#0B6E35 to #095a2b)
Total omsetning (4xl), stats grid
QR button
Full-width green button with QrCode icon
"Vis min QR-kode"
Transaction list
Card list with CheckCircle2 icons
Customer name, time, +amount (green)
Navigation
BottomNav (5 tabs)
Standard bottom navigation
8.2 Figma Reference
Source of truth: mockups/figma-make-export/src/app/screens/MerchantDashboard.tsx
Welcome header with business name
Period filter tabs (I dag / Uke / Maaned)
Green gradient revenue card with stats
QR code button
Transaction list with customer names
9. Role Upgrade Flow
Step
Before
After
1
User has role = 'user'
Same
2
User submits merchant registration
Same
3
API validates and creates merchant record
role = 'merchant'
4
User's JWT still has old role
Valid until refresh
5
On next token refresh / re-login
New JWT has role = 'merchant'
Authorization gates:
GET /api/merchants/dashboard — requires role = 'merchant'
GET /api/merchants/qr — requires role = 'merchant'
GET /api/merchants/transactions — requires role = 'merchant'
10. Platform Differences
Feature
Web
Mobile
Merchant registration
Full form via web UI
Not implemented
Merchant dashboard
Dedicated screen with stats
Not implemented
QR code display
Button to show QR
Not implemented
Transaction monitoring
List with period filter
Not implemented
Settlement view
Inline in dashboard stats
Not implemented
11. Accessibility Considerations (WCAG 2.1 AA)
Requirement
Implementation
Form validation
Registration form shows inline error messages
Revenue card
Uses both visual (bold text) and semantic (heading) for amounts
Period tabs
Active tab indicated by color AND aria-selected
Transaction list
Each item has descriptive text (customer, amount, status)
QR code
Alt text: "QR-kode for {businessName}"
Color contrast
White text on green gradient meets 4.5:1
Settings icon
Has aria-label "Innstillinger"
12. Cross-References
Merchant registration API: POST /api/merchants/register — See API Reference
Merchant dashboard API: GET /api/merchants/dashboard — See API Reference
Merchant QR API: GET /api/merchants/qr — See API Reference
Merchant transactions API: GET /api/merchants/transactions — See API Reference
Merchants schema: merchants table — See Database Schema
Transactions schema: transactions table — See Database Schema
Component overview: See component-overview.md
Figma merchant dashboard: mockups/figma-make-export/src/app/screens/MerchantDashboard.tsx
QR payment flow: See flow-qr-payment.md
Authentication flow: See flow-login-authentication.md
Transaction History Flow
Flow: Transaction History
Document: LLD-003
Version: 1.0
Date: 2026-02-21
Author: Frontend Architect (AI Agent)
Status: Draft
Scope: Transaction list rendering, filtering, pagination, transaction detail view, receipt download, and dispute initiation
1. Overview
The transaction history view provides users with a chronological list of all their financial transactions (remittances and QR payments). The list supports filtering by type and status, date-based grouping, infinite scroll pagination, and drill-down to transaction detail with receipt download.
Key capabilities:
Paginated transaction list (20 per page, max 50)
Filter by type: All, Remittance, QR Payment
Filter by status: Processing, Completed, Failed
Date grouping: I dag, I gar, Denne uken, Eldre
Transaction detail view with exchange rate info
Receipt download (JSON receipt)
Complaint/dispute initiation via /complaints
2. Transaction List Load + Filter + Detail View
2.1 Sequence Diagram
sequenceDiagram
actor User
participant App as Drop App (/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 = ? ORDER BY created_at DESC LIMIT 20
API-->>App: { data: [...], pagination: { page: 1, limit: 20, total: N } }
App->>App: groupByDate(transactions) → "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 with transaction context
3. Transaction List Components
3.1 Component Diagram
graph TD
subgraph "Transaction History Page"
PageHeader["PageHeader Back button + 'Transaksjonshistorikk' title"]
FilterTabs["FilterTabs (Tabs component)"]
TransactionList["TransactionList (scrollable area)"]
LoadMore["LoadMore Trigger (infinite scroll sentinel)"]
end
subgraph "Filter Tabs"
TabAll["Alle (all types)"]
TabRemittance["Overforinger (type=remittance)"]
TabQR["QR-betalinger (type=qr_payment)"]
end
subgraph "Transaction List Items"
DateGroup["DateGroupHeader ('I DAG', 'I GAR', etc.)"]
TransactionCard["TransactionCard"]
end
subgraph "Transaction Card"
TxIcon["TypeIcon (ArrowUpRight, ScanLine, Clock)"]
TxInfo["TxInfo (name, type label)"]
TxAmount["TxAmount (amount + status badge)"]
end
subgraph "Transaction Detail"
DetailHeader["DetailHeader (back + title)"]
DetailSummary["DetailSummary (type, status, date)"]
AmountBreakdown["AmountBreakdown (send, receive, rate, fee)"]
RecipientInfo["RecipientInfo (name, country, bank)"]
ReceiptButton["ReceiptButton ('Last ned kvittering')"]
DisputeButton["DisputeButton ('Klag')"]
end
FilterTabs --> TabAll
FilterTabs --> TabRemittance
FilterTabs --> TabQR
TransactionList --> DateGroup
DateGroup --> TransactionCard
TransactionCard --> TxIcon
TransactionCard --> TxInfo
TransactionCard --> TxAmount
TransactionCard -->|tap| DetailHeader
DetailHeader --> DetailSummary
DetailSummary --> AmountBreakdown
AmountBreakdown --> RecipientInfo
RecipientInfo --> ReceiptButton
RecipientInfo --> DisputeButton
4. Filter Options
4.1 Filter Options Table
Filter
API Parameter
Value
Label (Norwegian)
Default
All transactions
(none)
—
Alle
Yes
Remittances only
type
remittance
Overforinger
No
QR payments only
type
qr_payment
QR-betalinger
No
Processing status
status
processing
Behandles
No
Completed status
status
completed
Fulfort
No
Failed status
status
failed
Mislykket
No
4.2 Pagination Parameters
Parameter
Type
Default
Min
Max
Description
page
int
1
1
—
Page number
limit
int
20
1
50
Items per page
5. Transaction Status Display Mapping
Status
Label (Norwegian)
Color
Icon
Background
completed
Fulfort
#0B6E35 (green)
ArrowUpRight / ScanLine
#F8FAFC
processing
Behandles
#D4A017 (gold)
Clock
#FEF3C7
failed
Mislykket
#EF4444 (red)
X
#FEF2F2
5.1 Transaction Type Icons
Type
Icon
Color
Description
remittance (sent)
ArrowUpRight
#0B6E35
Outgoing international transfer
qr_payment
ScanLine
#0B6E35
QR code payment to merchant
remittance (processing)
Clock
#D4A017
Transfer in progress
5.2 Amount Display
Condition
Format
Color
Example
Outgoing (sent)
-{amount} kr
#0F172A (dark)
-2 000 kr
Incoming (received)
+{amount} kr
#10B981 (green)
+5 000 kr
Processing
-{amount} kr
#0F172A (dark)
-3 000 kr
6. Date Grouping Logic
The groupByDate() function categorizes transactions into temporal groups:
Group
Label
Condition
Today
I DAG
createdAt is today
Yesterday
I GAR
createdAt is yesterday
This Week
DENNE UKEN
createdAt is within current week (Mon-Sun)
Older
Date string (e.g., "12. OKT")
All older transactions, grouped by date
7. Transaction Detail View
7.1 Remittance Detail Fields
Field
Source
Example
Transaction ID
data.id
tx_rem_a1b2c3d4e5f6g7h8
Type
data.type
Overforing
Status
data.status
Fullfort
Send Amount
data.sendAmount + data.sendCurrency
2 000 NOK
Receive Amount
data.receiveAmount + data.receiveCurrency
23 400 RSD
Exchange Rate
data.exchangeRate
1 NOK = 11.70 RSD
Fee
data.fee
10.00 NOK (0.5%)
Total Cost
data.total
2 010 NOK
Recipient
data.recipientName
Mama Jasmina
Destination
data.recipientCountry
Serbia
Created
data.createdAt
21. feb 2026 kl. 14:32
Completed
data.completedAt
21. feb 2026 kl. 14:35
7.2 QR Payment Detail Fields
Field
Source
Example
Transaction ID
data.id
tx_qr_a1b2c3d4e5f6g7h8
Type
data.type
QR-betaling
Status
data.status
Fullfort
Amount
data.amount
129 NOK
Fee
data.fee
1.29 NOK (1%)
Merchant
data.merchantName
Ahmetov Kebab
Source Account
data.fromAccount
DNB
Created
data.createdAt
21. feb 2026 kl. 12:15
7.3 Receipt Endpoint
GET /api/transactions/{id}/receipt returns a structured receipt:
{
"data": {
"transactionId": "tx_rem_1",
"date": "2026-02-21T14:32:00.000Z",
"type": "remittance",
"amount": 2000,
"currency": "NOK",
"fee": 10,
"exchangeRate": 11.7,
"receiveAmount": 23400,
"receiveCurrency": "RSD",
"recipient": { "name": "Mama Jasmina", "country": "RS" },
"reference": "tx_rem_1",
"status": "completed",
"completedAt": "2026-02-21T14:35:00.000Z"
}
}
8. Dispute Initiation
Users can initiate a dispute from the transaction detail view by navigating to the complaints page:
Step
Action
1
User taps "Klag" on transaction detail
2
Navigate to /complaints with pre-filled category = "transaction"
3
User fills subject and description
4
POST /api/complaints { category: "transaction", subject, description }
5
Confirmation: "Vi behandler klagen din innen 15 virkedager" (Finansavtaleloven 3-53)
9. Platform Differences
Feature
Web ( /transactions )
Mobile ( history.js )
Filter tabs
Tabs component (Alle, Overforinger, QR-betalinger)
Custom buttons (Alle, Sendinger, QR)
Pagination
Infinite scroll with limit=50
FlatList with pull-to-refresh
Transaction detail
Inline expansion or modal
Separate screen (not yet implemented)
Receipt download
API call + browser download
Not implemented
Dispute link
Navigate to /complaints
Not implemented
Date grouping
groupByDate() utility
Same pattern
Refresh
Re-fetch on filter change
Pull-to-refresh via RefreshControl
10. Accessibility Considerations (WCAG 2.1 AA)
Requirement
Implementation
List semantics
Transaction list uses semantic list markup
Filter announcement
Active filter tab announced to screen readers
Amount polarity
Positive/negative amounts use text prefix (+/-) in addition to color
Status indication
Status uses both color AND text label (not color alone)
Tap targets
Transaction cards are full-width, min 48px height
Loading state
Skeleton placeholders shown during initial load
Empty state
"Ingen transaksjoner" message when list is empty
Keyboard nav
Tab cycles through filters, then transaction items
11. Cross-References
Transaction list API: GET /api/transactions — See API Reference
Transaction detail API: GET /api/transactions/{id} — See API Reference
Receipt API: GET /api/transactions/{id}/receipt — See API Reference
Complaints API: POST /api/complaints — See API Reference
Transaction schema: transactions table — See Database Schema
Component overview: See component-overview.md
Figma transaction history: mockups/figma-make-export/src/app/screens/TransactionHistory.tsx
Web page: src/drop-app/src/app/transactions/page.tsx — See PAGES.md
Mobile screen: src/drop-mobile/app/history.js — See MOBILE-APP.md
QR payment flow: See flow-qr-payment.md
Profile & Settings Flow
Flow: Profile & Settings
Document: LLD-004
Version: 1.0
Date: 2026-02-21
Author: Frontend Architect (AI Agent)
Status: Draft
Scope: Profile page, personal information display, KYC status, settings management, GDPR data export, and account deletion flow
1. Overview
The profile section provides users with personal information display (sourced from BankID), KYC verification status, and configurable settings. It also handles GDPR compliance features including data export (right to portability) and account deletion (right to erasure) with mandatory AML data retention notices.
Profile sub-pages (web):
/profile — Hub with user info and menu
/profile/personal — BankID-verified personal details (read-only)
/profile/security — Security settings and active devices
/profile/notifications — Push and email notification toggles
/profile/language — Language selection (nb, en, bs, sq)
2. Profile Load + Settings Update
2.1 Sequence Diagram
sequenceDiagram
actor User
participant App as Drop App (/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 (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 "Er du sikker? Dette kan ikke angres."
App->>App: Show AML retention notice "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 (type=erasure, status=completed)
API->>DB: COMMIT
API-->>App: 200 { message: "Account scheduled for deletion", 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 deleted_at = now]
G --> H[Revoke all active sessions]
H --> I[Create erasure request record]
I --> J[Clear auth cookie/token]
J --> K[Show confirmation screen]
K --> L[Redirect to /login]
style D fill:#FEF3C7
style G fill:#FEF2F2
4. Settings Matrix
4.1 User Settings
Setting
API Field
Type
Options
Default
Persistence
Display currency
currency
string
EUR, USD, GBP, BAM, CHF, PLN, NOK, RSD, TRY, PKR
NOK
PATCH /api/settings
Language
language
string
nb (Norsk Bokmal), en (English), bs (Bosanski), sq (Shqip)
nb
PATCH /api/settings
Push notifications
pushEnabled
boolean
true/false
true
PATCH /api/settings
Email notifications
emailEnabled
boolean
true/false
true
PATCH /api/settings
4.2 Personal Information (Read-Only from BankID)
Field
Source
Editable
Display Format
First name
BankID ID token
No
Plain text
Last name
BankID ID token
No
Plain text
Email
User registration / BankID
No
Plain text
Phone
User registration
No
+47 XXX XX XXX
Date of birth
BankID pid (national ID)
No
DD. MMMM YYYY (e.g., "15. mars 1995")
KYC status
System (auto via BankID)
No
Badge: "Verifisert med BankID" (green)
4.3 Security Settings (UI Display Only)
Setting
Current Value
Status
Actionable
Password
"Sist endret: Aldri"
Info display
Change button (planned)
BankID verification
Active
Green badge
N/A
Vipps verification
Not activated
Gray badge
Planned (Phase 2)
Active devices
iPhone 15 Pro (active), MacBook Pro (yesterday)
Live info
View/revoke (planned)
5. GDPR Rights Mapping
GDPR Right
Article
Implementation
API Endpoint
Status
Right to access (innsyn)
Art. 15
Full data export as JSON
GET /api/user/data-export
Implemented
Right to rectification (retting)
Art. 16
BankID data is authoritative; address via support
N/A
Via support
Right to erasure (sletting)
Art. 17
Soft-delete with 5-year AML retention
DELETE /api/user/account
Implemented
Right to data portability
Art. 20
Same as data export (JSON format)
GET /api/user/data-export
Implemented
Right to withdraw consent
Art. 7
Consent toggle + withdrawal tracking
POST /api/consents { granted: false }
Implemented
Right to lodge complaint
Art. 77
Link to Datatilsynet on privacy page
N/A
Link provided
5.1 Data Export Contents
GET /api/user/data-export returns:
Section
Data
user
id, email, first_name, last_name, phone, date_of_birth, kyc_status, role, created_at
transactions
All transaction records
recipients
All saved recipients
bankAccounts
Linked bank accounts (masked numbers)
settings
Currency, language, notification preferences
consents
All consent records with timestamps
5.2 Data Retention After Deletion
Data Category
Retention Period
Legal Basis
Transaction records
5 years
Hvitvaskingsloven (AML)
KYC/identity data
5 years
Hvitvaskingsloven (AML)
AML alerts and STR reports
5 years
Hvitvaskingsloven (AML)
Consent records
5 years
GDPR proof of consent
User profile (soft-deleted)
5 years
Legal obligation
Settings, preferences
Deleted immediately
No retention requirement
Notification history
Deleted immediately
No retention requirement
6. UI Components
6.1 Profile Hub ( /profile )
Element
Component
Source
Avatar
Green gradient circle with initials
Inline (gradient from #0B6E35 to #095a2b)
Edit button
Pen icon on avatar
Pen (lucide)
User info
Name (Fraunces font) + email
Text
Account section
Menu items with chevrons
ChevronRight (lucide)
Settings section
Menu items with chevrons
Bell, Shield, Globe (lucide)
Bottom navigation
5-tab bar
BottomNav component
6.2 Notification Settings ( /profile/notifications )
Element
Component
Behavior
Push toggle
Custom switch
Immediate PATCH, revert on failure
Email toggle
Custom switch
Immediate PATCH, revert on failure
6.3 Language Settings ( /profile/language )
Element
Component
Behavior
Language list
Radio selection with green checkmark
Local state update
Save button
Green "Lagre" button
PATCH on click
6.4 Figma Reference
Source of truth: mockups/figma-make-export/src/app/screens/Profile.tsx
User avatar with initials and edit button
Account section (Personlig informasjon, Bankkontoer)
Settings section (Varsler, Sikkerhet, Sprak)
7. Platform Differences
Feature
Web
Mobile
Profile layout
Hub with 4 sub-pages
Single screen with inline settings
Personal info
Dedicated /profile/personal page
Inline section
Security settings
Dedicated /profile/security page
Not implemented
Notification settings
Dedicated /profile/notifications page
Inline in profile
Language
Dedicated /profile/language page
"Sprak" in settings menu
Recipients list
Not on profile (separate /recipients)
Inline "Mine mottakere" section
GDPR export
Via /profile or API
Not implemented
Account deletion
Via /profile
Not implemented
Logout
Confirmation dialog
Simple button with token clear
8. Accessibility Considerations (WCAG 2.1 AA)
Requirement
Implementation
Form labels
All settings have visible labels
Toggle state
Push/email toggles announce on/off state to screen readers
Read-only fields
Personal info fields use disabled attribute with visible label
KYC badge
Uses both color (green) and icon (ShieldCheck) for verification status
Navigation
Sub-page back buttons have aria-label "Tilbake"
Destructive action
Account deletion requires explicit confirmation dialog
Language names
Languages listed in their own script for recognition
Color contrast
Green badge (#0B6E35) on white meets 4.5:1
9. Cross-References
User data API: GET /api/auth/me — See API Reference
Settings API: GET/PATCH /api/settings — See API Reference
Data export API: GET /api/user/data-export — See API Reference
Account deletion API: DELETE /api/user/account — See API Reference
Consents API: GET/POST /api/consents — See API Reference
Settings schema: settings table — See Database Schema
Data access requests schema: data_access_requests table — See Database Schema
Component overview: See component-overview.md
Figma profile screen: mockups/figma-make-export/src/app/screens/Profile.tsx
Web profile pages: src/drop-app/src/app/profile/ — See PAGES.md
Mobile profile screen: src/drop-mobile/app/(tabs)/profile.js — See MOBILE-APP.md
Authentication flow: See flow-login-authentication.md
Notifications Flow
Flow: Notifications
Document: LLD-005
Version: 1.0
Date: 2026-02-21
Author: Frontend Architect (AI Agent)
Status: Draft
Scope: Push notification delivery, in-app notification center, notification types, read/unread state, deep linking, and permission handling
1. Overview
Drop's notification system provides users with transaction alerts, security notifications, and system updates. The current implementation consists of an in-app notification center (bell icon) with read/unread state management. Push notifications via Expo Push are planned for mobile but not yet implemented.
Current state:
In-app notification center: Implemented (web)
Push notifications: Not yet implemented (planned via Expo Push for mobile)
Deep linking from notifications: Not yet configured
Notification preferences: Implemented (push/email toggles in settings)
2. Push Notification Delivery (Planned Architecture)
2.1 Sequence Diagram — Push Notification Flow
sequenceDiagram
actor User
participant Mobile as Expo App
participant API as Drop API
participant DB as Database
participant Push as Expo Push 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 (/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 = ? 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 { notificationIds: [unread IDs] }
API->>DB: UPDATE notifications SET read = 1 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 Back button + 'Varsler' title"]
NotificationList["NotificationList"]
EmptyState["EmptyState Bell icon + 'Ingen varsler enna'"]
end
subgraph "Notification List"
DateGroup["DateGroupHeader ('I DAG', 'I GAR', date)"]
NotificationCard["NotificationCard"]
end
subgraph "Notification Card"
TypeIcon["TypeIcon (colored circle + icon)"]
Content["Content (title, body, timestamp)"]
UnreadDot["UnreadDot (blue indicator)"]
end
subgraph "Dashboard Integration"
BellIcon["Bell Icon (header, with badge count)"]
end
NotificationList --> DateGroup
DateGroup --> NotificationCard
NotificationCard --> TypeIcon
NotificationCard --> Content
NotificationCard --> UnreadDot
BellIcon -->|navigate| Header
4. Notification Type Table
Type
Icon
Icon Color
Background
Title Example
Body Example
Priority
transaction_complete
ArrowUpRight
#0B6E35 (green)
#F0FDF4
"Overforing til Mama Jasmina fullfort"
"2 000 kr sendt til Serbia"
Normal
qr_payment
ScanLine
#D4A017 (gold)
#FEF3C7
"QR-betaling hos Ahmetov Kebab"
"129 kr betalt"
Normal
security
Smartphone
#3B82F6 (blue)
#EFF6FF
"Ny palogging fra iPhone"
"Oslo, Norge"
High
rate_update
TrendingUp
#D4A017 (gold)
#FEF3C7
"Valutakurs oppdatert"
"1 NOK = 11.70 RSD"
Low
system
Bell
#6B7280 (gray)
#F3F4F6
"Systemoppdatering"
"Drop er oppdatert til v0.2.0"
Low
promotional
—
#6B7280 (gray)
#F3F4F6
"Nytt tilbud"
"0% gebyr denne uken!"
Low
4.1 Priority Levels
Priority
Behavior
Push
In-App
High
Immediate delivery, sound, badge
Yes (when implemented)
Top of list, bold styling
Normal
Standard delivery
Yes (when implemented)
Normal styling
Low
Batched delivery
Optional (user preference)
Normal styling
5. Deep Link Routing Table (Planned)
Notification Type
Deep Link Target
Web Route
Mobile Route
transaction_complete
Transaction detail
/transactions?id={txId}
/(tabs)/history?id={txId}
qr_payment
Transaction detail
/transactions?id={txId}
/(tabs)/history?id={txId}
security
Security settings
/profile/security
/(tabs)/profile
rate_update
Send money (with rate)
/send
/(tabs)/send
system
Notification center
/notifications
/notifications
promotional
Landing or feature page
/ or feature URL
App home
5.1 Deep Link Format
Platform
Format
Example
Web
URL path
https://getdrop.no/transactions?id=tx_rem_1
Mobile (planned)
Custom scheme
drop://transactions/tx_rem_1
Expo push data
JSON payload
{ "type": "transaction_complete", "targetId": "tx_rem_1" }
6. Permission Handling
6.1 Notification Permission Flow
Platform
Permission Request
First Time
Denied
Settings Redirect
iOS (Expo)
Notifications.requestPermissionsAsync()
System dialog: "Drop would like to send you notifications"
Returns { status: 'denied' }
Linking.openSettings()
Android (Expo)
Notifications.requestPermissionsAsync()
System dialog (Android 13+): "Allow Drop to send you notifications?"
Returns { status: 'denied' }
Linking.openSettings()
Web
Notification.requestPermission()
Browser prompt: "getdrop.no wants to show notifications"
Blocked; user must reset in browser settings
Site settings via address bar
6.2 Permission State Machine
stateDiagram-v2
[*] --> NotDetermined: First launch
NotDetermined --> Requesting: App requests permission
Requesting --> Granted: User allows
Requesting --> Denied: User denies
Granted --> Active: Push token registered
Active --> Disabled: User toggles off in Drop settings
Disabled --> Active: User toggles on in Drop settings
Denied --> SettingsRedirect: App shows "enable in settings" prompt
SettingsRedirect --> Granted: User enables in OS settings
SettingsRedirect --> Denied: User keeps disabled
7. Notification Preferences (Settings Integration)
Users control notification delivery via /profile/notifications :
Setting
API Field
Effect
Push notifications ON
pushEnabled: true
Push tokens active, notifications delivered
Push notifications OFF
pushEnabled: false
Push tokens retained but not used for delivery
Email notifications ON
emailEnabled: true
Email alerts sent for high-priority events
Email notifications OFF
emailEnabled: false
No email alerts
API: PATCH /api/settings { pushEnabled: boolean, emailEnabled: boolean }
8. Time Formatting
Condition
Format
Example
Today
"I dag kl. HH:MM"
"I dag kl. 14:32"
Yesterday
"I gar kl. HH:MM"
"I gar kl. 18:45"
Older
"DD.MM.YYYY kl. HH:MM"
"19.02.2026 kl. 09:00"
9. Platform Differences
Feature
Web
Mobile
Notification center
/notifications page with BottomNav
Not implemented
Bell icon badge
Dashboard header (unread count)
Not implemented
Push notifications
Not applicable (web push planned)
Not implemented (Expo Push planned)
Auto-read on view
Yes (marks all unread as read on page load)
N/A
Deep linking
URL-based routing
Not configured
Notification grouping
Date-based (I DAG, I GAR)
N/A
Permission handling
Browser Notification API
Expo Notifications API
10. Data Schema
10.1 Notifications Table
Column
Type
Description
id
TEXT PK
Format: noti_ + 16 hex chars
user_id
TEXT FK
References users(id)
type
TEXT
transaction_complete , qr_payment , security , rate_update
title
TEXT
Notification title (Norwegian)
body
TEXT
Notification body text
read
INTEGER
0 = unread, 1 = read
created_at
TEXT
ISO timestamp
10.2 API Endpoints
Method
Endpoint
Purpose
GET
/api/notifications
List all notifications for user
PATCH
/api/notifications
Mark notifications as read (max 100 IDs)
11. Accessibility Considerations (WCAG 2.1 AA)
Requirement
Implementation
Badge count
Bell icon badge uses aria-label "X uleste varsler"
Read/unread
Unread dot uses both visual indicator (blue dot) and aria attributes
Notification list
Semantic list markup with role="list"
Empty state
Descriptive text "Ingen varsler enna" with Bell icon
Time formatting
Relative time ("I dag kl. 14:32") for recent, absolute for older
Auto-read
Fire-and-forget PATCH does not interrupt user reading
Push permission
Clear explanation before requesting system permission
12. Cross-References
Notifications API: GET/PATCH /api/notifications — See API Reference
Settings API: PATCH /api/settings (push/email toggles) — See API Reference
Notifications schema: notifications table — See Database Schema
Settings schema: settings table — See Database Schema
Component overview: See component-overview.md
Figma notifications screen: mockups/figma-make-export/src/app/screens/Notifications.tsx
Web notifications page: src/drop-app/src/app/notifications/page.tsx — See PAGES.md
Profile settings flow: See flow-profile-settings.md
Low-Level Design Document
Low-Level Design Document
Project: Drop
Module/Component: Transactions Module (Remittance + QR Payment)
Version: 1.0
Date: 2026-02-23
Author: Petter Graff, Senior Enterprise Architect
Status: Approved
Reviewers: Alem Bašić (CEO), John (AI Director)
Related HLD: HLD Document
Document History
Version
Date
Author
Changes
0.1
2026-02-21
Banking Architecture Team
Initial draft from source code
1.0
2026-02-23
Petter Graff
Filled with real Drop data
1. Module Overview
Module: transactions
Service/Repo: drop-api — src/drop-api/src/routes/transactions.ts
Team Owner: ALAI — Backend
Single Responsibility: Processes all financial operations — remittance (international money transfer via PISP) and QR payments (domestic merchant payments via PISP) — using the PSD2 pass-through model where Drop never holds funds.
Boundaries:
Owns: Transaction records ( transactions table), exchange rates ( exchange_rates table), fee calculation, pre-payment disclosure, idempotency enforcement, PISP payment initiation orchestration
Does NOT own: User authentication (auth module), recipient management ( recipients table owned by recipients route), bank account balance display (bank_accounts route / AISP), merchant registration (merchants route)
Delegates to: BankID auth middleware (JWT validation), Open Banking PISP API (actual payment execution), audit_log (compliance side-effect), notifications (user alerting)
Key Business Rules:
Drop never deducts money from the Drop DB balance except as a cached AISP value — all real deductions happen at the user's bank via PISP
Every transaction requires kyc_status = 'approved' on the initiating user
Remittance amounts: 100 NOK minimum, 50,000 NOK maximum; fee = 0.5% of send amount
QR payments: fee = merchant fee_rate (default 1%); validated via HMAC QR code
idempotency_key (unique index 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 {
<>
+query(sql, params): T[]
+getOne(sql, params): T
+run(sql, params): RunResult
+transaction(fn): void
}
class PISPClient {
<>
+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_
Transaction identifier
user_id
TEXT
NO
—
FK → users(id)
Initiating user
type
TEXT
NO
—
CHECK('remittance','qr_payment')
Transaction type
status
TEXT
NO
'processing'
CHECK('processing','completed','failed')
Payment status
amount
REAL
NO
—
NOT NULL
Send amount in NOK (stored in øre equivalent)
currency
TEXT
YES
'NOK'
—
Source currency (always NOK at MVP)
fee
REAL
YES
0
—
Fee in NOK (0.5% remittance, merchant rate for QR)
recipient_id
TEXT
YES
NULL
FK → recipients(id)
For remittances; NULL for QR
merchant_id
TEXT
YES
NULL
FK → merchants(id)
For QR payments; NULL for remittance
send_amount
REAL
YES
NULL
—
Amount sent in source currency
receive_amount
REAL
YES
NULL
—
Amount received in destination currency
receive_currency
TEXT
YES
NULL
—
Destination currency (e.g., RSD, EUR)
exchange_rate
REAL
YES
NULL
—
Exchange rate at time of transaction
description
TEXT
YES
NULL
—
Optional user-provided description
idempotency_key
TEXT
YES
NULL
UNIQUE
Prevents duplicate payments on retry
created_at
TEXT
NO
datetime('now')
—
Transaction timestamp
Indexes:
Index Name
Columns
Type
Rationale
transactions_pkey
id
B-tree (PK)
Primary key lookup
idx_transactions_user
user_id
B-tree
Filter all transactions per user (high frequency)
idx_tx_idempotency
idempotency_key
Unique B-tree
Prevent duplicate payment on API retry
idx_transactions_recipient
recipient_id
B-tree
Lookup by recipient
idx_transactions_merchant
merchant_id
B-tree
Lookup by merchant
exchange_rates
Purpose: Stores current NOK-to-foreign currency exchange rates for the 6 supported remittance corridors.
Column
Type
Nullable
Default
Constraints
Description
id
INTEGER
NO
auto
PK
Surrogate key
from_currency
TEXT
NO
—
NOT NULL
Always 'NOK' at MVP
to_currency
TEXT
NO
—
NOT NULL
Target currency (RSD, BAM, PLN, PKR, TRY, EUR)
rate
REAL
NO
—
NOT NULL
Exchange rate: 1 NOK = N target currency units
updated_at
TEXT
YES
—
—
Last rate update timestamp
Indexes:
Index Name
Columns
Type
Rationale
idx_rates_currency
from_currency, to_currency
Composite B-tree
Fast rate lookup by currency pair
3.2 Enums (CHECK constraints in SQLite, native ENUMs in PostgreSQL migration)
-- transaction type
CHECK(type IN ('remittance', 'qr_payment'))
-- transaction status
CHECK(status IN ('processing', 'completed', 'failed'))
3.3 Migration Notes
Migration: Included in db.ts initializeDatabase() — runs on startup (SQLite) or via separate migration script (PostgreSQL)
Zero-downtime: YES — only INSERT and UPDATE status needed; CREATE INDEX CONCURRENTLY for PostgreSQL
Backfill required: NO — new tables
Estimated migration time: < 1 second (SQLite), < 5 seconds (PostgreSQL)
4. API Contract
Base Path: /v1/transactions
POST /v1/transactions/remittance
Summary: Initiate international money transfer (PISP) from user's bank account to a saved recipient
Authentication: Bearer JWT required ( authMiddleware )
Rate Limit: 10 req/60s per IP + 3 req/60s per user
Request Body:
{
"recipientId": "rec_abc123def456gh78",
"amount": 2000,
"bankAccountId": "ba_abc123def456gh78",
"currency": "NOK"
}
Success Response — 201 Created :
{
"data": {
"id": "tx_rem_abc123def456gh78",
"type": "remittance",
"status": "processing",
"amount": 2000,
"fee": 10,
"receiveAmount": 20340,
"receiveCurrency": "RSD",
"exchangeRate": 10.17,
"estimatedDelivery": "2-4 business days",
"scaRedirect": "https://dnb.no/sca/pay/abc123",
"createdAt": "2026-02-23T10:00:00.000Z"
}
}
Error Responses:
Status
Code
Description
400
validation_error
Missing or invalid fields (amount, recipientId)
401
unauthorized
Missing or expired JWT
403
kyc_required
User kyc_status is not approved
403
insufficient_balance
Cached AISP balance < amount + fee
404
recipient_not_found
recipientId does not belong to this user
409
duplicate_transaction
Idempotency key collision — returns existing transaction
422
amount_out_of_range
Amount < 100 NOK or > 50,000 NOK
429
rate_limited
Exceeded 10 req/60s per IP or 3 req/60s per user
502
pisp_unavailable
Open Banking PISP API unreachable
500
internal_error
Unexpected server error
POST /v1/transactions/qr-payment
Summary: Initiate QR merchant payment (PISP) from user's bank account
Authentication: Bearer JWT required
Request Body:
{
"merchantId": "mer_abc123def456gh78",
"amount": 450
}
Success Response — 201 Created :
{
"data": {
"id": "tx_qr_abc123def456gh78",
"type": "qr_payment",
"status": "completed",
"amount": 450,
"fee": 4.5,
"merchantName": "Café Oslo AS",
"createdAt": "2026-02-23T10:00:00.000Z"
}
}
Error Responses:
Status
Code
Description
400
validation_error
Missing or invalid fields
401
unauthorized
Missing or expired JWT
403
kyc_required
User kyc_status not approved
404
merchant_not_found
merchantId not found or inactive
500
internal_error
Unexpected error
POST /v1/transactions/disclosure
Summary: Pre-payment disclosure — returns fee, exchange rate, receive amount (PSD2 Art. 45/46 compliance)
Authentication: Bearer JWT required
Request Body:
{
"type": "remittance",
"amount": 2000,
"recipientId": "rec_abc123def456gh78"
}
Success Response — 200 OK :
{
"data": {
"sendAmount": 2000,
"sendCurrency": "NOK",
"fee": 10,
"feePercentage": 0.5,
"exchangeRate": 10.17,
"receiveAmount": 20340,
"receiveCurrency": "RSD",
"totalCost": 2010,
"estimatedDelivery": "2-4 business days"
}
}
GET /v1/transactions
Summary: List authenticated user's transactions with pagination
Query Parameters:
Parameter
Type
Default
Description
page
integer
1
Page number (1-based)
limit
integer
20
Items per page (max 50)
type
string
—
Filter: remittance or qr_payment
status
string
—
Filter: processing , completed , failed
Success Response — 200 OK :
{
"data": {
"transactions": [
{
"id": "tx_rem_abc123",
"type": "remittance",
"status": "completed",
"amount": 2000,
"fee": 10,
"receiveAmount": 20340,
"receiveCurrency": "RSD",
"recipientName": "Marko Petrovic",
"createdAt": "2026-02-23T10:00:00.000Z"
}
],
"total": 15,
"page": 1,
"limit": 20
}
}
5. Algorithm Specifications
5.1 Fee Calculation — Remittance
Purpose: Calculate 0.5% fee on remittance, rounded to 2 decimal places
Complexity: Time O(1) | Space O(1)
function calculateRemittanceFee(sendAmountNOK: number): number
FEE_RATE = 0.005 // 0.5%
fee = sendAmountNOK * FEE_RATE
return Math.round(fee * 100) / 100 // Round to 2 decimal places
function calculateReceiveAmount(sendAmountNOK: number, exchangeRate: number): number
netSend = sendAmountNOK // Fee taken from send amount, not receive
receive = netSend * exchangeRate
return Math.round(receive) // Round to whole units of target currency
Edge Cases:
Minimum amount: 100 NOK → fee = 0.50 NOK
Maximum amount: 50,000 NOK → fee = 250 NOK
Rate not found: Return 404 — do not proceed to transaction
5.2 Idempotency Key Generation
Purpose: Prevent double-charging on network retry or duplicate form submission
Format: {userId}:{amount}:{recipientId}:{minuteTimestamp}
function generateIdempotencyKey(userId, amount, recipientId): string
minuteTimestamp = Math.floor(Date.now() / 60000) // Changes every 60s
key = `${userId}:${amount}:${recipientId}:${minuteTimestamp}`
return key
// Unique index on transactions.idempotency_key prevents duplicate
// If INSERT fails with UNIQUE constraint → return existing transaction
6. Sequence Diagrams
6.1 Remittance Initiation Flow
sequenceDiagram
autonumber
actor Client as Client (Web/Mobile)
participant RL as Rate Limiter
participant Auth as Auth Middleware
participant Route as Transactions Route
participant DB as Database
participant PISP as Open Banking PISP
Client->>RL: POST /v1/transactions/remittance
RL->>RL: Check rate_limits (10/IP, 3/user per 60s)
alt Rate limit exceeded
RL-->>Client: 429 Too Many Requests
end
RL->>Auth: Forward request
Auth->>Auth: Extract JWT from Bearer header / cookie
Auth->>DB: SELECT session WHERE token_hash = ? AND revoked = 0
Auth->>DB: SELECT user WHERE id = ? AND deleted_at IS NULL
Auth-->>Route: user context {userId, role, kycStatus}
Route->>Route: Validate body: recipientId, amount (100-50000), bankAccountId
alt Validation fails
Route-->>Client: 400 validation_error
end
alt kyc_status != 'approved'
Route-->>Client: 403 kyc_required
end
Route->>DB: SELECT * FROM recipients WHERE id = ? AND user_id = ?
alt Recipient not found
Route-->>Client: 404 recipient_not_found
end
Route->>DB: SELECT rate FROM exchange_rates WHERE to_currency = ?
Route->>DB: SELECT * FROM bank_accounts WHERE id = ? AND user_id = ? AND is_primary = 1
Route->>Route: Calculate fee (0.5%), total cost, receive amount
alt balance < totalCost
Route-->>Client: 403 insufficient_balance
end
Route->>DB: BEGIN TRANSACTION
Route->>DB: UPDATE bank_accounts SET balance = balance - totalCostInOere WHERE balance >= ?
Route->>DB: INSERT INTO transactions (status='processing', idempotency_key=?)
Route->>DB: INSERT INTO audit_log (action='transaction.create')
Route->>DB: INSERT INTO notifications (title='Overføring startet')
Route->>DB: COMMIT
Route->>PISP: POST /v1/payments/cross-border-credit-transfers
PISP-->>Route: {paymentId, transactionStatus: "RCVD", scaRedirect}
Route-->>Client: 201 {transactionId, status: "processing", scaRedirect}
6.2 QR Payment Flow
sequenceDiagram
autonumber
actor Client as Client (Mobile)
participant Route as Transactions Route
participant DB as Database
Client->>Route: POST /v1/transactions/qr-payment {merchantId, amount}
Route->>DB: Verify JWT session
Route->>DB: SELECT * FROM merchants WHERE id = ? AND status = 'active'
alt Merchant not found
Route-->>Client: 404 merchant_not_found
end
Route->>DB: SELECT * FROM bank_accounts WHERE user_id = ? AND is_primary = 1
Route->>Route: Calculate fee = amount * merchant.fee_rate
Route->>Route: Calculate total = amount + fee
Route->>DB: BEGIN TRANSACTION
Route->>DB: UPDATE bank_accounts SET balance = balance - total
Route->>DB: INSERT INTO transactions (type='qr_payment', status='completed')
Route->>DB: INSERT INTO audit_log (action='qr_payment.create')
Route->>DB: INSERT INTO notifications (title='Betaling registrert')
Route->>DB: COMMIT
Route-->>Client: 201 {transactionId, status: "completed", merchantName}
7. State Diagrams
stateDiagram-v2
[*] --> processing: POST /v1/transactions/remittance (PISP initiated)
processing --> completed: PISP webhook — payment confirmed by bank
processing --> failed: PISP webhook — payment rejected (insufficient funds, SCA timeout, SCA cancelled)
processing --> failed: 5-minute SCA timeout — no callback received
completed --> [*]
failed --> [*]
Note: QR payments go directly from creation to completed (synchronous in MVP — no PISP webhook for domestic transfers in mock mode).
State Transition Rules:
From
To
Trigger
Guard Condition
Side Effect
(none)
processing
POST /v1/transactions/remittance
KYC approved, balance sufficient
Deduct cached balance, create audit log, send notification
processing
completed
PISP webhook or QR sync completion
paymentId matches transaction
Update status, send completion notification
processing
failed
PISP webhook rejection or 5-min timeout
paymentId matches, status RJCT
Restore cached balance (re-sync AISP), send failure notification
8. Error Handling Strategy
8.1 Error Classification
Error Type
HTTP Status
Retry?
Log Level
Alert?
ValidationError
400
No
INFO
No
UnauthorizedError
401
No
WARN
No
KYCRequired
403
No
INFO
No
InsufficientBalance
403
No
INFO
No
RecipientNotFound
404
No
INFO
No
DuplicateTransaction
409
No
INFO
No
AmountOutOfRange
422
No
INFO
No
RateLimited
429
After Retry-After
WARN
No
PISPUnavailable
502
Yes (3x backoff)
ERROR
Yes (if sustained > 5 min)
DatabaseError
500
Yes (1x)
ERROR
Yes
UnexpectedError
500
No
ERROR
Yes
8.2 Error Response Format
{
"error": "kyc_required",
"message": "Du må fullføre identitetsverifisering før du kan sende penger.",
"details": []
}
8.3 Retry & Fallback Strategy
PISP API call failure:
→ Retry with exponential backoff: [1s, 2s, 4s]
→ Max retries: 3
→ Circuit breaker: Open after 3 failures in 60s window → 60s cooldown
→ Fallback: Return 502 to client — payment cannot proceed without PISP
→ Alert: Sentry alert if circuit remains open > 5 minutes
→ Idempotency: PISP call uses X-Request-ID = idempotency_key to prevent double-payment on retry
9. Concurrency & Thread Safety
Concern
Scenario
Mitigation
Double payment
Client retries POST /v1/transactions/remittance after network timeout
Unique index on idempotency_key — second INSERT fails with UNIQUE constraint → return existing transaction
Balance race condition
Two simultaneous payments from same account
DB transaction with UPDATE bank_accounts SET balance = balance - X WHERE balance >= X — atomic check-and-deduct
Exchange rate staleness
Rate changes between disclosure and payment
Rate locked at payment initiation time; user sees pre-payment disclosure; rate used is from DB at payment time
10. Performance Considerations
Operation
Target (p99)
Current Baseline
Optimization
POST /v1/transactions/remittance
< 500ms (local) + PISP latency
~50ms DB operations
DB transaction atomic; PISP call is async from user perspective
GET /v1/transactions
< 100ms
~20ms
Index idx_transactions_user on user_id ; pagination limits result set
POST /v1/transactions/disclosure
< 50ms
~10ms
Two DB reads (recipient + exchange rate); no external API call
POST /v1/transactions/qr-payment
< 200ms
~40ms
Synchronous completion in mock mode; PISP async in production
Known bottlenecks:
PISP API latency: 200-2000ms external call — mitigated by async SCA redirect pattern
Exchange rate reads: High frequency but 6 rows only — fully cached in PostgreSQL buffer pool
11. Dependencies
Internal Dependencies
Dependency
Type
Purpose
Fallback if unavailable
middleware/auth.ts
Synchronous
JWT validation + user context
None — request rejected with 401
middleware/rate-limit.ts
Synchronous
IP + user rate limiting
None — request rejected with 429
lib/db.ts
Required
All data access (query, run, transaction)
None — module unavailable
External Dependencies
Dependency
Version
Purpose
Fallback if unavailable
PostgreSQL
16
Primary data store
SQLite (dev only)
Open Banking PISP (Neonomics/ASPSP)
Berlin Group v1.3.12+
Payment initiation
None — return 502, payment cannot proceed
Open Banking AISP
Berlin Group v1.3.12+
Pre-payment balance verification
Use cached bank_accounts.balance with staleness warning
12. Configuration Parameters
Variable
Type
Default
Required
Description
DATABASE_URL
string
—
No (SQLite default)
PostgreSQL connection string
NEXT_PUBLIC_SERVICE_MODE
string
mock
No
mock = simulate PISP; production = real PISP calls
OPEN_BANKING_API_URL
string
—
Yes (prod)
Neonomics or ASPSP base URL
OPEN_BANKING_CLIENT_ID
string
—
Yes (prod)
eIDAS client identifier
OPEN_BANKING_CLIENT_SECRET
string
—
Yes (prod)
eIDAS client secret
13. Testing Approach
Test Type
Tool
Coverage Target
Location
Unit tests
Vitest
> 80% business logic
src/drop-api/src/__tests__/unit/transactions/
Integration tests
Supertest
Key payment flows
src/drop-api/src/__tests__/integration/transactions/
Key test scenarios:
Remittance — success path (mock PISP)
Remittance — KYC not approved → 403
Remittance — amount < 100 NOK → 422
Remittance — amount > 50,000 NOK → 422
Remittance — recipient not found → 404
Remittance — duplicate request (idempotency key) → 409 with existing transaction
QR payment — success path
QR payment — merchant inactive → 404
Disclosure — calculates fee, exchange rate, receive amount correctly
PISP circuit breaker — 3 failures → open → 502 (integration test, Phase 2)
Concurrent payment — balance race condition handled correctly (Phase 2)
Approval
Role
Name
Date
Signature
Author
Petter Graff
2026-02-23
Module Owner
John (AI Director)
Security Review
Tech Lead
John (AI Director)
LLD: Withdrawal Flow
Withdrawal Request Flow (Angrerett)
Purpose
Implements the user's right of withdrawal (angrerett) as required by Norwegian consumer protection law (angrerettloven). Users can submit a withdrawal request to cancel their account or service agreement within the statutory cooling-off period.
Sequence Diagram
sequenceDiagram
participant U as User (App)
participant API as Drop API
participant Auth as Auth Middleware
participant DB as PostgreSQL
participant Audit as Audit Log
U->>API: POST /withdrawal { reason, comment }
API->>Auth: Validate JWT token
Auth-->>API: user context
alt Invalid JSON body
API-->>U: 400 bad_request
end
API->>API: Sanitize reason (max 100 chars)
API->>API: Sanitize comment (max 1000 chars)
API->>API: Validate reason against VALID_REASONS
alt Invalid reason
API-->>U: 400 validation_error
end
API->>DB: INSERT INTO withdrawal_requests (id, user_id, reason, comment)
DB-->>API: OK
API->>Audit: Log WITHDRAWAL_REQUEST action
Audit->>DB: INSERT INTO audit_log
API-->>U: 201 { success: true, id }
Database Schema
withdrawal_requests table
Column
Type
Constraints
id
TEXT
PRIMARY KEY (prefix: wr_ )
user_id
TEXT
NOT NULL, REFERENCES users(id)
reason
TEXT
Nullable
comment
TEXT
Nullable
status
TEXT
DEFAULT 'pending', CHECK IN ('pending','processing','completed','rejected')
created_at
TIMESTAMPTZ
DEFAULT NOW()
Index: idx_withdrawal_requests_user on user_id .
Valid Withdrawal Reasons
Value
Description
not_needed
User no longer needs the service
alternative
User found an alternative service
not_satisfied
User is not satisfied with the service
other
Other reason (details in comment field)
"" (empty)
No reason provided
Request Processing
Authentication -- request must include a valid JWT token (authMiddleware).
Input validation -- reason is checked against the allowlist; both reason and comment are sanitized via sanitizeText with length limits.
Record creation -- a new withdrawal_requests row is inserted with status pending .
Audit logging -- an audit log entry is created with action WITHDRAWAL_REQUEST , including the reason and the requester's IP address.
Status Lifecycle
pending --> processing --> completed
\-> rejected
pending -- initial state after user submits request.
processing -- staff/admin has begun reviewing the request.
completed -- withdrawal has been executed, account closed or service cancelled.
rejected -- request was denied (e.g., outside cooling-off period, regulatory hold).
Error States
Scenario
HTTP Status
Error Code
Missing/invalid JWT
401
unauthorized
Malformed JSON body
400
bad_request
Invalid reason value
400
validation_error
Database write failure
500
internal_error
Edge Cases
Duplicate requests -- no uniqueness constraint on user_id; a user can submit multiple withdrawal requests. Business logic should handle deduplication at the review stage.
Already deleted user -- the foreign key on user_id ensures the user must exist. If the user record has deleted_at set, the auth middleware should reject the request before it reaches this route.
AML retention -- even after withdrawal is completed, transaction records and AML-related data must be retained for 5 years per hvitvaskingsloven. The data retention cron ( /cron/retention ) handles anonymization after the retention period expires.
Cross-References
Angrerettloven -- Norwegian Act on the Right of Withdrawal (consumer protection).
Data retention -- See src/drop-api/src/routes/cron.ts retention endpoint and docs/architecture/lld/flow-kyc-aml.md for AML retention requirements.
Audit logging -- See src/drop-api/src/lib/audit.ts for audit log implementation.
LLD: Middleware Lifecycle Flow
Middleware Lifecycle — Low-Level Design
Document: LLD-MIDDLEWARE
Status: Approved
Last updated: 2026-02-21
Author: Standards Architect
Applies to: Drop API (Hono) — src/drop-api/src/app.ts
Overview
The Drop API uses Hono as its HTTP framework. Middleware is organized into two layers: global middleware applied to every request via app.use("*") , and per-route middleware applied within individual route handlers. This document describes the complete execution order.
Source of truth: src/drop-api/src/app.ts
Middleware Execution Order
flowchart TD
A["Incoming HTTP Request"] --> B["1. CORS Middleware\n(global)"]
B --> C["2. Request ID Middleware\n(global)"]
C --> D["3. Client IP Middleware\n(global)"]
D --> E["4. Route Matching\n(/v1/* or /api/*)"]
E --> F{Route found?}
F -->|No| G["404 Not Found"]
F -->|Yes| H["5. Per-Route Middleware\n(auth / rateLimit / featureGate)"]
H --> I["6. Route Handler"]
I --> J["Response"]
I -.->|Error thrown| K["Global Error Handler\n(app.onError)"]
K --> J
style A fill:#f5f5f5,stroke:#333
style B fill:#ffd93d,stroke:#333
style C fill:#ffd93d,stroke:#333
style D fill:#ffd93d,stroke:#333
style H fill:#6bcb77,stroke:#333
style I fill:#4d96ff,stroke:#333,color:#fff
style K fill:#ff6b6b,stroke:#333,color:#fff
Global Middleware (Applied to Every Request)
These are registered in app.ts with app.use("*") and execute in registration order, top-to-bottom.
1. CORS ( hono/cors )
Source: app.ts:23-30
Configures Cross-Origin Resource Sharing headers for browser-based clients.
Setting
Value
Allowed origins
http://localhost:3000 , http://localhost:3001 , process.env.APP_URL
Credentials
true (cookies sent cross-origin)
The credentials: true setting is required because the web app sends JWT tokens in httpOnly cookies. Empty strings from unset env vars are filtered out.
2. Request ID
Source: app.ts:33-38
Generates or propagates a unique request identifier for distributed tracing.
Behavior
Detail
Header checked
x-request-id
Fallback
crypto.randomUUID()
Context variable
c.get("requestId")
Response header
x-request-id (echoed back)
Downstream middleware and route handlers access the request ID via c.get("requestId") for structured logging and audit trails.
3. Client IP
Source: app.ts:41-47
Extracts the originating client IP address from proxy headers.
Priority
Header
Processing
1st
x-real-ip
Trimmed
2nd
x-forwarded-for
First entry, trimmed
Fallback
—
127.0.0.1
The extracted IP is stored as c.get("clientIp") and used by rate limiting and audit logging.
Note: The rate-limit.ts module also exports a getClientIp(c) helper that performs the same extraction. Some route handlers use getClientIp(c) directly instead of c.get("clientIp") .
4. Global Error Handler
Source: app.ts:50 , middleware/error-handler.ts:16-23
Registered via app.onError(globalErrorHandler) . This is not middleware in the traditional sense — it is an error boundary that catches any unhandled exceptions thrown during request processing.
Error Type
Response
HTTPException (Hono)
Returns the exception's status and message
All other errors
Logs via logger.error , reports to Sentry via captureError , returns 500 Internal Server Error with generic message
The error handler never leaks stack traces or internal details to the client.
Route Mounting
Source: app.ts:53-72
All API routes are mounted under a versioned prefix:
Mount Point
Purpose
/v1/*
Primary API path (mobile + new clients)
/api/*
Backward compatibility during migration
Both mount points serve the identical route handlers — /api is an alias for /v1 .
Mounted Route Groups
Path
Route Module
Primary Middleware
/v1/auth
authRoutes
Rate limiting (inline)
/v1/health
healthRoutes
None (public)
/v1/transactions
transactionRoutes
authMiddleware + rate limiting
/v1/recipients
recipientRoutes
authMiddleware
/v1/rates
rateRoutes
None (public)
/v1/cards
cardRoutes
authMiddleware + feature gate
/v1/merchants
merchantRoutes
merchantMiddleware
/v1/settings
settingsRoutes
authMiddleware
/v1/notifications
notificationRoutes
authMiddleware
/v1/user
userRoutes
authMiddleware
/v1/admin
adminRoutes
adminMiddleware + rate limiting
/v1/consents
consentRoutes
authMiddleware
/v1/complaints
complaintRoutes
authMiddleware
/v1/cron
cronRoutes
Varies
/v1/withdrawal
withdrawalRoutes
authMiddleware
Per-Route Middleware
Per-route middleware is applied within individual route files, not globally. It executes after the global middleware chain.
Authentication Middleware ( middleware/auth.ts )
Three variants, all following the same pattern: extract JWT, verify token + session, set c.set("user", ...) .
Middleware
Role Check
Used By
authMiddleware
Any authenticated user
Most routes (transactions, recipients, settings, etc.)
merchantMiddleware
role === 'merchant'
Merchant routes
adminMiddleware
role === 'admin'
Admin routes (audit, screening, STR)
Flow:
Extract bearer token from Authorization header or cookie
Verify JWT signature (HS256) and check session in sessions table
If invalid or expired: return 401 Unauthorized
If role mismatch (merchant/admin variants): return 403 Forbidden
Set c.set("user", authUser) for downstream handlers
Rate Limiting ( middleware/rate-limit.ts )
Rate limiting is not a Hono middleware function — it is a utility called inline within route handlers.
// Example from transactions.ts
if (!(await rateLimit(ip, 10))) {
return c.json({ error: "rate_limited", message: "Too many requests" }, 429);
}
Parameter
Description
ip
Rate limit key (usually client IP, sometimes user:{id} )
limit
Maximum requests per window
windowMs
Window duration in ms (default: 60000 = 1 minute)
Rate limit state is persisted in the rate_limits database table (SQLite/PostgreSQL). Expired entries are cleaned up every 100 checks.
Per-endpoint limits:
Endpoint
Key
Limit
Window
POST /transactions/remittance
IP
10/min
60s
POST /transactions/remittance
user:{id}
3/min
60s
POST /transactions/qr-payment
IP
10/min
60s
POST /transactions/qr-payment
user:{id}
3/min
60s
GET /admin/audit
IP
30/min
60s
GET /admin/screening
IP
30/min
60s
POST /admin/screening
IP
10/min
60s
GET /admin/str
IP
30/min
60s
POST /admin/str
IP
10/min
60s
PATCH /admin/str
IP
10/min
60s
Feature Gates ( lib/feature-flags.ts )
Feature gates control access to unreleased functionality. Like rate limiting, they are called inline within route handlers, not as Hono middleware.
// Example from cards.ts
if (!isEnabled("virtualCards")) {
return c.json({ error: "not_found", message: "Feature not available" }, 404);
}
Flag
Default
Controls
virtualCards
false
Card creation, listing, detail, cancellation
physicalCards
false
Physical card ordering
cardDetails
false
Card detail endpoint
cardFreeze
false
Card freeze/unfreeze
cardPin
false
Card PIN management
spendingLimits
false
Spending limit management
notifications
true
Notification endpoints
merchantDashboard
true
Merchant dashboard
Flags are read from environment variables ( FF_VIRTUAL_CARDS=true ) with fallback to compiled defaults. The featureGate() helper throws an HTTPException(404) for disabled features, which the global error handler catches.
Complete Request Lifecycle (Sequence Diagram)
sequenceDiagram
participant Client
participant CORS as CORS Middleware
participant ReqID as Request ID Middleware
participant IP as Client IP Middleware
participant Router as Hono Router
participant Auth as Auth Middleware
participant RL as Rate Limiter
participant FG as Feature Gate
participant Handler as Route Handler
participant DB as Database
participant ErrH as Error Handler
Client->>CORS: HTTP Request
CORS->>CORS: Check origin, set CORS headers
CORS->>ReqID: next()
ReqID->>ReqID: Extract/generate x-request-id
ReqID->>IP: next()
IP->>IP: Extract client IP from headers
IP->>Router: next()
Router->>Router: Match route (/v1/* or /api/*)
alt Public route (health, rates)
Router->>Handler: Direct execution
else Authenticated route
Router->>Auth: authMiddleware / adminMiddleware / merchantMiddleware
Auth->>DB: Verify JWT + session
alt Token invalid
Auth-->>Client: 401 Unauthorized
else Token valid
Auth->>Auth: Set c.user
Auth->>RL: Check rate limit (inline)
alt Rate exceeded
RL-->>Client: 429 Too Many Requests
else Within limit
RL->>FG: Check feature flag (if applicable)
alt Feature disabled
FG-->>Client: 404 Feature not available
else Feature enabled
FG->>Handler: Execute route logic
Handler->>DB: Query/mutation
Handler-->>Client: JSON response
end
end
end
end
Note over Handler,ErrH: If any error is thrown
Handler-->>ErrH: Unhandled error
ErrH->>ErrH: Log + Sentry report
ErrH-->>Client: 500 Internal Server Error
Input Validation
Input validation is not middleware — it is a collection of utility functions in middleware/validation.ts called directly by route handlers.
Function
Purpose
Used By
sanitizeText(text, maxLength)
Strip HTML tags, control characters, truncate
All text input fields
validatePhone(phone)
International phone format ( + prefix, 8-15 digits)
User profile
validateAmount(amount)
Positive number, max 2 decimal places
Transactions
validateIBAN(iban)
ISO 13616 IBAN checksum validation
Bank accounts
validatePIN(pin)
Exactly 4 digits
Card PIN
validateEmail(email)
Basic email format
Registration
validateCurrency(currency)
Whitelist: EUR, USD, GBP, BAM, CHF, PLN, NOK, RSD, TRY, PKR
Transactions
validateName(name)
Non-empty, contains letters, no script injection
Recipients
validateLanguage(lang)
Whitelist: nb, en, bs, sq
Settings
auditLog(...)
Insert audit trail record
All significant actions
Cross-References
Security Architecture — Trust boundaries, STRIDE, application security controls
Authentication — JWT, session management, BankID OIDC
API Reference — Endpoint specifications and security requirements
Login Authentication Flow — BankID OIDC authentication detail