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