# Low-Level Design (LLD)

Detailed flow diagrams and implementation details

# Login & Authentication Flow

# Flow: Login & Authentication

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

---

## 1. Overview

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

**Key facts:**
- BankID is mandatory for production (PSD2/SCA compliance)
- Demo mode provides email/password fallback for development
- JWT lifetime: 7d (all clients)
- Session tracking via SHA-256 token hash in `sessions` table
- Age verification (>= 18) enforced during BankID callback

---

## 2. Web Login Flow (BankID OIDC)

### 2.1 Sequence Diagram — Web BankID Login

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

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

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

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

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

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

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

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

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

### 2.2 Demo Mode Login (Development Only)

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

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

    User->>Browser: Click "Demo Login"

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

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

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

---

## 3. Mobile Login Flow (BankID OIDC)

### 3.1 Sequence Diagram — Mobile BankID Login

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

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

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

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

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

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

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

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

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

---

## 4. Authentication State Diagram

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

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

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

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

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

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

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

---

## 5. Error States

### 5.1 Error State Table

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

### 5.2 Error Handling by Platform

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

---

## 6. Session Management

### 6.1 Token Storage

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

### 6.2 Session Lifecycle

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

### 6.3 Token Refresh

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

1. Reads `drop_token` cookie / Bearer token
2. Verifies current JWT and session validity
3. Revokes old session (`UPDATE sessions SET revoked = 1`)
4. Creates new session + JWT
5. Sets new `drop_token` cookie (Max-Age=604800, HttpOnly, SameSite=Lax)
6. Returns new user data

### 6.4 Deprecated Endpoints

The following endpoints return `410 Gone` and exist only for backward compatibility:

| Endpoint | Status | Reason |
|----------|--------|--------|
| `POST /v1/auth/login` | 410 Gone | Replaced by BankID OIDC flow |
| `POST /v1/auth/register` | 410 Gone | User creation is automatic on first BankID login |
| `POST /v1/auth/verify-otp` | 410 Gone | OTP flow removed with BankID migration |

### 6.5 useAuth Hook (Web)

The `useAuth()` hook on the web client:
1. Calls `GET /api/auth/me` on mount
2. If 401 → redirects to `/login`
3. Returns `{ user, loading }` to the page component
4. Every authenticated page wraps content with `if (loading) return <Skeleton />`

---

## 7. UI Components Involved

### 7.1 Web Login Page (`/login`)

| Component | Source | Purpose |
|-----------|--------|---------|
| `Image` | next/image | Drop logo display |
| `Link` | next/link | Navigation to /register, /dashboard |
| `Button` | shadcn/ui | Submit button, social login buttons |
| `Mail` | lucide-react | Email input icon |
| `Lock` | lucide-react | Password input icon |
| `Eye` / `EyeOff` | lucide-react | Password visibility toggle |
| `ArrowRight` | lucide-react | Submit button icon |

### 7.2 Mobile Login Screen (`login.js`)

| Element | Implementation | Purpose |
|---------|---------------|---------|
| Email input | `<TextInput>` | Email entry |
| Password input | `<TextInput secureTextEntry>` | Password entry |
| Login button | `<TouchableOpacity>` | Trigger `api.login()` |
| Register link | `router.push("/register")` | Navigate to registration |

---

## 8. Accessibility Considerations (WCAG 2.1 AA)

| Requirement | Implementation |
|-------------|---------------|
| Form labels | All inputs have associated `<label>` elements |
| Error announcements | Error messages use `role="alert"` for screen readers |
| Focus management | After error, focus returns to the first invalid field |
| Color contrast | Error red (#EF4444) on white background meets 4.5:1 ratio |
| Keyboard navigation | Tab order follows visual order: email → password → submit → social buttons |
| Password visibility | Toggle button has aria-label "Vis passord" / "Skjul passord" |
| Loading state | Submit button shows loading spinner and is disabled during request |

---

## 9. Cross-References

- **BankID OIDC integration details:** See [Authentication](../../backend/AUTHENTICATION.md)
- **API endpoints:** `GET /v1/auth/bankid/initiate`, `POST /v1/auth/bankid/callback`, `POST /v1/auth/demo-login`, `POST /v1/auth/refresh`, `GET /api/auth/me` — See [API Reference](../../backend/API-REFERENCE.md)
- **Session database schema:** `sessions` table — See [Database Schema](../../backend/DATABASE-SCHEMA.md)
- **Component overview:** See [component-overview.md](../hld/component-overview.md)
- **Figma login screen:** `mockups/figma-make-export/src/app/screens/Login.tsx`
- **Web login page:** `src/drop-app/src/app/login/page.tsx` — See [PAGES.md](../../frontend/PAGES.md)
- **Registration flow:** See [flow-registration-onboarding.md](flow-registration-onboarding.md)

# Login & Authentication (Backend)

# Login & Authentication — Backend Architecture

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

---

## JWT Token Structure

### Token Generation

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

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

### JWT Claims

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

### Algorithm Selection

| Algorithm | Condition | Key Source | Use Case |
|-----------|-----------|-----------|----------|
| **HS256** (default) | `JWT_RS256_PRIVATE_KEY` not set | `JWT_SECRET` env var → `TextEncoder.encode()` | Standard deployment |
| **RS256** (opt-in) | Both `JWT_RS256_PRIVATE_KEY` and `JWT_RS256_PUBLIC_KEY` set | PEM-encoded RSA key pair | Multi-service verification (API gateway, microservices) |

**Source:** `auth.ts:12-34` — `getAlgorithm()` auto-detects based on available keys.

### Token Verification

**Source:** `auth.ts:50-66`

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

---

## JWT Refresh Flow

```mermaid
sequenceDiagram
    participant Client as Client (Web/Mobile)
    participant API as drop-api
    participant DB as Database

    Client->>API: POST /v1/auth/refresh<br/>Authorization: Bearer <current-jwt>

    API->>API: Extract token from Bearer header or cookie
    API->>API: Verify JWT signature (jose)
    API->>DB: SELECT session WHERE token_hash = SHA256(token)<br/>AND revoked = 0 AND expires_at > NOW()
    DB-->>API: Session valid

    API->>DB: SELECT user WHERE id = userId AND deleted_at IS NULL
    DB-->>API: User record

    Note over API: Revoke ALL existing sessions
    API->>DB: UPDATE sessions SET revoked = 1<br/>WHERE user_id = ?

    Note over API: Create new session
    API->>API: Sign new JWT (7d expiry)
    API->>DB: INSERT INTO sessions<br/>(id, user_id, token_hash, expires_at)

    Note over API: Set cookie for web clients
    API->>API: Set-Cookie: drop_token=<new-jwt>;<br/>HttpOnly; Path=/; Max-Age=604800; SameSite=Lax

    API-->>Client: { data: { id, email, firstName, ... }, token: "<new-jwt>" }
```

### Refresh Behavior

**Source:** `routes/auth.ts:201-210`

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

**Key design decision:** Token refresh performs a full session rotation — old sessions are invalidated immediately. This limits the window for token theft: a stolen token becomes invalid as soon as the legitimate user refreshes.

---

## Session Revocation

### Session Revocation Flow

```mermaid
sequenceDiagram
    participant Client as Client
    participant API as drop-api
    participant DB as Database

    alt Logout (user-initiated)
        Client->>API: POST /v1/auth/logout<br/>Authorization: Bearer <jwt>
        API->>API: Verify token (authMiddleware)
        API->>DB: UPDATE sessions SET revoked = 1<br/>WHERE user_id = ?
        Note over DB: ALL sessions for this user revoked
        API->>DB: INSERT INTO audit_log<br/>(action: 'logout', resource_type: 'session')
        API->>API: Set-Cookie: drop_token=; Max-Age=0
        API-->>Client: { data: { message: "Logged out" } }

    else Security incident (admin-initiated)
        Note over API: Admin detects compromised account
        API->>DB: UPDATE sessions SET revoked = 1<br/>WHERE user_id = ?
        API->>DB: INSERT INTO audit_log<br/>(action: 'security_revocation')
        Note over Client: Next request fails auth check
        Client->>API: Any authenticated request
        API->>DB: SELECT session WHERE token_hash = ?<br/>AND revoked = 0
        DB-->>API: No valid session found
        API-->>Client: 401 Unauthorized

    else Token refresh (rotation)
        Client->>API: POST /v1/auth/refresh
        API->>DB: UPDATE sessions SET revoked = 1<br/>WHERE user_id = ?
        Note over DB: Old sessions invalidated
        API->>DB: INSERT INTO sessions (new session)
        API-->>Client: { token: "<new-jwt>" }
    end
```

### Session Verification on Every Request

**Source:** `auth.ts:108-117`

Every authenticated request performs these checks:

1. **Token signature verification** — JWT must be valid and not expired
2. **Session lookup** — `SELECT id FROM sessions WHERE token_hash = SHA256(token) AND revoked = 0 AND expires_at > NOW()`
3. **Session count check** — If user has any sessions in DB but none match the current token, reject (prevents use of tokens from before session tracking was enabled)
4. **User existence check** — `SELECT * FROM users WHERE id = ? AND deleted_at IS NULL` (soft-deleted users are blocked)

### Session Table Schema

| Column | Type | Description |
|--------|------|-------------|
| `id` | TEXT PK | Format: `ses_<16 hex chars>` |
| `user_id` | TEXT FK | References `users.id` |
| `token_hash` | TEXT | SHA-256 hash of the JWT string |
| `created_at` | TEXT | ISO timestamp of session creation |
| `expires_at` | TEXT | ISO timestamp, 7 days from creation |
| `revoked` | INTEGER | `0` = active, `1` = revoked |

**Indexes:** `idx_sessions_user` (user_id), `idx_sessions_token` (token_hash)

---

## Rate Limiting on Auth Endpoints

### Rate Limit Configuration

| Endpoint | Limit | Window | Source |
|----------|-------|--------|--------|
| `GET /v1/auth/bankid/initiate` | 10 requests | 60 seconds | `routes/auth.ts:19` |
| `POST /v1/auth/bankid/callback` | 10 requests | 60 seconds | `routes/auth.ts:43` |
| `POST /v1/auth/demo-login` | Inherits from service mode check | N/A | Only available in demo mode |
| `GET /v1/auth/me` | No additional rate limit | N/A | Auth required (implicit protection) |
| `POST /v1/auth/logout` | No additional rate limit | N/A | Auth required |
| `POST /v1/auth/refresh` | No additional rate limit | N/A | Auth required |

### Rate Limiting Implementation

**Source:** `middleware/rate-limit.ts:7-23`

```
Storage: rate_limits table (persistent across restarts)
Key: Client IP address
Algorithm: Fixed window counter
Cleanup: Every 100 requests, expired entries are deleted
Atomic: Uses runUpsert for race-condition-safe counter updates
```

The rate limiter uses the `rate_limits` database table:

| Column | Type | Description |
|--------|------|-------------|
| `key` | TEXT PK | Client IP address |
| `count` | INTEGER | Request count in current window |
| `reset_at` | INTEGER | Unix timestamp when window resets |

### Client IP Extraction

**Source:** `middleware/rate-limit.ts:25-27`

Priority order:
1. `x-real-ip` header (nginx/Cloudflare)
2. First IP in `x-forwarded-for` chain (proxy chain)
3. Fallback: `127.0.0.1`

---

## Audit Logging for Auth Events

### Audit Actions

**Source:** `src/drop-api/src/lib/audit.ts`

| Action | Trigger | Data Recorded |
|--------|---------|--------------|
| `REGISTER` | New user created via BankID | `userId`, `method: bankid`, `isNewUser: true`, IP, user agent |
| `LOGIN` | Existing user authenticated via BankID | `userId`, `method: bankid`, `isNewUser: false`, IP, user agent |
| `LOGOUT` | User calls `/v1/auth/logout` | `userId`, `resourceType: session` |
| `REFRESH` | Token refresh | `userId`, `resourceType: session` |

### Audit Log Schema

**Table:** `audit_log`

| Column | Type | Auth-Specific Usage |
|--------|------|-------------------|
| `id` | TEXT PK | Format: `aud_<16 hex chars>` |
| `timestamp` | TEXT | ISO timestamp of event |
| `user_id` | TEXT FK | Authenticated user ID |
| `action` | TEXT | One of `REGISTER`, `LOGIN`, `LOGOUT`, `REFRESH` |
| `resource_type` | TEXT | `auth` or `session` |
| `resource_id` | TEXT | Session ID (for session events) |
| `details` | TEXT | JSON: `{ method, isNewUser, platform }` |
| `ip_address` | TEXT | Client IP from middleware |
| `user_agent` | TEXT | `User-Agent` header value |
| `request_id` | TEXT | Correlation ID from `x-request-id` header |

### Audit Log Example

```json
{
  "id": "aud_a1b2c3d4e5f6g7h8",
  "timestamp": "2026-02-21T12:00:00.000Z",
  "user_id": "usr_f1e2d3c4b5a69788",
  "action": "LOGIN",
  "resource_type": "auth",
  "details": "{\"method\":\"bankid\",\"isNewUser\":false}",
  "ip_address": "203.0.113.42",
  "user_agent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X)",
  "request_id": "550e8400-e29b-41d4-a716-446655440000"
}
```

---

## Demo Mode Implementation

### Overview

**Source:** `routes/auth.ts:131-159`

Demo mode provides authentication without BankID for development and testing. API-side demo mode is controlled by `DROP_MODE` env var (checked via `isDemoMode()` in `services/mode.ts`). `NEXT_PUBLIC_SERVICE_MODE` is the client-side equivalent (set to `demo` in docker-compose.yml).

### Demo Login Endpoint

**Endpoint:** `POST /v1/auth/demo-login`

| Aspect | Behavior |
|--------|----------|
| Availability | Only when `isDemoMode()` returns `true` |
| Authentication | None required |
| User | Fixed demo user: `usr_demo1` (seeded in `db.ts`) |
| Response | JWT token + user data (same format as BankID callback) |
| Feature flag | Returns 404 when demo mode is disabled |

### Demo User Profile

| Field | Value | Source |
|-------|-------|--------|
| ID | `usr_demo1` | `db.ts` seed data |
| Email | `demo@example.test` | `db.ts` seed data |
| Name | Demo User | `db.ts` seed data |
| Phone | `+4700000000` | `db.ts` seed data |
| Role | `merchant` | Upgraded in `initDb()` |
| KYC Status | `approved` | Set on seed |
| Bank accounts | DNB (45,000 NOK), Nordea (12,350 NOK) | `db.ts` seed data |

### BankID Mock Mode

**Source:** `bankid.ts:126-128`

When `BANKID_MOCK=true`, the BankID OIDC flow is mocked:
- `exchangeAndVerify()` skips token exchange and JWKS verification
- Returns a mock user based on the auth code value:
  - Code starting with `underage`: returns user born 2010 (fails age check)
  - Default: returns `Test Bankersen`, born 1990 (passes age check)

### Deprecated Endpoints

**Source:** `routes/auth.ts:109-128`

| Endpoint | Status Code | Message |
|----------|-------------|---------|
| `POST /v1/auth/login` | 410 Gone | "Email/password login is no longer supported. Please use BankID." |
| `POST /v1/auth/register` | 410 Gone | "Email/password registration is no longer supported. Please use BankID." |
| `POST /v1/auth/verify-otp` | 410 Gone | "OTP verification is no longer supported. Authentication is handled via BankID." |

---

## Cookie Security Settings

### Cookie Configuration

**Source:** `routes/auth.ts:195, 206`

| Property | Value | Purpose | Source |
|----------|-------|---------|--------|
| `HttpOnly` | `true` | Prevents JavaScript access — mitigates XSS token theft | `auth.ts`, cookie string |
| `Secure` | `true` (production) | Cookie only sent over HTTPS | Implied by deployment (Cloudflare enforces HTTPS) |
| `SameSite` | `Lax` | Prevents CSRF — cookie not sent on cross-origin POST requests | `routes/auth.ts:206` |
| `Path` | `/` | Cookie available to all routes | `routes/auth.ts:206` |
| `Max-Age` | `604800` (7 days) | Session lifetime matching JWT expiry | `routes/auth.ts:206` |
| `Domain` | Not set (defaults to current domain) | Scoped to `getdrop.no` in production | Default browser behavior |

### Cookie Lifecycle

| Event | Cookie Action | Source |
|-------|--------------|--------|
| BankID callback (web) | Set `drop_token=<jwt>` with full security attributes | BFF redirect handler |
| Token refresh | Set new `drop_token=<new-jwt>`, same attributes | `routes/auth.ts:206` |
| Logout | Clear cookie: `drop_token=; Max-Age=0` | `routes/auth.ts:195` |

### SameSite=Lax Behavior

| Request Type | Cookie Sent? | Reason |
|-------------|-------------|--------|
| Same-origin GET | Yes | Normal navigation |
| Same-origin POST | Yes | Form submissions |
| Cross-origin GET (top-level navigation) | Yes | Allows BankID redirect back |
| Cross-origin POST | No | CSRF protection |
| Cross-origin AJAX/fetch | No | CSRF protection |
| Subdomain requests | Depends on Domain setting | No Domain set = strict origin match |

### Web vs Mobile Token Strategy

| Aspect | Web (Next.js) | Mobile (Expo) |
|--------|--------------|---------------|
| Token delivery | httpOnly cookie (`drop_token`) | JSON body (`{ token }`) |
| Token storage | Browser cookie jar (managed by browser) | `AsyncStorage` (React Native encrypted storage) |
| Token transmission | Automatic via `Cookie` header | Manual via `Authorization: Bearer` header |
| CSRF protection | `SameSite=Lax` + CORS origin validation | Not needed (no cookies, Bearer token) |
| Token extraction | `auth.ts:96-99` — parse from `cookie` header | `auth.ts:93-94` — extract from `Authorization` header |

---

## Cross-References

- **BankID OIDC flow:** [AUTHENTICATION.md](../../backend/AUTHENTICATION.md) — Full BankID authentication sequence
- **Auth source:** `src/drop-api/src/lib/auth.ts` — JWT signing, verification, session management
- **Auth routes:** `src/drop-api/src/routes/auth.ts` — Endpoint handlers
- **Auth middleware:** `src/drop-api/src/middleware/auth.ts` — Request authentication
- **Rate limiter:** `src/drop-api/src/middleware/rate-limit.ts` — IP-based rate limiting
- **BankID library:** `src/drop-api/src/lib/bankid.ts` — OIDC flow, pid parsing, user creation
- **Security architecture:** [SECURITY-ARCHITECTURE.md](../../security/SECURITY-ARCHITECTURE.md) — Cookie settings, JWT configuration
- **API reference:** [API-REFERENCE.md](../../backend/API-REFERENCE.md) — Full endpoint documentation
- **Database schema:** [DATABASE-SCHEMA.md](../../backend/DATABASE-SCHEMA.md) — `sessions`, `users`, `audit_log` tables

# Registration & Onboarding Flow

# Registration & Onboarding Flow -- Low-Level Design

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

---

## Overview

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

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

---

## Complete Registration Flow

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

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

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

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

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

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

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

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

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

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

---

## Onboarding States

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

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

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

    rejected_underage --> [*]

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

    existing_user --> dashboard : Session created, redirect

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

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

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

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

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

    dashboard --> [*]
```

---

## Age Verification (18+)

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

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

**Century determination** (from individual number, digits 7-9):
- 000-499: born 1900-1999
- 500-749: born 1854-1899 (historical) or 2000-2039
- 750-899: born 1854-1899 (historical)
- 900-999: born 1940-1999

**Verification logic:**
```
1. Extract DD, MM, YY from pid[0..5]
2. Determine century from pid[6..8]
3. Construct full birthdate
4. Calculate age = today - birthdate
5. If age < 18: reject with "Du ma vaere minst 18 ar for a bruke Drop"
```

**Source:** vilkar.html section 3 -- "Du ma vaere minst 18 ar og bosatt i Norge for a bruke Drop."

---

## Norwegian Residency Check

BankID issuance inherently confirms Norwegian residency:
- BankID is issued only by Norwegian banks to their customers
- Norwegian bank accounts require Norwegian national ID (fodselsnummer or D-number)
- D-numbers are issued to foreign nationals with legitimate ties to Norway

**Additional signals:**
- Phone number prefix: `+47` (Norwegian)
- BankID issuer: Norwegian bank (from ID token `amr` claim)

| Signal | Check | Enforcement |
|--------|-------|-------------|
| BankID ownership | Implicit -- only Norwegian residents have BankID | At authentication |
| Phone number | `+47` prefix | Optional validation at profile update |
| Postal address | Norwegian postal code | At bank account linking (from AISP data) |

---

## Consent Collection

### Required Consents

Per GDPR Article 6(1)(a) and Article 7, explicit consent must be collected before processing personal data. The following consents are collected during onboarding:

| Consent Type | Required | Legal Basis | Description | Withdrawable |
|-------------|----------|-------------|-------------|-------------|
| `terms` | **Yes** (mandatory) | Contract (Art. 6(1)(b)) | Terms of service acceptance | Account deletion required |
| `privacy` | **Yes** (mandatory) | Consent (Art. 6(1)(a)) | Privacy policy acknowledgment | Account deletion required |
| `data_processing` | **Yes** (mandatory) | Consent (Art. 6(1)(a)) | PSD2 AISP/PISP data processing consent | Revokes bank access |
| `marketing` | No (optional) | Consent (Art. 6(1)(a)) | Marketing communications | Yes, at any time |
| `cookies_analytics` | No (optional) | Consent (Art. 6(1)(a)) | Analytics cookies | Yes, at any time |
| `cookies_marketing` | No (optional) | Consent (Art. 6(1)(a)) | Marketing/tracking cookies | Yes, at any time |

### Consent Checklist

| # | Consent | Checkbox Text (Norwegian) | Default | Validation |
|---|---------|--------------------------|---------|------------|
| 1 | Terms of service | "Jeg godtar Drop sine brukervilkar" | Unchecked | Must be checked to proceed |
| 2 | Privacy policy | "Jeg har lest og godtar personvernerklaringen" | Unchecked | Must be checked to proceed |
| 3 | PSD2 data access | "Jeg godtar at Drop leser kontoinformasjon og initierer betalinger via Open Banking" | Unchecked | Must be checked to proceed |
| 4 | Marketing | "Jeg onsker a motta nyheter og tilbud fra Drop" | Unchecked | Optional |

### Consent Storage

Each consent is stored in the `consents` table:

| Column | Value | Purpose |
|--------|-------|---------|
| `id` | `con_<hex16>` | Unique consent record ID |
| `user_id` | `usr_<hex16>` | References the user |
| `consent_type` | `terms`, `privacy`, `marketing`, etc. | Type of consent |
| `granted` | `1` (true) or `0` (false) | Current consent state |
| `granted_at` | ISO timestamp | When consent was granted |
| `withdrawn_at` | ISO timestamp or NULL | When consent was withdrawn |
| `ip_address` | Client IP | Proof of consent action (GDPR Art. 7(1)) |

### Consent API

| Endpoint | Method | Purpose |
|----------|--------|---------|
| `GET /api/consents` | GET | List all user consents |
| `POST /api/consents` | POST | Grant or withdraw consent |

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

---

## First Bank Account Linking

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

```mermaid
sequenceDiagram
    participant User
    participant App as Drop App
    participant BFF as Drop API
    participant Bank as Nordic Bank

    User->>App: Select bank from list (DNB, SpareBank1, etc.)
    App->>BFF: POST /api/bank-accounts/link { bankId: "dnb" }

    BFF->>Bank: Open Banking: GET /authorize (AISP scope)
    Bank->>User: Show consent screen (SCA required)
    Note over User,Bank: "Drop vil lese kontosaldo og<br/>transaksjonshistorikk"

    User->>Bank: Approve (BankID in banking app)
    Bank->>BFF: Authorization code
    BFF->>Bank: POST /token (exchange code)
    Bank->>BFF: AISP access token (90-day validity)

    BFF->>Bank: GET /accounts (list user accounts)
    Bank->>BFF: [{ accountId, iban, name, type }]

    BFF->>Bank: GET /accounts/{id}/balances
    Bank->>BFF: { balance: 45230.00, currency: "NOK" }

    BFF->>BFF: INSERT bank_accounts
    BFF->>BFF: Set first account as is_primary = 1

    BFF->>App: { bankAccounts: [{ bankName: "DNB", balance: 45230, isPrimary: true }] }
    App->>User: "DNB koblet! Saldo: 45 230 kr"
```

---

## Progressive Disclosure UX

The onboarding flow uses progressive disclosure to minimize upfront friction:

| Step | Screen | User Action | Background Action |
|------|--------|-------------|-------------------|
| 1 | BankID login | Tap "Logg inn med BankID" | Auto-create account from BankID data |
| 2 | Consent | Check 3 mandatory boxes + optional marketing | Record consents with IP + timestamp |
| 3 | Link bank | Select bank, approve AISP | Sumsub KYC screening runs in parallel |
| 4 | Dashboard | Explore the app | KYC result webhook updates user status |

**Time to first value:** ~2 minutes (BankID auth + consents + bank linking)

**Deferred actions** (not required during onboarding):
- Add remittance recipients (done when user first sends money)
- Merchant registration (done from Settings if user is a business)
- Notification preferences (defaults: push enabled, email enabled)
- Profile completion (BankID provides name and DOB; address is optional)

---

## Error Handling

| Error | Screen | User Message | Action |
|-------|--------|-------------|--------|
| BankID timeout | Login | "BankID-tidsavbrudd. Prov igjen." | Retry button |
| Age < 18 | Login | "Du ma vaere minst 18 ar for a bruke Drop." | No retry -- explain policy |
| BankID state mismatch | Login | "Noe gikk galt. Prov igjen." | Redirect to login |
| Consent not accepted | Onboarding | Cannot proceed without mandatory consents | Highlight unchecked boxes |
| Bank linking failed | Onboarding | "Kunne ikke koble banken. Prov igjen." | Retry or skip (link later) |
| Sumsub KYC rejected | Background | "Identitetsbekreftelse mislyktes. Kontakt oss." | Account limited until resolved |

---

## Cross-References

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

# KYC & AML Flow

# KYC/AML Flow -- Low-Level Design

**Document:** LLD-KYC-AML
**Status:** Approved
**Last updated:** 2026-02-21
**Author:** Standards Architect
**Applies to:** Drop v1.0 (PSD2 pass-through model)
**Regulatory basis:** Hvitvaskingsloven (LOV-2018-06-01-23), GDPR (Personopplysningsloven)

---

## Overview

Drop's KYC (Know Your Customer) and AML (Anti-Money Laundering) system ensures compliance with Norwegian anti-money laundering law (hvitvaskingsloven). The system has three phases:

1. **Onboarding KYC** -- Identity verification at registration via BankID + Sumsub document verification
2. **Transaction monitoring** -- Real-time and periodic analysis of transaction patterns
3. **Ongoing due diligence** -- Periodic re-screening of PEP/sanctions lists and adverse media

Drop uses a risk-based approach per hvitvaskingsloven section 4-6: higher-risk customers and transactions receive enhanced scrutiny.

---

## KYC Verification Flow

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

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

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

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

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

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

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

---

## KYC Applicant States

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

---

## Document Verification Steps

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

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

**Accepted documents (Norway-specific):**
- Norwegian passport (preferred)
- Norwegian national ID card
- Norwegian driver's license (with photo)
- EEA passport or national ID card (for EEA residents)

---

## PEP/Sanctions Screening

### Screening Sources

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

### Screening Triggers

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

### Database: `screening_results` table

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

---

## Risk Scoring Algorithm

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

### Risk Factor Matrix

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

### Risk Level Classification

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

### Database: `users` risk fields

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

---

## AML Transaction Monitoring

### Alert Rules

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

### Database: `aml_alerts` table

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

### Alert Lifecycle

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

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

---

## SAR/STR Filing

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

### STR Filing Trigger Conditions

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

### Database: `str_reports` table

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

### STR Content (per hvitvaskingsloven section 4-26)

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

---

## Ongoing Monitoring Schedule

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

---

## GDPR Data Minimization for KYC Data

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

### Data Retention Table

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

### Data Minimization Controls

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

### Conflict: GDPR Erasure vs AML Retention

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

1. User record is soft-deleted (`deleted_at` timestamp set)
2. Active sessions are revoked
3. A `data_access_request` record is created (type: `erasure`, status: `completed`)
4. **AML-required data is RETAINED** for 5 years per hvitvaskingsloven s. 4-18
5. Response includes: `"retentionNote": "Data retained for 5 years per AML requirements"`

This is legally permitted under GDPR Article 17(3)(b): "compliance with a legal obligation which requires processing by Union or Member State law."

---

## Cross-References

- [Registration & Onboarding Flow](flow-registration-onboarding.md) -- User registration with KYC trigger
- [System Context (C4 Level 1)](../hld/system-context.md) -- Sumsub and Okokrim external actors
- [Sumsub KYC Integration](../integration/sumsub-kyc-integration.md) -- Technical integration specification
- [Database Schema](../../backend/DATABASE-SCHEMA.md) -- Compliance tables (aml_alerts, str_reports, screening_results, consents)
- [Compliance Status](../../security/COMPLIANCE.md) -- Current compliance readiness
- [ADR-003: PSD2 Pass-through](../adr/ADR-003-psd2-pass-through.md) -- Regulatory context
- [Data Lifecycle](../database/data-lifecycle.md) -- Full data retention and deletion policies

# Bank Account Linking Flow

# Low-Level Design: Bank Account Linking Flow

**Version:** 1.0
**Date:** 2026-02-21
**Author:** Banking Architecture Team
**Status:** Approved
**Applies to:** Drop — AISP Consent & Bank Account Linking

---

## 1. Overview

Bank account linking is the process where a Drop user connects their bank account via Open Banking (PSD2 AISP). This enables Drop to:

- Read the user's bank account balance (displayed on Dashboard)
- Verify sufficient funds before initiating PISP payments
- Display linked accounts in the Bank Accounts screen (`/accounts`)

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

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

---

## 2. Complete Bank Linking Flow

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

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

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

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

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

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

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

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

---

## 3. Linked Account States

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

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

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

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

    Unlinked --> [*]
```

---

## 4. Detailed Steps

### 4.1 Step 1: Bank Selection

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

**Supported banks (initial):**

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

### 4.2 Step 2: AISP Consent Creation

**Berlin Group consent request:**

```json
{
  "access": {
    "balances": [{"iban": "allAccounts"}],
    "transactions": [{"iban": "allAccounts"}]
  },
  "recurringIndicator": true,
  "validUntil": "2026-05-22",
  "frequencyPerDay": 4,
  "combinedServiceIndicator": false
}
```

| Field | Value | Reason |
|---|---|---|
| `balances` | `allAccounts` | User picks which accounts to display after consent |
| `transactions` | `allAccounts` | Future: transaction history from bank |
| `recurringIndicator` | `true` | Ongoing access (not one-time) |
| `validUntil` | Now + 90 days | PSD2 maximum consent duration (RTS Art. 10) |
| `frequencyPerDay` | `4` | Max TPP-initiated reads per day (PSD2 RTS Art. 36(6)) |
| `combinedServiceIndicator` | `false` | AISP consent only (PISP is separate per transaction) |

### 4.3 Step 3: SCA at Bank

The user is redirected to their bank's consent page where they:
1. See what data Drop is requesting (balances, transactions)
2. Authenticate with BankID (SCA: 2 of 3 factors)
3. Approve or deny the consent
4. Get redirected back to Drop

### 4.4 Step 4: Account Retrieval

After consent is confirmed, Drop calls the ASPSP's account list endpoint to discover which accounts the user has. Then it fetches the balance for each account.

### 4.5 Step 5: Data Storage

Each linked account is stored in the `bank_accounts` table:

| Column | Value | Source |
|---|---|---|
| `id` | `ba_<hex16>` | Generated by `randomId("ba")` |
| `user_id` | Current user ID | From JWT |
| `bank_name` | "DNB" | From bank selection |
| `account_number` | Domestic format (masked in API) | From ASPSP account details |
| `iban` | Full IBAN | From ASPSP |
| `balance` | Balance in oere (e.g., 4523000 = 45,230 NOK) | From ASPSP balance endpoint |
| `balance_synced_at` | Current timestamp | Set on each refresh |
| `currency` | "NOK" | From ASPSP |
| `is_primary` | 1 for first account, 0 for others | Auto-set |
| `connected_at` | Current timestamp | Set on linking |

---

## 5. Balance Refresh Strategy

| Trigger | Frequency | ASPSP Call | UI Behavior |
|---|---|---|---|
| User opens Dashboard | On demand | GET `/v1/accounts/{id}/balances` | Show spinner, then updated balance |
| User pulls to refresh | On demand | GET `/v1/accounts/{id}/balances` | Pull-to-refresh animation |
| Background sync | Every 6 hours | GET `/v1/accounts/{id}/balances` | Silent update |
| Pre-payment check | Before PISP | GET `/v1/accounts/{id}/balances` | Inline balance verification |
| User views /accounts | On demand | GET `/v1/accounts/{id}/balances` | Show spinner per account |

**PSD2 constraint:** TPP-initiated requests (background sync) are limited to **4 per day per account** (RTS Art. 36(6)). User-initiated requests (opening app, pull-to-refresh) are **unlimited**.

---

## 6. Error Handling

### 6.1 Error Table

| Error | Stage | HTTP Status | User Message (Norwegian) | Recovery |
|---|---|---|---|---|
| Bank not supported | Bank selection | 400 | "Denne banken stottes ikke ennaa." | Show supported banks |
| ASPSP API unreachable | Consent creation | 502 | "Kunne ikke koble til banken. Prv igjen senere." | Retry after 30s |
| SCA timeout | SCA at bank | 408 | "BankID-sesjonen utlp. Prv igjen." | Restart linking flow |
| SCA cancelled | SCA at bank | 400 | "Du avbrt tilkoblingen." | Offer retry button |
| SCA rejected | SCA at bank | 403 | "Banken avviste tilgangen." | Contact bank support |
| Consent invalid | Callback | 403 | "Tilgangsforesprselen var ugyldig." | Restart flow |
| State mismatch | Callback | 403 | "Sikkerhetssjekk feilet. Prv igjen." | Restart flow |
| No accounts found | Account retrieval | 404 | "Fant ingen kontoer hos denne banken." | Verify correct bank selected |
| Balance read failed | Balance fetch | 502 | "Kunne ikke hente saldo. Viser sist kjente." | Show cached balance with timestamp |
| Consent expired | Any balance read | 403 | "Tilgangen til banken din er utlpt. Koble til paa nytt." | Re-link flow |
| Rate limit exceeded | Balance read | 429 | "For mange foresprsler. Viser sist kjente saldo." | Show cached balance |

### 6.2 Fallback Behavior

When a balance read fails, Drop shows the **last cached balance** with the `balance_synced_at` timestamp:

```
DNB Brukskonto
45 230,00 kr
Sist oppdatert: 2 timer siden
```

If the consent is expired or revoked, the balance is zeroed and the user sees:

```
DNB Brukskonto
-- kr
Tilgangen er utlopt. Koble til paa nytt.
[Koble til] button
```

---

## 7. Consent Renewal

AISP consents have a maximum validity of 90 days (PSD2 RTS Art. 10). Drop proactively prompts renewal:

| Timeline | Action |
|---|---|
| Day 0 | Consent created, `validUntil` = Day 90 |
| Day 60 | Push notification: "Din banktilgang utlper snart. Forny tilgangen." |
| Day 80 | In-app banner on Dashboard: "Tilgangen til DNB utlper om 10 dager." |
| Day 85 | Push notification: "Tilgangen utlper om 5 dager. Forny naa." |
| Day 90 | Consent expires. Balance zeroed. User must re-link. |

**Renewal flow:** Same as initial linking — new consent + BankID SCA at bank. The old consent is deleted after the new one is active.

---

## 8. Account Unlinking

When the user taps "Fjern konto" (Remove account):

1. `DELETE /v1/consents/{consentId}` at the ASPSP (revoke consent)
2. `DELETE FROM bank_accounts WHERE id = ?` in Drop DB
3. `UPDATE consents SET withdrawn_at = now WHERE aspsp_consent_id = ?`
4. If the removed account was primary, promote another linked account to primary
5. If no accounts remain, Dashboard shows "Koble til bank" prompt

---

## 9. Multi-Bank Support

Users can link accounts from multiple banks. Each linked account has its own AISP consent with its own lifecycle.

**Dashboard display:**

```
Dine bankkontoer (Your bank accounts)

DNB Brukskonto          45 230,00 kr  (primary)
DNB Sparekonto          12 800,00 kr
Nordea Brukskonto        8 450,00 kr

Totalt                  66 480,00 kr

[Koble til ny bank]
```

**Drop API:** `GET /api/auth/me` returns `totalBalance` (sum of all linked account balances) and `bankAccounts[]` array.

---

## 10. Database Impact

### 10.1 Tables Affected

| Table | Operation | When |
|---|---|---|
| `bank_accounts` | INSERT | Account linked |
| `bank_accounts` | UPDATE (balance, balance_synced_at) | Balance refresh |
| `bank_accounts` | DELETE | Account unlinked |
| `consents` | INSERT (consent_type: 'psd2_aisp') | Consent created |
| `consents` | UPDATE (granted, granted_at) | SCA completed |
| `consents` | UPDATE (withdrawn_at) | Consent revoked |
| `audit_log` | INSERT | Every linking/unlinking action |
| `notifications` | INSERT | Consent expiry reminders |

### 10.2 Index Usage

| Index | Used By |
|---|---|
| `idx_bank_accounts_user` on `user_id` | All account queries (scoped to user) |
| `idx_consents_user` on `user_id` | Consent lookups for renewal checks |

---

## 11. Cross-References

- **Open Banking AISP/PISP:** [../integration/open-banking-aisp-pisp.md](../integration/open-banking-aisp-pisp.md) — Berlin Group API details, consent properties
- **BankID OIDC:** [../integration/bankid-oidc-integration.md](../integration/bankid-oidc-integration.md) — Drop authentication (separate from bank SCA)
- **Security Architecture:** [../hld/security-architecture.md](../hld/security-architecture.md) — Trust boundaries, data classification
- **Remittance Flow:** [flow-remittance.md](./flow-remittance.md) — Uses linked bank account for PISP
- **Database Schema:** [../../backend/DATABASE-SCHEMA.md](../../backend/DATABASE-SCHEMA.md) — `bank_accounts`, `consents` tables
- **API Reference:** [../../backend/API-REFERENCE.md](../../backend/API-REFERENCE.md) — `GET /api/auth/me` returns bank accounts

# QR Payment Flow

# Flow: QR Payment

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

---

## 1. Overview

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

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

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

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

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

---

## 2. QR Payment Sequence Diagram

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

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

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

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

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

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

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

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

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

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

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

    API-->>App: 201 { transaction }

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

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

---

## 3. Payment Flow State Diagram

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

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

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

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

    SimulationMode --> MerchantResolved: Click "Simuler skanning"

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

    InvalidQR --> Scanning: Dismiss error, retry scan

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

    MerchantNotFound --> Scanning: Go back, scan again

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

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

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

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

    Success --> [*]: Navigate to dashboard
```

---

## 4. Camera Permission Handling

### 4.1 Camera Permission Table (iOS / Android)

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

### 4.2 Fallback Behavior

When camera is unavailable or denied:
- **Web:** Shows "Simuler skanning" button that triggers a demo merchant scan (Ahmetov Kebab, merchant_001)
- **Mobile:** Shows camera placeholder (gray box with QR icon) + "Simuler skanning" button + nearby merchants list (hardcoded: Ahmetov Kebab, Kafe Oslo, Narvesen)

---

## 5. QR Code Format and Validation

| Field | Value | Validation |
|-------|-------|------------|
| URI scheme | `drop://` | Must match exactly |
| Path | `pay/{merchantId}` | Must start with `pay/` |
| Merchant ID format | `mer_` prefix + flexible identifier | No strict regex enforced (e.g., `mer_demo1` is valid) |
| Example | `drop://pay/mer_demo1` | Validated by DB lookup |

**HMAC verification:** QR codes may optionally include `timestamp` and `signature` parameters for HMAC-SHA256 verification using the merchant's `qr_hmac_key`. Verification is performed only when both `qrTimestamp` and `qrSignature` are present in the request. If omitted, the payment proceeds without cryptographic QR verification.

**Invalid QR handling:**
- Non-Drop QR codes: Show "Ugyldig QR-kode. Vennligst skann en Drop-butikks QR-kode."
- Malformed merchant ID: Show "Ugyldig betalingskode."
- Empty scan result: Continue scanning (do not trigger error)

---

## 6. Error States

| Error | HTTP Status | Cause | User-Facing Message (Norwegian) | Recovery |
|-------|-------------|-------|---------------------------------|----------|
| Invalid QR | N/A (client) | Not a `drop://pay/` URI | "Ugyldig QR-kode. Skann en Drop-butikks QR-kode." | Retry scan |
| Merchant Not Found | 404 | Merchant ID not in database | "Butikken ble ikke funnet. QR-koden kan være utdatert." | Scan different QR |
| Insufficient Funds | 402 | Bank balance < amount + fee | "Ikke nok penger på kontoen. Saldo: {balance} NOK." | Reduce amount or top up bank |
| No Bank Account | 400 | No linked bank account | "Ingen bankkonto koblet. Koble en konto først." | Navigate to /accounts |
| Rate Limited | 429 | >10 payments/min | "For mange betalinger. Vent litt." | Wait and retry |
| Network Error | N/A | No connectivity | "Ingen nettverkstilkobling." | Retry when online |
| Server Error | 500 | Internal error | "Noe gikk galt. Prøv igjen." | Retry |
| Camera Error | N/A | Camera hardware failure | "Kameraet fungerer ikke. Bruk 'Simuler skanning'." | Use simulation mode |

---

## 7. UI Components

### 7.1 Web — Scan Page (`/scan`)

| State | UI Elements | Components Used |
|-------|-------------|-----------------|
| Scanning | Dark background (#0F172A), camera viewfinder with gold corner brackets (#D4A017), instruction text, "Simuler skanning" button, BankID/Vipps badges | BottomNav, Button, ArrowLeft/Camera (lucide) |
| Payment | White background, merchant icon (gold gradient circle), merchant name (Fraunces font), amount display (4xl), source account info, "Betal nå" button (green), "Avbryt" button | BottomNav, Button, ChevronLeft (lucide) |
| Paying | Loading spinner overlay | Spinner component |
| Success | Checkmark icon (green), transaction details, merchant name, amount, fee | BottomNav, Check/Store (lucide) |

### 7.2 Mobile — Scan Screen (`(tabs)/scan.js`)

| State | UI Elements |
|-------|-------------|
| Scanning | Gray camera placeholder, QR icon, "Skann QR-kode" text, "Simuler skanning" button, nearby merchants list |
| Payment | Merchant info, amount input, confirm button |
| Success | Confirmation with "Tilbake til hjem" button |

### 7.3 Figma Reference

Source of truth: `mockups/figma-make-export/src/app/screens/ScanQR.tsx`
- Dark scanning mode with gold bracket viewfinder
- Payment confirmation with merchant details and amount
- Green "Betal nå" CTA button

---

## 8. Data Flow

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

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

### 8.2 Response: 201 Created

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

### 8.3 Database Operations (Atomic Transaction)

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

---

## 9. Production vs Demo Differences

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

---

## 10. Accessibility Considerations (WCAG 2.1 AA)

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

---

## 11. Cross-References

- **QR payment API:** `POST /api/transactions/qr-payment` — See [API Reference](../../backend/API-REFERENCE.md)
- **Merchant registration:** `POST /api/merchants/register` — See [API Reference](../../backend/API-REFERENCE.md)
- **Transaction schema:** `transactions` table — See [Database Schema](../../backend/DATABASE-SCHEMA.md)
- **Merchant schema:** `merchants` table — See [Database Schema](../../backend/DATABASE-SCHEMA.md)
- **Component overview:** See [component-overview.md](../hld/component-overview.md)
- **Figma scan screen:** `mockups/figma-make-export/src/app/screens/ScanQR.tsx`
- **Web scan page:** `src/drop-app/src/app/scan/page.tsx` — See [PAGES.md](../../frontend/PAGES.md)
- **Mobile scan screen:** `src/drop-mobile/app/(tabs)/scan.js` — See [MOBILE-APP.md](../../mobile/MOBILE-APP.md)
- **Merchant onboarding flow:** See [flow-merchant-onboarding.md](flow-merchant-onboarding.md)
- **PSD2 PISP details:** See [open-banking-aisp-pisp.md](../integration/open-banking-aisp-pisp.md)

# Remittance Flow

# Low-Level Design: Remittance Flow

**Version:** 1.0
**Date:** 2026-02-21
**Author:** Banking Architecture Team
**Status:** Approved
**Applies to:** Drop — Cross-Border Money Transfer (PISP)

---

## 1. Overview

Remittance is Drop's core feature — sending money from a Norwegian bank account to a recipient abroad. The flow has 4 user-facing steps:

1. **Select recipient** (or add new)
2. **Enter amount** (see FX rate + fee in real-time)
3. **Review** (PSD2 Art. 45 pre-payment disclosure)
4. **Confirm** (SCA via BankID at user's bank)

Drop uses **PISP** (Payment Initiation Service) to initiate the transfer directly from the user's bank account. Drop never touches the money.

**API endpoint:** `POST /api/transactions/remittance`
**Fee:** 0.5% of send amount
**Amount range:** 100 - 50,000 NOK
**KYC required:** Yes (`kyc_status = 'approved'`)
**Supported corridors:** Serbia (RSD), Bosnia (BAM), Poland (PLN), Pakistan (PKR), Turkey (TRY), EU (EUR)

---

## 2. End-to-End Remittance Flow

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

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

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

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

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

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

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

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

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

---

## 3. Transaction States

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

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

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

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

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

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

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

**Database status values** (`transactions.status` CHECK constraint):
- `processing` — Transaction created, awaiting SCA or settlement
- `completed` — ASPSP accepted payment, settlement in progress or done
- `failed` — Payment rejected, SCA failed, or settlement error

---

## 4. Pre-Payment Disclosure (PSD2 Art. 45)

Before the user confirms a remittance, Drop must show a **complete cost breakdown**. This is a legal requirement under PSD2 Art. 45 (Betalingstjenesteloven in Norwegian law).

### 4.1 Disclosure Checklist

| Item | PSD2 Reference | Drop Field | Example |
|---|---|---|---|
| Amount to be transferred | Art. 45(1)(a) | `amount` | 2,000 NOK |
| All fees/charges | Art. 45(1)(b) | `fee`, `feePercentage` | 10 NOK (0.5%) |
| Exchange rate used | Art. 45(1)(c) | `exchangeRate` | 1 NOK = 10.17 RSD |
| Amount after conversion | Art. 45(1)(d) | `receiveAmount`, `receiveCurrency` | 20,340 RSD |
| Total cost to payer | Art. 45(1)(e) | `totalCost` | 2,010 NOK |
| Estimated delivery time | Art. 45(1)(f) | `estimatedDelivery` | 2-4 business days |
| Currency of debit | Implicit | Send currency | NOK |
| Currency of credit | Implicit | Receive currency | RSD |

### 4.2 Disclosure Screen Content (Norwegian)

```
Bekreft overfoering

Til:           Marko Petrovic
Land:          Serbia
Bankkonto:     *****567-89 (Banca Intesa)

Du sender:     2 000,00 kr
Gebyr (0,5%):     10,00 kr
Totalt belop:  2 010,00 kr

Vekslingskurs: 1 NOK = 10,17 RSD
Marko mottar:  20 340,00 RSD

Estimert levering: 2-4 virkedager
Pengene trekkes fra: DNB Brukskonto

[Bekreft og send]    [Avbryt]
```

---

## 5. FX Rate Lock & Expiry

### 5.1 Rate Lock Window

| Event | Time | Action |
|---|---|---|
| User views disclosure | T+0 | Rate displayed from `exchange_rates` table |
| User confirms payment | T+0 to T+15min | Rate locked in `transactions.exchange_rate` |
| SCA completes | T+0 to T+15min | Locked rate applies to settlement |
| SCA not completed | T+15min | Rate expires, transaction fails, user must re-quote |

### 5.2 Rate Lock Implementation

1. When `POST /api/transactions/remittance` is called, the current rate is read from `exchange_rates`
2. The rate is stored in `transactions.exchange_rate` at insert time
3. If the SCA takes longer than 15 minutes, the reconciliation job detects the stale transaction and marks it `failed`
4. User is notified to retry (with a new, current rate)

---

## 6. Validation Rules

### 6.1 Pre-Flight Checks (Before Transaction Creation)

| Check | Source | Error if Failed |
|---|---|---|
| User authenticated | JWT from cookie/header | 401 `unauthorized` |
| KYC approved | `users.kyc_status = 'approved'` | 403 `kyc_required` |
| Recipient exists | `recipients.id` WHERE `user_id = ?` | 404 `not_found` |
| Recipient belongs to user | `recipients.user_id = jwt.userId` | 404 `not_found` |
| Bank account exists | `bank_accounts.id` WHERE `user_id = ?` | 400 `no_bank_account` |
| Amount in range | 100 to 50,000 NOK | 422 `validation_error` |
| Amount valid | `Number.isFinite()`, max 2 decimals | 422 `validation_error` |
| Balance sufficient | `bank_accounts.balance >= amount + fee` | 402 `insufficient_balance` |
| Currency corridor supported | `exchange_rates.to_currency` exists | 422 `validation_error` |
| Not duplicate | `idempotency_key` unique | Return existing transaction |
| Rate limit | < 10 requests/min per IP | 429 `rate_limited` |

### 6.2 Amount Validation

```
Minimum: 100 NOK (protect against micro-transaction abuse)
Maximum: 50,000 NOK (regulatory limit for simplified CDD)
Decimals: max 2 (validated by validateAmount())
Type: Number.isFinite() (prevents NaN, Infinity injection)
```

---

## 7. Error Scenarios & User Messages

| Scenario | API Response | User Message (Norwegian) | Next Step |
|---|---|---|---|
| KYC not approved | 403 `kyc_required` | "Du maa fullfoere identitetsverifisering for aa sende penger." | Redirect to KYC flow |
| No linked bank account | 400 `no_bank_account` | "Du har ingen tilkoblet bankkonto. Koble til en bank foerst." | Redirect to /accounts |
| Insufficient balance | 402 `insufficient_balance` | "Ikke nok penger paa kontoen. Saldo: 1 200 kr, totalt belop: 2 010 kr." | Show balance, suggest lower amount |
| Unsupported corridor | 422 `validation_error` | "Vi stoetter ikke overfoering til dette landet ennaa." | Show supported countries |
| Amount too low | 422 `validation_error` | "Minimumsbelopet er 100 kr." | Adjust amount |
| Amount too high | 422 `validation_error` | "Maksimumsbelopet er 50 000 kr." | Adjust amount |
| SCA timeout | (callback timeout) | "BankID-sesjonen utlop. Overforingen ble ikke gjennomfoert." | Retry button |
| SCA cancelled | (callback cancelled) | "Du avbrot betalingen. Ingen penger er trukket." | Retry button |
| Bank rejected | ASPSP `RJCT` | "Banken avviste overforingen. Kontakt banken din." | Show bank support info |
| Rate expired | (rate > 15min old) | "Vekslingskursen har utlopt. Vennligst bekreft ny kurs." | Re-show disclosure with new rate |
| Network error | 502/503 | "Teknisk feil. Proev igjen om noen minutter." | Retry after 30s |
| Duplicate detected | 200 (existing tx) | "Denne overforingen er allerede registrert." | Show existing transaction |

---

## 8. Post-Transaction

### 8.1 Confirmation Screen

After successful SCA:

```
Overfoering sendt!

2 000 kr sendt til Marko Petrovic
Marko mottar 20 340 RSD

Referanse: tx_rem_a1b2c3d4...
Status: Under behandling
Estimert levering: 2-4 virkedager

[Se detaljer]    [Send til en annen]
```

### 8.2 Transaction Tracking

Users can track their remittance in the Transaction History (`/transactions`):

| Status | Display | Icon |
|---|---|---|
| `processing` | "Under behandling" | Spinner |
| `completed` | "Fullfoert" | Green checkmark |
| `failed` | "Feilet" | Red X |

### 8.3 Transaction Summary

`GET /api/transactions/summary` returns aggregated transaction statistics for the authenticated user (total sent, total fees, transaction count, breakdown by corridor).

### 8.4 Receipt

`GET /api/transactions/{id}/receipt` returns a detailed receipt:

```json
{
  "transactionId": "tx_rem_xxx",
  "date": "2026-02-21T14:30:00Z",
  "type": "remittance",
  "amount": 2000,
  "currency": "NOK",
  "fee": 10,
  "exchangeRate": 10.17,
  "receiveAmount": 20340,
  "receiveCurrency": "RSD",
  "recipient": {"name": "Marko Petrovic", "country": "RS"},
  "reference": "tx_rem_xxx",
  "status": "completed",
  "completedAt": "2026-02-21T14:35:00Z"
}
```

### 8.5 Notifications

On completion/failure, a notification is created:

| Event | Notification Title | Notification Body |
|---|---|---|
| Payment sent | "Overfoering sendt" | "2 000 kr sendt til Marko Petrovic" |
| Payment completed | "Overfoering fullfoert" | "20 340 RSD mottatt av Marko Petrovic" |
| Payment failed | "Overfoering feilet" | "Overfoering til Marko Petrovic ble avvist. Kontakt oss for hjelp." |

---

## 9. Refund Handling

If a remittance fails after funds were debited (e.g., correspondent bank rejects, recipient IBAN invalid):

| Step | Action | Timeline |
|---|---|---|
| 1 | ASPSP reports `RJCT` or `CANC` status | 1-5 business days |
| 2 | Drop detects via reconciliation job | Within 1 hour of status change |
| 3 | Drop creates refund record in audit_log | Immediate |
| 4 | ASPSP reverses the debit (automatic for SEPA) | 1-3 business days |
| 5 | Drop updates `bank_accounts.balance` on next AISP sync | Next balance refresh |
| 6 | User notified via push notification | Immediate |

**Note:** For SWIFT transfers, refund timing depends on correspondent banks and may take 5-10 business days. Drop sends a notification with estimated refund timeline.

---

## 10. AML/Compliance Checks

Each remittance triggers compliance checks before PISP initiation:

| Check | Implementation | Action on Trigger |
|---|---|---|
| Velocity limit | > 5 remittances/hour or > 20/day | `aml_alerts` record (medium severity), continue |
| Structuring detection | Multiple amounts just below 25,000 NOK | `aml_alerts` record (high severity), review queue |
| High-risk corridor | FATF grey/black list country | Enhanced due diligence flag |
| Single large transfer | > 25,000 NOK | Enhanced monitoring |
| Total daily volume | > 100,000 NOK cumulative | `aml_alerts` record, may require manual approval |
| Sanctions screening | Recipient name vs sanctions lists | Block if match, `screening_results` record |

---

## 11. Database Impact

### 11.1 Tables Written

| Table | Operation | When |
|---|---|---|
| `transactions` | INSERT | Payment initiated (Step 4) |
| `transactions` | UPDATE | Status change (processing to completed/failed) |
| `bank_accounts` | UPDATE (balance) | Atomic debit during transaction creation |
| `audit_log` | INSERT | Every payment action |
| `notifications` | INSERT | Payment sent / completed / failed |
| `aml_alerts` | INSERT | If AML rule triggered |

### 11.2 Tables Read

| Table | Operation | When |
|---|---|---|
| `users` | SELECT (kyc_status) | Pre-flight KYC check |
| `recipients` | SELECT | Recipient lookup (Step 1) |
| `bank_accounts` | SELECT (balance) | Balance check (Step 4) |
| `exchange_rates` | SELECT (rate) | FX rate lookup (Step 2, 3, 4) |
| `transactions` | SELECT (idempotency_key) | Duplicate detection |

---

## 12. Cross-References

- **Payment Processing:** [../integration/payment-processing.md](../integration/payment-processing.md) — SEPA/SWIFT settlement, FX, fees
- **Open Banking AISP/PISP:** [../integration/open-banking-aisp-pisp.md](../integration/open-banking-aisp-pisp.md) — PISP API details
- **BankID OIDC:** [../integration/bankid-oidc-integration.md](../integration/bankid-oidc-integration.md) — Drop authentication
- **Security Architecture:** [../hld/security-architecture.md](../hld/security-architecture.md) — Fraud detection, AML pipeline
- **Bank Account Linking:** [flow-bank-account-linking.md](./flow-bank-account-linking.md) — Prerequisite: linked bank account
- **Database Schema:** [../../backend/DATABASE-SCHEMA.md](../../backend/DATABASE-SCHEMA.md) — `transactions`, `recipients`, `exchange_rates` tables
- **API Reference:** [../../backend/API-REFERENCE.md](../../backend/API-REFERENCE.md) — Remittance, disclosure, receipt, rate endpoints

# Merchant Onboarding Flow

# Flow: Merchant Onboarding

**Document:** LLD-006
**Version:** 1.0
**Date:** 2026-02-21
**Author:** Frontend Architect (AI Agent)
**Status:** Draft
**Scope:** Merchant registration, business verification, QR code generation, merchant dashboard, transaction monitoring, and settlement

---

## 1. Overview

Drop supports merchant onboarding for in-store QR payments. Any authenticated user can register as a merchant by providing business details and a valid Norwegian organization number. Upon registration, the user's role is upgraded from `user` to `merchant`, granting access to the merchant dashboard with transaction monitoring, daily summaries, and settlement views.

**Key facts:**
- Merchant registration requires authenticated BankID login
- Organization number verified against Brønnøysundregistrene (production; format validation in demo)
- QR code format: `drop://pay/{merchantId}`
- Merchant fee: 1% per QR transaction (lower than card terminal 1.75-2.75%)
- Settlement: T+1 (planned) — funds from transactions deposited to merchant's bank account

---

## 2. Merchant Registration Flow

### 2.1 Sequence Diagram — Registration to Active Merchant

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

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

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

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

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

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

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

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

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

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

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

---

## 3. Merchant Dashboard Components

### 3.1 Component Diagram

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

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

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

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

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

---

## 4. Business Verification Checklist

### 4.1 Registration Requirements

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

### 4.2 Brønnøysundregistrene Verification (Production)

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

---

## 5. Settlement Schedule

### 5.1 Settlement Schedule Table

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

### 5.2 Settlement Calculation

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

### 5.3 Merchant Dashboard API

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

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

---

## 6. QR Code Generation

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

**QR endpoint:** `GET /api/merchants/qr`

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

---

## 7. Merchant Transaction Monitoring

### 7.1 Transaction List

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

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

### 7.2 Period Filtering

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

---

## 8. UI Components (Web)

### 8.1 Merchant Dashboard Layout

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

### 8.2 Figma Reference

Source of truth: `mockups/figma-make-export/src/app/screens/MerchantDashboard.tsx`
- Welcome header with business name
- Period filter tabs (I dag / Uke / Maaned)
- Green gradient revenue card with stats
- QR code button
- Transaction list with customer names

---

## 9. Role Upgrade Flow

| Step | Before | After |
|------|--------|-------|
| 1 | User has `role = 'user'` | Same |
| 2 | User submits merchant registration | Same |
| 3 | API validates and creates merchant record | `role = 'merchant'` |
| 4 | User's JWT still has old role | Valid until refresh |
| 5 | On next token refresh / re-login | New JWT has `role = 'merchant'` |

**Authorization gates:**
- `GET /api/merchants/dashboard` — requires `role = 'merchant'`
- `GET /api/merchants/qr` — requires `role = 'merchant'`
- `GET /api/merchants/transactions` — requires `role = 'merchant'`

---

## 10. Platform Differences

| Feature | Web | Mobile |
|---------|-----|--------|
| Merchant registration | Full form via web UI | Not implemented |
| Merchant dashboard | Dedicated screen with stats | Not implemented |
| QR code display | Button to show QR | Not implemented |
| Transaction monitoring | List with period filter | Not implemented |
| Settlement view | Inline in dashboard stats | Not implemented |

---

## 11. Accessibility Considerations (WCAG 2.1 AA)

| Requirement | Implementation |
|-------------|---------------|
| Form validation | Registration form shows inline error messages |
| Revenue card | Uses both visual (bold text) and semantic (heading) for amounts |
| Period tabs | Active tab indicated by color AND aria-selected |
| Transaction list | Each item has descriptive text (customer, amount, status) |
| QR code | Alt text: "QR-kode for {businessName}" |
| Color contrast | White text on green gradient meets 4.5:1 |
| Settings icon | Has aria-label "Innstillinger" |

---

## 12. Cross-References

- **Merchant registration API:** `POST /api/merchants/register` — See [API Reference](../../backend/API-REFERENCE.md)
- **Merchant dashboard API:** `GET /api/merchants/dashboard` — See [API Reference](../../backend/API-REFERENCE.md)
- **Merchant QR API:** `GET /api/merchants/qr` — See [API Reference](../../backend/API-REFERENCE.md)
- **Merchant transactions API:** `GET /api/merchants/transactions` — See [API Reference](../../backend/API-REFERENCE.md)
- **Merchants schema:** `merchants` table — See [Database Schema](../../backend/DATABASE-SCHEMA.md)
- **Transactions schema:** `transactions` table — See [Database Schema](../../backend/DATABASE-SCHEMA.md)
- **Component overview:** See [component-overview.md](../hld/component-overview.md)
- **Figma merchant dashboard:** `mockups/figma-make-export/src/app/screens/MerchantDashboard.tsx`
- **QR payment flow:** See [flow-qr-payment.md](flow-qr-payment.md)
- **Authentication flow:** See [flow-login-authentication.md](flow-login-authentication.md)

# Transaction History Flow

# Flow: Transaction History

**Document:** LLD-003
**Version:** 1.0
**Date:** 2026-02-21
**Author:** Frontend Architect (AI Agent)
**Status:** Draft
**Scope:** Transaction list rendering, filtering, pagination, transaction detail view, receipt download, and dispute initiation

---

## 1. Overview

The transaction history view provides users with a chronological list of all their financial transactions (remittances and QR payments). The list supports filtering by type and status, date-based grouping, infinite scroll pagination, and drill-down to transaction detail with receipt download.

**Key capabilities:**
- Paginated transaction list (20 per page, max 50)
- Filter by type: All, Remittance, QR Payment
- Filter by status: Processing, Completed, Failed
- Date grouping: I dag, I gar, Denne uken, Eldre
- Transaction detail view with exchange rate info
- Receipt download (JSON receipt)
- Complaint/dispute initiation via `/complaints`

---

## 2. Transaction List Load + Filter + Detail View

### 2.1 Sequence Diagram

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

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

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

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

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

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

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

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

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

---

## 3. Transaction List Components

### 3.1 Component Diagram

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

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

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

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

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

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

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

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

---

## 4. Filter Options

### 4.1 Filter Options Table

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

### 4.2 Pagination Parameters

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

---

## 5. Transaction Status Display Mapping

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

### 5.1 Transaction Type Icons

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

### 5.2 Amount Display

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

---

## 6. Date Grouping Logic

The `groupByDate()` function categorizes transactions into temporal groups:

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

---

## 7. Transaction Detail View

### 7.1 Remittance Detail Fields

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

### 7.2 QR Payment Detail Fields

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

### 7.3 Receipt Endpoint

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

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

---

## 8. Dispute Initiation

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

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

---

## 9. Platform Differences

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

---

## 10. Accessibility Considerations (WCAG 2.1 AA)

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

---

## 11. Cross-References

- **Transaction list API:** `GET /api/transactions` — See [API Reference](../../backend/API-REFERENCE.md)
- **Transaction detail API:** `GET /api/transactions/{id}` — See [API Reference](../../backend/API-REFERENCE.md)
- **Receipt API:** `GET /api/transactions/{id}/receipt` — See [API Reference](../../backend/API-REFERENCE.md)
- **Complaints API:** `POST /api/complaints` — See [API Reference](../../backend/API-REFERENCE.md)
- **Transaction schema:** `transactions` table — See [Database Schema](../../backend/DATABASE-SCHEMA.md)
- **Component overview:** See [component-overview.md](../hld/component-overview.md)
- **Figma transaction history:** `mockups/figma-make-export/src/app/screens/TransactionHistory.tsx`
- **Web page:** `src/drop-app/src/app/transactions/page.tsx` — See [PAGES.md](../../frontend/PAGES.md)
- **Mobile screen:** `src/drop-mobile/app/history.js` — See [MOBILE-APP.md](../../mobile/MOBILE-APP.md)
- **QR payment flow:** See [flow-qr-payment.md](flow-qr-payment.md)

# Profile & Settings Flow

# Flow: Profile & Settings

**Document:** LLD-004
**Version:** 1.0
**Date:** 2026-02-21
**Author:** Frontend Architect (AI Agent)
**Status:** Draft
**Scope:** Profile page, personal information display, KYC status, settings management, GDPR data export, and account deletion flow

---

## 1. Overview

The profile section provides users with personal information display (sourced from BankID), KYC verification status, and configurable settings. It also handles GDPR compliance features including data export (right to portability) and account deletion (right to erasure) with mandatory AML data retention notices.

**Profile sub-pages (web):**
- `/profile` — Hub with user info and menu
- `/profile/personal` — BankID-verified personal details (read-only)
- `/profile/security` — Security settings and active devices
- `/profile/notifications` — Push and email notification toggles
- `/profile/language` — Language selection (nb, en, bs, sq)

---

## 2. Profile Load + Settings Update

### 2.1 Sequence Diagram

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

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

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

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

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

    App->>App: Render notification toggles

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

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

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

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

---

## 3. Account Deletion Flow

### 3.1 Sequence Diagram

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

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

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

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

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

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

### 3.2 Account Deletion Process Flow

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

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

---

## 4. Settings Matrix

### 4.1 User Settings

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

### 4.2 Personal Information (Read-Only from BankID)

| Field | Source | Editable | Display Format |
|-------|--------|----------|----------------|
| First name | BankID ID token | No | Plain text |
| Last name | BankID ID token | No | Plain text |
| Email | User registration / BankID | No | Plain text |
| Phone | User registration | No | +47 XXX XX XXX |
| Date of birth | BankID pid (national ID) | No | DD. MMMM YYYY (e.g., "15. mars 1995") |
| KYC status | System (auto via BankID) | No | Badge: "Verifisert med BankID" (green) |

### 4.3 Security Settings (UI Display Only)

| Setting | Current Value | Status | Actionable |
|---------|--------------|--------|------------|
| Password | "Sist endret: Aldri" | Info display | Change button (planned) |
| BankID verification | Active | Green badge | N/A |
| Vipps verification | Not activated | Gray badge | Planned (Phase 2) |
| Active devices | iPhone 15 Pro (active), MacBook Pro (yesterday) | Live info | View/revoke (planned) |

---

## 5. GDPR Rights Mapping

| GDPR Right | Article | Implementation | API Endpoint | Status |
|------------|---------|---------------|--------------|--------|
| Right to access (innsyn) | Art. 15 | Full data export as JSON | `GET /api/user/data-export` | Implemented |
| Right to rectification (retting) | Art. 16 | BankID data is authoritative; address via support | N/A | Via support |
| Right to erasure (sletting) | Art. 17 | Soft-delete with 5-year AML retention | `DELETE /api/user/account` | Implemented |
| Right to data portability | Art. 20 | Same as data export (JSON format) | `GET /api/user/data-export` | Implemented |
| Right to withdraw consent | Art. 7 | Consent toggle + withdrawal tracking | `POST /api/consents { granted: false }` | Implemented |
| Right to lodge complaint | Art. 77 | Link to Datatilsynet on privacy page | N/A | Link provided |

### 5.1 Data Export Contents

`GET /api/user/data-export` returns:

| Section | Data |
|---------|------|
| `user` | id, email, first_name, last_name, phone, date_of_birth, kyc_status, role, created_at |
| `transactions` | All transaction records |
| `recipients` | All saved recipients |
| `bankAccounts` | Linked bank accounts (masked numbers) |
| `settings` | Currency, language, notification preferences |
| `consents` | All consent records with timestamps |

### 5.2 Data Retention After Deletion

| Data Category | Retention Period | Legal Basis |
|---------------|-----------------|-------------|
| Transaction records | 5 years | Hvitvaskingsloven (AML) |
| KYC/identity data | 5 years | Hvitvaskingsloven (AML) |
| AML alerts and STR reports | 5 years | Hvitvaskingsloven (AML) |
| Consent records | 5 years | GDPR proof of consent |
| User profile (soft-deleted) | 5 years | Legal obligation |
| Settings, preferences | Deleted immediately | No retention requirement |
| Notification history | Deleted immediately | No retention requirement |

---

## 6. UI Components

### 6.1 Profile Hub (`/profile`)

| Element | Component | Source |
|---------|-----------|--------|
| Avatar | Green gradient circle with initials | Inline (gradient from #0B6E35 to #095a2b) |
| Edit button | Pen icon on avatar | Pen (lucide) |
| User info | Name (Fraunces font) + email | Text |
| Account section | Menu items with chevrons | ChevronRight (lucide) |
| Settings section | Menu items with chevrons | Bell, Shield, Globe (lucide) |
| Bottom navigation | 5-tab bar | BottomNav component |

### 6.2 Notification Settings (`/profile/notifications`)

| Element | Component | Behavior |
|---------|-----------|----------|
| Push toggle | Custom switch | Immediate PATCH, revert on failure |
| Email toggle | Custom switch | Immediate PATCH, revert on failure |

### 6.3 Language Settings (`/profile/language`)

| Element | Component | Behavior |
|---------|-----------|----------|
| Language list | Radio selection with green checkmark | Local state update |
| Save button | Green "Lagre" button | PATCH on click |

### 6.4 Figma Reference

Source of truth: `mockups/figma-make-export/src/app/screens/Profile.tsx`
- User avatar with initials and edit button
- Account section (Personlig informasjon, Bankkontoer)
- Settings section (Varsler, Sikkerhet, Sprak)

---

## 7. Platform Differences

| Feature | Web | Mobile |
|---------|-----|--------|
| Profile layout | Hub with 4 sub-pages | Single screen with inline settings |
| Personal info | Dedicated /profile/personal page | Inline section |
| Security settings | Dedicated /profile/security page | Not implemented |
| Notification settings | Dedicated /profile/notifications page | Inline in profile |
| Language | Dedicated /profile/language page | "Sprak" in settings menu |
| Recipients list | Not on profile (separate /recipients) | Inline "Mine mottakere" section |
| GDPR export | Via /profile or API | Not implemented |
| Account deletion | Via /profile | Not implemented |
| Logout | Confirmation dialog | Simple button with token clear |

---

## 8. Accessibility Considerations (WCAG 2.1 AA)

| Requirement | Implementation |
|-------------|---------------|
| Form labels | All settings have visible labels |
| Toggle state | Push/email toggles announce on/off state to screen readers |
| Read-only fields | Personal info fields use `disabled` attribute with visible label |
| KYC badge | Uses both color (green) and icon (ShieldCheck) for verification status |
| Navigation | Sub-page back buttons have aria-label "Tilbake" |
| Destructive action | Account deletion requires explicit confirmation dialog |
| Language names | Languages listed in their own script for recognition |
| Color contrast | Green badge (#0B6E35) on white meets 4.5:1 |

---

## 9. Cross-References

- **User data API:** `GET /api/auth/me` — See [API Reference](../../backend/API-REFERENCE.md)
- **Settings API:** `GET/PATCH /api/settings` — See [API Reference](../../backend/API-REFERENCE.md)
- **Data export API:** `GET /api/user/data-export` — See [API Reference](../../backend/API-REFERENCE.md)
- **Account deletion API:** `DELETE /api/user/account` — See [API Reference](../../backend/API-REFERENCE.md)
- **Consents API:** `GET/POST /api/consents` — See [API Reference](../../backend/API-REFERENCE.md)
- **Settings schema:** `settings` table — See [Database Schema](../../backend/DATABASE-SCHEMA.md)
- **Data access requests schema:** `data_access_requests` table — See [Database Schema](../../backend/DATABASE-SCHEMA.md)
- **Component overview:** See [component-overview.md](../hld/component-overview.md)
- **Figma profile screen:** `mockups/figma-make-export/src/app/screens/Profile.tsx`
- **Web profile pages:** `src/drop-app/src/app/profile/` — See [PAGES.md](../../frontend/PAGES.md)
- **Mobile profile screen:** `src/drop-mobile/app/(tabs)/profile.js` — See [MOBILE-APP.md](../../mobile/MOBILE-APP.md)
- **Authentication flow:** See [flow-login-authentication.md](flow-login-authentication.md)

# Notifications Flow

# Flow: Notifications

**Document:** LLD-005
**Version:** 1.0
**Date:** 2026-02-21
**Author:** Frontend Architect (AI Agent)
**Status:** Draft
**Scope:** Push notification delivery, in-app notification center, notification types, read/unread state, deep linking, and permission handling

---

## 1. Overview

Drop's notification system provides users with transaction alerts, security notifications, and system updates. The current implementation consists of an in-app notification center (bell icon) with read/unread state management. Push notifications via Expo Push are planned for mobile but not yet implemented.

**Current state:**
- In-app notification center: Implemented (web)
- Push notifications: Not yet implemented (planned via Expo Push for mobile)
- Deep linking from notifications: Not yet configured
- Notification preferences: Implemented (push/email toggles in settings)

---

## 2. Push Notification Delivery (Planned Architecture)

### 2.1 Sequence Diagram — Push Notification Flow

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

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

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

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

### 2.2 In-App Notification Center (Current Implementation)

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

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

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

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

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

---

## 3. Notification Center Components

### 3.1 Component Diagram

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

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

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

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

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

    BellIcon -->|navigate| Header
```

---

## 4. Notification Type Table

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

### 4.1 Priority Levels

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

---

## 5. Deep Link Routing Table (Planned)

| Notification Type | Deep Link Target | Web Route | Mobile Route |
|-------------------|-----------------|-----------|--------------|
| `transaction_complete` | Transaction detail | `/transactions?id={txId}` | `/(tabs)/history?id={txId}` |
| `qr_payment` | Transaction detail | `/transactions?id={txId}` | `/(tabs)/history?id={txId}` |
| `security` | Security settings | `/profile/security` | `/(tabs)/profile` |
| `rate_update` | Send money (with rate) | `/send` | `/(tabs)/send` |
| `system` | Notification center | `/notifications` | `/notifications` |
| `promotional` | Landing or feature page | `/` or feature URL | App home |

### 5.1 Deep Link Format

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

---

## 6. Permission Handling

### 6.1 Notification Permission Flow

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

### 6.2 Permission State Machine

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

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

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

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

---

## 7. Notification Preferences (Settings Integration)

Users control notification delivery via `/profile/notifications`:

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

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

---

## 8. Time Formatting

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

---

## 9. Platform Differences

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

---

## 10. Data Schema

### 10.1 Notifications Table

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

### 10.2 API Endpoints

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

---

## 11. Accessibility Considerations (WCAG 2.1 AA)

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

---

## 12. Cross-References

- **Notifications API:** `GET/PATCH /api/notifications` — See [API Reference](../../backend/API-REFERENCE.md)
- **Settings API:** `PATCH /api/settings` (push/email toggles) — See [API Reference](../../backend/API-REFERENCE.md)
- **Notifications schema:** `notifications` table — See [Database Schema](../../backend/DATABASE-SCHEMA.md)
- **Settings schema:** `settings` table — See [Database Schema](../../backend/DATABASE-SCHEMA.md)
- **Component overview:** See [component-overview.md](../hld/component-overview.md)
- **Figma notifications screen:** `mockups/figma-make-export/src/app/screens/Notifications.tsx`
- **Web notifications page:** `src/drop-app/src/app/notifications/page.tsx` — See [PAGES.md](../../frontend/PAGES.md)
- **Profile settings flow:** See [flow-profile-settings.md](flow-profile-settings.md)

# Low-Level Design Document

# Low-Level Design Document

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

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

---

## 1. Module Overview

**Module:** `transactions`
**Service/Repo:** `drop-api` — `src/drop-api/src/routes/transactions.ts`
**Team Owner:** ALAI — Backend

**Single Responsibility:** Processes all financial operations — remittance (international money transfer via PISP) and QR payments (domestic merchant payments via PISP) — using the PSD2 pass-through model where Drop never holds funds.

**Boundaries:**
- **Owns:** Transaction records (`transactions` table), exchange rates (`exchange_rates` table), fee calculation, pre-payment disclosure, idempotency enforcement, PISP payment initiation orchestration
- **Does NOT own:** User authentication (auth module), recipient management (`recipients` table owned by recipients route), bank account balance display (bank_accounts route / AISP), merchant registration (merchants route)
- **Delegates to:** BankID auth middleware (JWT validation), Open Banking PISP API (actual payment execution), audit_log (compliance side-effect), notifications (user alerting)

**Key Business Rules:**
1. Drop never deducts money from the Drop DB balance except as a cached AISP value — all real deductions happen at the user's bank via PISP
2. Every transaction requires `kyc_status = 'approved'` on the initiating user
3. Remittance amounts: 100 NOK minimum, 50,000 NOK maximum; fee = 0.5% of send amount
4. QR payments: fee = merchant `fee_rate` (default 1%); validated via HMAC QR code
5. `idempotency_key` (unique index on `transactions`) prevents double-charging on retry

---

## 2. Class / Module Diagram

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

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

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

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

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

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

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

---

## 3. Database Schema

### 3.1 Tables

#### `transactions`

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

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

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

#### `exchange_rates`

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

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

**Indexes:**
| Index Name | Columns | Type | Rationale |
|-----------|---------|------|-----------|
| `idx_rates_currency` | `from_currency, to_currency` | Composite B-tree | Fast rate lookup by currency pair |

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

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

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

### 3.3 Migration Notes

- Migration: Included in `db.ts` `initializeDatabase()` — runs on startup (SQLite) or via separate migration script (PostgreSQL)
- Zero-downtime: YES — only `INSERT` and `UPDATE status` needed; `CREATE INDEX CONCURRENTLY` for PostgreSQL
- Backfill required: NO — new tables
- Estimated migration time: < 1 second (SQLite), < 5 seconds (PostgreSQL)

---

## 4. API Contract

**Base Path:** `/v1/transactions`

### `POST /v1/transactions/remittance`

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

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

**Request Body:**
```json
{
  "recipientId": "rec_abc123def456gh78",
  "amount": 2000,
  "bankAccountId": "ba_abc123def456gh78",
  "currency": "NOK"
}
```

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

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

---

### `POST /v1/transactions/qr-payment`

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

**Authentication:** Bearer JWT required

**Request Body:**
```json
{
  "merchantId": "mer_abc123def456gh78",
  "amount": 450
}
```

**Success Response — `201 Created`:**
```json
{
  "data": {
    "id": "tx_qr_abc123def456gh78",
    "type": "qr_payment",
    "status": "completed",
    "amount": 450,
    "fee": 4.5,
    "merchantName": "Café Oslo AS",
    "createdAt": "2026-02-23T10:00:00.000Z"
  }
}
```

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

---

### `POST /v1/transactions/disclosure`

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

**Authentication:** Bearer JWT required

**Request Body:**
```json
{
  "type": "remittance",
  "amount": 2000,
  "recipientId": "rec_abc123def456gh78"
}
```

**Success Response — `200 OK`:**
```json
{
  "data": {
    "sendAmount": 2000,
    "sendCurrency": "NOK",
    "fee": 10,
    "feePercentage": 0.5,
    "exchangeRate": 10.17,
    "receiveAmount": 20340,
    "receiveCurrency": "RSD",
    "totalCost": 2010,
    "estimatedDelivery": "2-4 business days"
  }
}
```

---

### `GET /v1/transactions`

**Summary:** List authenticated user's transactions with pagination

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

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

---

## 5. Algorithm Specifications

### 5.1 Fee Calculation — Remittance

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

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

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

**Edge Cases:**
- **Minimum amount:** 100 NOK → fee = 0.50 NOK
- **Maximum amount:** 50,000 NOK → fee = 250 NOK
- **Rate not found:** Return 404 — do not proceed to transaction

### 5.2 Idempotency Key Generation

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

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

---

## 6. Sequence Diagrams

### 6.1 Remittance Initiation Flow

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

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

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

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

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

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

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

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

### 6.2 QR Payment Flow

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

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

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

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

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

---

## 7. State Diagrams

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

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

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

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

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

---

## 8. Error Handling Strategy

### 8.1 Error Classification

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

### 8.2 Error Response Format

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

### 8.3 Retry & Fallback Strategy

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

---

## 9. Concurrency & Thread Safety

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

---

## 10. Performance Considerations

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

**Known bottlenecks:**
- PISP API latency: 200-2000ms external call — mitigated by async SCA redirect pattern
- Exchange rate reads: High frequency but 6 rows only — fully cached in PostgreSQL buffer pool

---

## 11. Dependencies

### Internal Dependencies
| Dependency | Type | Purpose | Fallback if unavailable |
|-----------|------|---------|------------------------|
| `middleware/auth.ts` | Synchronous | JWT validation + user context | None — request rejected with 401 |
| `middleware/rate-limit.ts` | Synchronous | IP + user rate limiting | None — request rejected with 429 |
| `lib/db.ts` | Required | All data access (query, run, transaction) | None — module unavailable |

### External Dependencies
| Dependency | Version | Purpose | Fallback if unavailable |
|-----------|---------|---------|------------------------|
| PostgreSQL | 16 | Primary data store | SQLite (dev only) |
| Open Banking PISP (Neonomics/ASPSP) | Berlin Group v1.3.12+ | Payment initiation | None — return 502, payment cannot proceed |
| Open Banking AISP | Berlin Group v1.3.12+ | Pre-payment balance verification | Use cached `bank_accounts.balance` with staleness warning |

---

## 12. Configuration Parameters

| Variable | Type | Default | Required | Description |
|---------|------|---------|----------|-------------|
| `DATABASE_URL` | `string` | — | No (SQLite default) | PostgreSQL connection string |
| `NEXT_PUBLIC_SERVICE_MODE` | `string` | `mock` | No | `mock` = simulate PISP; `production` = real PISP calls |
| `OPEN_BANKING_API_URL` | `string` | — | Yes (prod) | Neonomics or ASPSP base URL |
| `OPEN_BANKING_CLIENT_ID` | `string` | — | Yes (prod) | eIDAS client identifier |
| `OPEN_BANKING_CLIENT_SECRET` | `string` | — | Yes (prod) | eIDAS client secret |

---

## 13. Testing Approach

| Test Type | Tool | Coverage Target | Location |
|-----------|------|-----------------|----------|
| Unit tests | Vitest | > 80% business logic | `src/drop-api/src/__tests__/unit/transactions/` |
| Integration tests | Supertest | Key payment flows | `src/drop-api/src/__tests__/integration/transactions/` |

**Key test scenarios:**
- [x] Remittance — success path (mock PISP)
- [x] Remittance — KYC not approved → 403
- [x] Remittance — amount < 100 NOK → 422
- [x] Remittance — amount > 50,000 NOK → 422
- [x] Remittance — recipient not found → 404
- [x] Remittance — duplicate request (idempotency key) → 409 with existing transaction
- [x] QR payment — success path
- [x] QR payment — merchant inactive → 404
- [x] Disclosure — calculates fee, exchange rate, receive amount correctly
- [ ] PISP circuit breaker — 3 failures → open → 502 (integration test, Phase 2)
- [ ] Concurrent payment — balance race condition handled correctly (Phase 2)

---

## Approval
| Role | Name | Date | Signature |
|------|------|------|-----------|
| Author | Petter Graff | 2026-02-23 | |
| Module Owner | John (AI Director) | | |
| Security Review | | | |
| Tech Lead | John (AI Director) | | |

# LLD: Withdrawal Flow

# Withdrawal Request Flow (Angrerett)

## Purpose

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

## Sequence Diagram

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

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

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

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

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

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

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

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

## Database Schema

### withdrawal_requests table

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

Index: `idx_withdrawal_requests_user` on `user_id`.

## Valid Withdrawal Reasons

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

## Request Processing

1. **Authentication** -- request must include a valid JWT token (authMiddleware).
2. **Input validation** -- reason is checked against the allowlist; both reason and comment are sanitized via `sanitizeText` with length limits.
3. **Record creation** -- a new `withdrawal_requests` row is inserted with status `pending`.
4. **Audit logging** -- an audit log entry is created with action `WITHDRAWAL_REQUEST`, including the reason and the requester's IP address.

## Status Lifecycle

```
pending --> processing --> completed
                      \-> rejected
```

- **pending** -- initial state after user submits request.
- **processing** -- staff/admin has begun reviewing the request.
- **completed** -- withdrawal has been executed, account closed or service cancelled.
- **rejected** -- request was denied (e.g., outside cooling-off period, regulatory hold).

## Error States

| Scenario                  | HTTP Status | Error Code         |
|---------------------------|-------------|--------------------|
| Missing/invalid JWT       | 401         | unauthorized       |
| Malformed JSON body       | 400         | bad_request        |
| Invalid reason value      | 400         | validation_error   |
| Database write failure    | 500         | internal_error     |

## Edge Cases

- **Duplicate requests** -- no uniqueness constraint on user_id; a user can submit multiple withdrawal requests. Business logic should handle deduplication at the review stage.
- **Already deleted user** -- the foreign key on user_id ensures the user must exist. If the user record has `deleted_at` set, the auth middleware should reject the request before it reaches this route.
- **AML retention** -- even after withdrawal is completed, transaction records and AML-related data must be retained for 5 years per hvitvaskingsloven. The data retention cron (`/cron/retention`) handles anonymization after the retention period expires.

## Cross-References

- **Angrerettloven** -- Norwegian Act on the Right of Withdrawal (consumer protection).
- **Data retention** -- See `src/drop-api/src/routes/cron.ts` retention endpoint and `docs/architecture/lld/flow-kyc-aml.md` for AML retention requirements.
- **Audit logging** -- See `src/drop-api/src/lib/audit.ts` for audit log implementation.

# LLD: Middleware Lifecycle Flow

# Middleware Lifecycle — Low-Level Design

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

---

## Overview

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

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

---

## Middleware Execution Order

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

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

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

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

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

---

## Global Middleware (Applied to Every Request)

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

### 1. CORS (`hono/cors`)

**Source:** `app.ts:23-30`

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

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

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

### 2. Request ID

**Source:** `app.ts:33-38`

Generates or propagates a unique request identifier for distributed tracing.

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

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

### 3. Client IP

**Source:** `app.ts:41-47`

Extracts the originating client IP address from proxy headers.

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

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

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

### 4. Global Error Handler

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

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

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

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

---

## Route Mounting

**Source:** `app.ts:53-72`

All API routes are mounted under a versioned prefix:

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

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

### Mounted Route Groups

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

---

## Per-Route Middleware

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

### Authentication Middleware (`middleware/auth.ts`)

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

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

**Flow:**
1. Extract bearer token from `Authorization` header or cookie
2. Verify JWT signature (HS256) and check session in `sessions` table
3. If invalid or expired: return `401 Unauthorized`
4. If role mismatch (merchant/admin variants): return `403 Forbidden`
5. Set `c.set("user", authUser)` for downstream handlers

### Rate Limiting (`middleware/rate-limit.ts`)

Rate limiting is **not** a Hono middleware function — it is a utility called inline within route handlers.

```typescript
// Example from transactions.ts
if (!(await rateLimit(ip, 10))) {
  return c.json({ error: "rate_limited", message: "Too many requests" }, 429);
}
```

| Parameter | Description |
|-----------|-------------|
| `ip` | Rate limit key (usually client IP, sometimes `user:{id}`) |
| `limit` | Maximum requests per window |
| `windowMs` | Window duration in ms (default: 60000 = 1 minute) |

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

**Per-endpoint limits:**

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

### Feature Gates (`lib/feature-flags.ts`)

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

```typescript
// Example from cards.ts
if (!isEnabled("virtualCards")) {
  return c.json({ error: "not_found", message: "Feature not available" }, 404);
}
```

| Flag | Default | Controls |
|------|---------|----------|
| `virtualCards` | `false` | Card creation, listing, detail, cancellation |
| `physicalCards` | `false` | Physical card ordering |
| `cardDetails` | `false` | Card detail endpoint |
| `cardFreeze` | `false` | Card freeze/unfreeze |
| `cardPin` | `false` | Card PIN management |
| `spendingLimits` | `false` | Spending limit management |
| `notifications` | `true` | Notification endpoints |
| `merchantDashboard` | `true` | Merchant dashboard |

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

---

## Complete Request Lifecycle (Sequence Diagram)

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

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

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

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

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

---

## Input Validation

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

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

---

## Cross-References

- [Security Architecture](../hld/security-architecture.md) — Trust boundaries, STRIDE, application security controls
- [Authentication](../../backend/AUTHENTICATION.md) — JWT, session management, BankID OIDC
- [API Reference](../../backend/API-REFERENCE.md) — Endpoint specifications and security requirements
- [Login Authentication Flow](flow-login-authentication.md) — BankID OIDC authentication detail