# Integration Specifications

Third-party integration architecture and flows

# BankID OIDC Integration

# BankID OIDC Integration

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

---

## 1. Overview

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

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

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

---

## 2. BankID OIDC Flow

### 2.1 Web Flow (Next.js BFF)

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

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

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

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

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

### 2.2 Mobile Flow (Hono API + Expo)

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

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

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

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

---

## 3. Token Lifecycle

### 3.1 Drop JWT (Issued After BankID Auth)

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

### 3.2 JWT Payload Structure

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

### 3.3 Token Lifetimes

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

### 3.4 Session Tracking

Every JWT has a corresponding record in the `sessions` table:

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

On every authenticated request, the middleware:
1. Extracts JWT from cookie (web) or Authorization header (mobile)
2. Verifies JWT signature and expiry with `jose.jwtVerify()`
3. Hashes the JWT with SHA-256
4. Looks up the session by `token_hash`
5. Verifies `revoked = 0` and `expires_at > now`
6. Rejects the request if any check fails

---

## 4. BankID Claims Mapping

### 4.1 ID Token Claims

| BankID Claim | Type | Drop Usage | Stored In |
|---|---|---|---|
| `sub` | string | Subject identifier (BankID internal) | Fallback for `pid` |
| `pid` | string | Norwegian fødselsnummer (11 digits) | Hashed as `users.national_id_hash` (SHA-256) |
| `name` | string | Full name (e.g., "Ola Nordmann") | Split into `users.first_name` + `users.last_name` |
| `birthdate` | string | ISO date (not always present) | Parsed from `pid` instead (more reliable) |
| `iss` | string | Issuer URL | Validated against expected issuer |
| `aud` | string | Client ID | Validated against `BANKID_CLIENT_ID` |
| `exp` | number | Token expiry | Validated (must be in future) |
| `iat` | number | Issued at | Validated (must be reasonable) |
| `nonce` | string | Anti-replay | Matched against value sent in auth request |

### 4.2 PID (Fødselsnummer) Processing

The `pid` (personal identification number) is an 11-digit Norwegian national ID that encodes the birthdate:

```
Format: DDMMYYNNNCC
  DD   = Day of birth (01-31)
  MM   = Month of birth (01-12)
  YY   = Year of birth (2 digits)
  NNN  = Individual number (000-999)
  CC   = Check digits (Luhn variant)

Century derivation (from individual number):
  NNN 000-499 → born 1900-1999
  NNN 500-999 → born 2000-2099
```

**Source:** `bankid.ts:37-51` (`parseBirthdateFromPid`)

**Processing pipeline:**

1. **Parse birthdate** from pid digits → ISO date string (`YYYY-MM-DD`)
2. **Verify age >= 18** by comparing birthdate to current date (`isOver18()`)
3. **Hash pid** with SHA-256 for storage: `crypto.createHash("sha256").update(pid).digest("hex")`
4. **Never store raw pid** — only the hash exists in the database (`users.national_id_hash`)

### 4.3 User Deduplication

Users are identified by `national_id_hash` (SHA-256 of their fødselsnummer). This ensures:
- The same person logging in on different devices gets the same account
- Re-authentication after token expiry reconnects to the existing account
- Future Vipps Login integration can match users by the same pid hash

**Source:** `bankid.ts:208-249` (`findOrCreateUser`)

---

## 5. User Creation (Auto-Registration)

BankID login automatically creates user accounts. There is no separate registration step.

| Field | Value | Source |
|---|---|---|
| `id` | `usr_<hex16>` | `randomId("usr")` |
| `email` | `usr_xxx@bankid.drop.local` | Placeholder (BankID doesn't provide email) |
| `password_hash` | `EIDONLY` | Sentinel — no password authentication |
| `auth_provider` | `bankid` | Indicates BankID-only auth |
| `first_name` | First word of `name` claim | Split from BankID name |
| `last_name` | Remaining words of `name` claim | Split from BankID name |
| `date_of_birth` | Parsed from pid | `parseBirthdateFromPid(pid)` |
| `kyc_status` | `approved` | BankID = verified identity |
| `kyc_method` | `bankid` | KYC via BankID |
| `kyc_verified_at` | Current timestamp | Set on creation |
| `national_id_hash` | SHA-256(pid) | For user deduplication |
| `role` | `user` | Default; upgradable to `merchant` |

---

## 6. SCA Compliance

### 6.1 PSD2 SCA Requirements Met by BankID

| PSD2 RTS Requirement | BankID Implementation | Status |
|---|---|---|
| Two of three factors (knowledge, possession, inherence) | BankID app (possession) + personal code (knowledge) or biometrics (inherence) | Met |
| Independence of factors | Separate device (BankID app) + separate knowledge factor | Met |
| Dynamic linking (for PISP) | Amount and payee displayed in BankID app during payment approval | Met (ASPSP-side) |
| Authentication code specific to amount + payee | BankID generates unique code per transaction | Met (ASPSP-side) |
| Session timeout | BankID sessions expire (configurable by ASPSP) | Met |
| Max 5 failed attempts | BankID locks after failed attempts | Met (BankID-side) |

### 6.2 Drop's SCA Scope

Drop performs SCA at two levels:

1. **Drop authentication (login):** BankID OIDC — satisfies SCA for account access
2. **Payment SCA (PISP):** Delegated to ASPSP — user re-authenticates with BankID at the bank for each payment (see [open-banking-aisp-pisp.md](./open-banking-aisp-pisp.md))

---

## 7. Error Handling

### 7.1 Error Matrix

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

### 7.2 Error Flow

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

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

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

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

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

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

---

## 8. Mock Mode (Development)

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

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

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

**Mock flow:**
1. `initiateOIDC()` generates a redirect URL (same structure, but not called)
2. Frontend skips BankID redirect, posts a mock code to callback
3. `exchangeAndVerify()` detects `isMock=true` and returns mock user info
4. `findOrCreateUser()` runs normally (creates real DB records)

---

## 9. Production Migration Checklist

| Step | Description | Status |
|---|---|---|
| 1 | Register BankID OIDC client at `developer.bankid.no` | Not started |
| 2 | Obtain `BANKID_CLIENT_ID` and `BANKID_CLIENT_SECRET` | Not started |
| 3 | Configure callback URLs (web + mobile deep link) | Not started |
| 4 | Set `BANKID_MOCK=false` (or remove env var) | Pending |
| 5 | Test OIDC flow in BankID preprod environment | Not started |
| 6 | Configure JWKS caching (jose handles this, verify TTL) | Pending |
| 7 | Set secure `JWT_SECRET` (min 32 chars, from vault) | Pending |
| 8 | Verify nonce validation in callback | Implemented in code |
| 9 | Test underage rejection with real BankID test user | Not started |
| 10 | Test session revocation and re-authentication | Implemented in code |
| 11 | Move to production BankID endpoints | Not started |
| 12 | Monitor JWKS key rotation (BankID rotates keys) | Pending |

---

## 10. Phase 2: Vipps Login (Planned)

Vipps Login uses the same OIDC protocol. Integration plan:

1. Register Vipps Login client at `portal.vipps.no`
2. Add Vipps OIDC endpoints alongside BankID
3. Vipps also returns Norwegian pid (fødselsnummer)
4. User deduplication via `national_id_hash` — same hash regardless of whether user logs in with BankID or Vipps
5. UI: Login screen offers two buttons: "Logg inn med BankID" and "Logg inn med Vipps"

**Optional:** Use an OIDC aggregator like **Idura** to get a single integration point for BankID + Vipps + future providers.

---

## 11. Environment Variables

### Required (Production)

| Variable | Description | Example |
|---|---|---|
| `BANKID_CLIENT_ID` | OIDC client ID from BankID | `abc123-def456` |
| `BANKID_CLIENT_SECRET` | OIDC client secret | `secret-from-bankid` |
| `BANKID_CALLBACK_URL` | Web callback URL | `https://getdrop.no/api/auth/bankid/callback` |
| `BANKID_CALLBACK_URL_MOBILE` | Mobile deep link callback | `drop://auth/callback` |
| `JWT_SECRET` | Drop JWT signing secret (min 32 chars) | (from Vaultwarden) |

### Optional

| Variable | Description | Default |
|---|---|---|
| `BANKID_AUTHORIZE_URL` | Override authorize endpoint | BankID prod URL |
| `BANKID_TOKEN_URL` | Override token endpoint | BankID prod URL |
| `BANKID_JWKS_URL` | Override JWKS endpoint | BankID prod URL |
| `BANKID_ISSUER` | Override expected issuer | BankID prod issuer |
| `BANKID_MOCK` | Enable mock mode (`true`/`false`) | `false` |
| `JWT_ALGORITHM` | JWT signing algorithm | `HS256` |
| `JWT_EXPIRY` | Token lifetime | `24h` |

---

## 12. Cross-References

- **Authentication System:** [../../backend/AUTHENTICATION.md](../../backend/AUTHENTICATION.md) — Full auth system documentation
- **Open Banking AISP/PISP:** [open-banking-aisp-pisp.md](./open-banking-aisp-pisp.md) — ASPSP SCA (separate from BankID login SCA)
- **Security Architecture:** [../hld/security-architecture.md](../hld/security-architecture.md) — JWT security, session management
- **Login Flow (LLD):** [../lld/flow-login-authentication.md](../lld/flow-login-authentication.md) — Step-by-step login UX
- **Database Schema:** [../../backend/DATABASE-SCHEMA.md](../../backend/DATABASE-SCHEMA.md) — `users`, `sessions` tables
- **Source:** `src/drop-api/src/lib/bankid.ts` — BankID OIDC implementation

# Open Banking (AISP/PISP)

# Open Banking Integration: AISP & PISP

**Version:** 1.0
**Date:** 2026-02-21
**Author:** Banking Architecture Team
**Status:** Approved
**Applies to:** Drop — PSD2 Pass-Through Model

---

## 1. Overview

Drop operates as a **Third Party Provider (TPP)** under PSD2, using two regulated services:

- **AISP (Account Information Service Provider)** — reads bank account balances and transaction history from the user's bank
- **PISP (Payment Initiation Service Provider)** — initiates payments directly from the user's bank account

Drop **never holds customer funds**. All money stays in the user's bank account. Drop initiates payments on behalf of the user via PISP and reads balances via AISP, both requiring explicit user consent and Strong Customer Authentication (SCA).

### TPP Registration

Before operating as AISP/PISP, Drop must:

1. **Register with Finanstilsynet** (Norwegian FSA) as a payment institution or authorized agent
2. **Obtain eIDAS certificates** (QWAC for TLS, QSeal for signing) from a qualified TSP
3. **Register in the EBA TPP Register** for EU-wide passporting
4. **Onboard with ASPSPs** (banks) via their Open Banking developer portals or through an aggregator (e.g., Neonomics, Tink, Enable Banking)

| Registration Item | Authority | Status |
|---|---|---|
| Finanstilsynet PISP/AISP license | Finanstilsynet | Not applied (Phase 2 blocker) |
| eIDAS QWAC certificate | Qualified TSP (e.g., Buypass, Commfides) | Not obtained |
| eIDAS QSeal certificate | Qualified TSP | Not obtained |
| EBA TPP Register entry | EBA | Pending license |
| ASPSP onboarding (DNB, SpareBank 1, Nordea) | Per bank | Pending license |

**Interim approach:** Drop can operate as an **agent** under a licensed PSP's umbrella (1-3 months to set up) while preparing its own license application (6-12 months).

---

## 2. Berlin Group NextGenPSD2 API Standard

Drop targets the **Berlin Group NextGenPSD2** specification (v1.3.12+), which is the dominant Open Banking standard in Scandinavia and the EEA. Norwegian banks (DNB, SpareBank 1, Nordea) expose APIs conforming to this standard, often through the **BITS** (Banking Industry's Technical Secretariat) coordination framework.

### 2.1 API Endpoint Mapping

| Berlin Group Endpoint | Method | Drop Usage | Drop Feature |
|---|---|---|---|
| `/v1/consents` | POST | Create AISP consent | Bank Account Linking (`/accounts`) |
| `/v1/consents/{consentId}` | GET | Check consent status | Consent lifecycle management |
| `/v1/consents/{consentId}` | DELETE | Revoke consent | Settings / Account unlinking |
| `/v1/consents/{consentId}/authorisations` | POST | Start SCA for consent | Bank linking SCA redirect |
| `/v1/consents/{consentId}/status` | GET | Poll consent status | Post-SCA consent verification |
| `/v1/accounts` | GET | List user's bank accounts | Dashboard account selector |
| `/v1/accounts/{accountId}` | GET | Get account details | Bank Account detail view |
| `/v1/accounts/{accountId}/balances` | GET | Read balance | Dashboard balance display (`bank_accounts.balance`) |
| `/v1/accounts/{accountId}/transactions` | GET | Read transaction history | Transaction History (future) |
| `/v1/payments/sepa-credit-transfers` | POST | Initiate SEPA payment | Remittance (EEA corridors) |
| `/v1/payments/cross-border-credit-transfers` | POST | Initiate cross-border payment | Remittance (non-EEA corridors) |
| `/v1/payments/domestic-credit-transfers` | POST | Initiate Norwegian payment | QR Payment to merchant |
| `/v1/payments/{paymentId}` | GET | Check payment status | Transaction status tracking |
| `/v1/payments/{paymentId}/authorisations` | POST | Start SCA for payment | Payment SCA redirect |
| `/v1/payments/{paymentId}/status` | GET | Poll payment status | Post-SCA payment verification |

### 2.2 Base URLs (Norwegian Banks)

| Bank (ASPSP) | Sandbox URL | Production URL |
|---|---|---|
| DNB | `https://developer.dnb.no/sandbox/psd2/` | `https://api.dnb.no/psd2/` |
| SpareBank 1 | `https://developer.sparebank1.no/sandbox/` | `https://api.sparebank1.no/open-banking/` |
| Nordea | `https://developer.nordeaopenbanking.com/` | `https://api.nordeaopenbanking.com/` |

---

## 3. AISP — Account Information Service

### 3.1 Consent Flow

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

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

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

### 3.2 Balance Retrieval

After consent is granted, Drop reads balances to display on the Dashboard. The `bank_accounts.balance` column stores the **cached AISP-read value** — it is never a Drop-held balance.

**Refresh strategy:**

| Trigger | Frequency | Method |
|---|---|---|
| User opens Dashboard | On demand | GET `/v1/accounts/{id}/balances` |
| Background sync | Every 6 hours (max 4/day per PSD2 RTS) | Scheduled job per linked account |
| Pre-payment check | Before PISP initiation | GET `/v1/accounts/{id}/balances` |
| User manual refresh | Pull-to-refresh gesture | GET `/v1/accounts/{id}/balances` |

**PSD2 RTS constraint:** A TPP may access the ASPSP's account data **a maximum of 4 times per day** without the PSU's active request (Art. 36(6) RTS). User-initiated requests are unlimited.

**Drop API mapping:**

- `GET /api/auth/me` returns `totalBalance` and `bankAccounts[].balance` — these are cached AISP reads from the `bank_accounts` table
- `bank_accounts.balance_synced_at` tracks when the balance was last refreshed from the ASPSP

### 3.3 Data Storage

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

---

## 4. PISP — Payment Initiation Service

### 4.1 Payment Initiation Flow

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

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

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

### 4.2 Payment Types

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

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

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

- The payer must be made aware of the **amount** and **payee** during authentication
- The authentication code must be **specific** to that amount and payee
- Any change to amount or payee invalidates the authentication

**Implementation:** Drop passes `instructedAmount` and `creditorName` in the PISP request. The ASPSP displays these on the BankID SCA screen. The user confirms by authenticating, creating a cryptographic link between the consent and the specific transaction parameters.

### 4.4 Idempotency

Drop uses the `idempotency_key` column in the `transactions` table (with a unique index `idx_tx_idempotency`) to prevent duplicate payments:

1. Generate `idempotency_key` from: `{userId}:{recipientId}:{amount}:{timestamp_minute}`
2. Include as `X-Request-ID` header in PISP API calls
3. If ASPSP returns a duplicate error, look up existing transaction by `idempotency_key`
4. Return the existing transaction to the user (no double-charge)

---

## 5. Consent Lifecycle

### 5.1 State Diagram

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

### 5.2 Consent Properties

| Property | Value | PSD2 Reference |
|---|---|---|
| Maximum validity | 90 days | RTS Art. 10(1) |
| Renewal SCA required | Yes, every 90 days | RTS Art. 10(2) |
| Access frequency (TPP-initiated) | Max 4x/day per account | RTS Art. 36(6) |
| Access frequency (PSU-initiated) | Unlimited | RTS Art. 36(6) |
| Revocation | User can revoke at any time | PSD2 Art. 94 |
| Scope | Per-account (balances + transactions) | Berlin Group consent model |
| Combined service | `false` (AISP separate from PISP) | Berlin Group `combinedServiceIndicator` |

### 5.3 Consent Storage

Drop tracks consents in two places:

1. **`consents` table** — GDPR consent records (consent_type: `psd2_aisp`, `psd2_pisp`)
2. **`bank_accounts` table** — Links to ASPSP consent ID for each linked account

When a consent expires or is revoked:
- `bank_accounts.balance` is zeroed (stale data removed)
- `bank_accounts.balance_synced_at` is nulled
- User is prompted to re-link via notification

---

## 6. SCA Requirements

### 6.1 When SCA Is Required

| Operation | SCA Required | SCA Type |
|---|---|---|
| AISP consent creation | Yes | Redirect to bank (BankID) |
| AISP consent renewal (90 days) | Yes | Redirect to bank (BankID) |
| AISP balance read (after initial consent) | No | Access token sufficient |
| PISP payment initiation | Yes, always | Redirect to bank with dynamic linking |
| PISP payment > threshold | Yes (no exemption) | Drop does not apply SCA exemptions |

### 6.2 SCA Methods

Drop does not perform SCA directly. SCA is delegated to the ASPSP (user's bank), which uses BankID as the authentication mechanism. The flow is:

1. Drop sends AISP consent or PISP payment request to ASPSP
2. ASPSP returns an `scaRedirect` URL
3. Drop redirects the user to the bank's SCA page
4. User authenticates with BankID (knowledge + possession factors)
5. Bank redirects back to Drop with the result

### 6.3 SCA Exemptions

Drop does **not** apply SCA exemptions for PISP transactions. All payments require full SCA regardless of amount. This is a conservative approach that:
- Simplifies implementation
- Reduces fraud risk
- Avoids complex exemption logic (low value, trusted beneficiary, recurring)
- Aligns with Norwegian banks' SCA enforcement

---

## 7. Fallback Mechanisms

### 7.1 ASPSP API Unavailability

If the ASPSP's dedicated PSD2 API is unavailable:

| Scenario | Fallback | PSD2 Reference |
|---|---|---|
| API down (AISP) | Show last cached balance with timestamp | RTS Art. 33(4) |
| API down (PISP) | Display error, suggest retry later | No fallback — payment requires live API |
| API degraded (slow) | 30s timeout, retry once | Standard HTTP retry |
| API returns 5xx | Circuit breaker (3 failures → 60s cooldown) | Operational resilience |
| Consent expired | Prompt user to re-authenticate | Renewal flow |

### 7.2 Fallback Access (Screen Scraping)

Under PSD2 RTS Art. 33(4), if an ASPSP's dedicated API does not meet availability and performance standards, the TPP may fall back to the ASPSP's customer-facing online banking interface. Drop does **not** plan to implement screen scraping — instead relying on aggregators (Neonomics, Tink) who handle multi-bank connectivity and fallback.

### 7.3 Multi-Bank Strategy

Norwegian users may have accounts at multiple banks. Drop supports multiple linked accounts via the `bank_accounts` table (one marked `is_primary`). Each linked account has its own AISP consent with its own 90-day lifecycle.

---

## 8. Error Handling

### 8.1 ASPSP Error Codes (Berlin Group)

| HTTP Status | Berlin Group Code | Drop Handling | User Message |
|---|---|---|---|
| 400 | `FORMAT_ERROR` | Log + show validation error | "Kunne ikke koble til banken. Sjekk kontonummeret." |
| 401 | `CERTIFICATE_INVALID` | Alert ops, block requests | "Teknisk feil. Prøv igjen senere." |
| 403 | `CONSENT_INVALID` | Delete consent, prompt re-link | "Tilgangen til banken din er utløpt. Koble til på nytt." |
| 403 | `CONSENT_EXPIRED` | Delete consent, prompt re-link | "Tilgangen til banken din er utløpt. Koble til på nytt." |
| 404 | `RESOURCE_UNKNOWN` | Log + remove stale data | "Kontoen ble ikke funnet i banken." |
| 429 | `ACCESS_EXCEEDED` | Back off, use cached data | "For mange forespørsler. Viser sist kjente saldo." |
| 500+ | Server error | Circuit breaker, use cached data | "Banken svarer ikke. Prøv igjen senere." |

### 8.2 Payment-Specific Errors

| Scenario | ASPSP Response | Drop Handling |
|---|---|---|
| Insufficient funds | `RJCT` (rejected) | Update transaction status to `failed`, notify user |
| Invalid IBAN | `FORMAT_ERROR` | Reject before sending to ASPSP (validate locally) |
| SCA timeout | No callback within 5 min | Mark transaction as `failed`, release hold |
| SCA cancelled | User cancels at bank | Mark transaction as `failed` |
| Duplicate payment | `DUPLICATE` or HTTP 409 | Look up by `idempotency_key`, return existing |

---

## 9. Aggregator Strategy

Rather than integrating directly with each ASPSP, Drop plans to use an **Open Banking aggregator** for production:

| Aggregator | Coverage | Strengths | Consideration |
|---|---|---|---|
| **Neonomics** (Norwegian) | Nordics + EEA | Strong Nordic bank coverage, Norwegian company | Primary candidate |
| **Tink** (Visa) | EU-wide (6000+ banks) | Largest coverage, Visa backing | Broader coverage |
| **Enable Banking** | Nordics | Direct PSD2, no screen scraping | Privacy-focused |

**Benefits of aggregator approach:**
- Single API integration (vs. per-bank integration)
- Aggregator handles eIDAS certificates, bank onboarding, fallback
- Faster time-to-market
- Aggregator maintains bank API compatibility as banks update

**Trade-offs:**
- Additional per-transaction cost
- Data passes through third party (GDPR implications)
- Dependency on aggregator uptime

---

## 10. Implementation Roadmap

| Phase | Milestone | Dependencies |
|---|---|---|
| **Current (MVP)** | Mock AISP/PISP (local DB balances, simulated payments) | None |
| **Phase 2a** | Aggregator sandbox integration (Neonomics or Tink) | Aggregator contract signed |
| **Phase 2b** | BankID OIDC for Drop auth + ASPSP SCA for consents | BankID client credentials |
| **Phase 2c** | AISP live (read real balances from DNB, SpareBank 1) | eIDAS cert + consent flow |
| **Phase 2d** | PISP live (initiate real payments) | Finanstilsynet license or agent arrangement |
| **Phase 3** | Multi-bank support, consent renewal automation, SEPA Instant | Production scaling |

---

## 11. Cross-References

- **BankID OIDC Integration:** [bankid-oidc-integration.md](./bankid-oidc-integration.md) — Drop's authentication layer (separate from ASPSP SCA)
- **Payment Processing:** [payment-processing.md](./payment-processing.md) — SEPA/SWIFT settlement, FX, fees
- **Security Architecture:** [../hld/security-architecture.md](../hld/security-architecture.md) — Threat model, SCA controls
- **Bank Account Linking Flow:** [../lld/flow-bank-account-linking.md](../lld/flow-bank-account-linking.md) — Detailed AISP consent UX
- **Remittance Flow:** [../lld/flow-remittance.md](../lld/flow-remittance.md) — Detailed PISP payment UX
- **Database Schema:** [../../backend/DATABASE-SCHEMA.md](../../backend/DATABASE-SCHEMA.md) — `bank_accounts`, `transactions`, `consents` tables
- **API Reference:** [../../backend/API-REFERENCE.md](../../backend/API-REFERENCE.md) — Drop API endpoints
- **Compliance Status:** [../../security/COMPLIANCE.md](../../security/COMPLIANCE.md) — PSD2 readiness assessment

# Payment Processing

# Payment Processing Architecture

**Version:** 1.0
**Date:** 2026-02-21
**Author:** Banking Architecture Team
**Status:** Approved
**Applies to:** Drop — Payment Initiation & Settlement

---

## 1. Overview

Drop processes two types of payments, both initiated via **PISP** (Payment Initiation Service Provider) from the user's own bank account:

1. **Remittance** — cross-border money transfers to 30+ countries (via SEPA SCT/SCTInst for EEA, SWIFT gpi for non-EEA)
2. **QR Payment** — instant domestic payments to merchants in Norway

Drop **never holds customer funds**. All payments are initiated directly from the user's bank account via PSD2 Open Banking APIs. Drop earns revenue from transaction fees (0.5% remittance, 1% QR).

| Property | Remittance | QR Payment |
|---|---|---|
| API endpoint | `POST /api/transactions/remittance` | `POST /api/transactions/qr-payment` |
| Fee | 0.5% of send amount | 1.0% of payment amount |
| Amount range | 100 - 50,000 NOK | 1 - 100,000 NOK |
| Settlement rail | SEPA SCT/SCTInst (EEA), SWIFT (non-EEA) | Domestic credit transfer (instant) |
| KYC required | Yes (`kyc_status = 'approved'`) | No (auth sufficient) |
| FX conversion | Yes (NOK to recipient currency) | No (NOK to NOK) |
| Status flow | `processing` -> `completed` / `failed` | `completed` (instant) |

---

## 2. SEPA Payment Flow (EEA Remittance)

For remittances to EEA countries (EU + Norway, Iceland, Liechtenstein), Drop uses **SEPA Credit Transfer (SCT)** or **SEPA Instant Credit Transfer (SCT Inst)**.

### 2.1 SEPA SCT Flow

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

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

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

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

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

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

### 2.2 SEPA Specifications

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

---

## 3. Cross-Border Remittance with FX

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

### 3.1 Cross-Border Flow with FX Conversion

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

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

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

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

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

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

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

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

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

### 3.2 Supported Corridors

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

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

---

## 4. FX Rate Management

### 4.1 Rate Sourcing

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

### 4.2 Rate Lock Window

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

1. User sees rate on the disclosure screen
2. Rate is locked when user confirms (before SCA)
3. If SCA completes within 15 minutes, the locked rate applies
4. If SCA times out, the rate expires and must be re-quoted

This protects both the user (no surprise rate changes during authentication) and Drop (limited exposure to rate movement).

### 4.3 Rate Storage

| Column | Table | Description |
|---|---|---|
| `exchange_rates.rate` | `exchange_rates` | Current mid-market rate (NOK to target) |
| `exchange_rates.updated_at` | `exchange_rates` | Last rate update timestamp |
| `transactions.exchange_rate` | `transactions` | Rate locked at transaction time |
| `transactions.send_amount` | `transactions` | Amount in NOK (stored in oere) |
| `transactions.receive_amount` | `transactions` | Amount in target currency (stored in subunits) |

---

## 5. Fee Calculation Model

### 5.1 Fee Structure

| Transaction Type | Fee Rate | Min Fee | Max Fee | Applied To |
|---|---|---|---|---|
| Remittance | 0.5% | 10 NOK | 500 NOK | Send amount (before FX) |
| QR Payment | 1.0% | 1 NOK | 1,000 NOK | Payment amount |
| AISP balance read | Free | - | - | No charge |

### 5.2 Fee Calculation

**Remittance example (2,000 NOK to Serbia):**

| Line Item | Calculation | Amount |
|---|---|---|
| Send amount | User input | 2,000.00 NOK |
| Fee (0.5%) | 2,000 * 0.005 | 10.00 NOK |
| Total debit | Send + Fee | 2,010.00 NOK |
| Exchange rate | From `exchange_rates` table | 10.17 RSD/NOK |
| Receive amount | 2,000 * 10.17 | 20,340.00 RSD |

**QR Payment example (149 NOK at merchant):**

| Line Item | Calculation | Amount |
|---|---|---|
| Payment amount | From QR scan | 149.00 NOK |
| Fee (1.0%) | 149 * 0.01 | 1.49 NOK |
| Total debit | Payment + Fee | 150.49 NOK |
| Merchant receives | Payment - merchant fee | 147.51 NOK |

### 5.3 Fee Code References

| Endpoint | Fee Logic | Source |
|---|---|---|
| `POST /api/transactions/remittance` | `fee = amount * 0.005` | `transactions/remittance/route.ts` |
| `POST /api/transactions/qr-payment` | `fee = amount * 0.01` | `transactions/qr-payment/route.ts` |
| `POST /api/transactions/disclosure` | Returns fee + FX pre-payment | `transactions/disclosure/route.ts` |
| `GET /api/rates/[currency]` | Returns `fee: 0.005` (informational) | `rates/[currency]/route.ts` |

### 5.4 Revenue Model Comparison

| Provider | Remittance Fee | QR/In-Store Fee |
|---|---|---|
| **Drop** | **0.5%** | **1.0%** |
| Western Union | 5-10% | N/A |
| Wise | 0.7-1.5% | N/A |
| Vipps | N/A | 1.75-2.75% |

---

## 6. Settlement & Reconciliation

### 6.1 Settlement Flow

Drop does not settle payments itself — the ASPSP (user's bank) handles settlement via interbank rails. Drop's role is to **initiate** and **track** payments.

| Step | Actor | Action |
|---|---|---|
| 1. Initiation | Drop | POST PISP request to ASPSP |
| 2. SCA | User + ASPSP | User authenticates at bank |
| 3. Acceptance | ASPSP | Bank accepts payment instruction |
| 4. Clearing | CSM (SEPA) / SWIFT | Message routed to recipient bank |
| 5. Settlement | Central bank / CSM | Funds transferred between banks |
| 6. Credit | Recipient bank | Recipient account credited |
| 7. Confirmation | Drop | Poll payment status, update transaction |

### 6.2 Reconciliation Process

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

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

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

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

### 6.3 ASPSP Transaction Statuses (Berlin Group)

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

---

## 7. Idempotency & Retry Strategy

### 7.1 Idempotency

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

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

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

**Flow:**
1. Before creating a transaction, check if `idempotency_key` already exists
2. If exists, return the existing transaction (no duplicate)
3. If not, insert new transaction with the key
4. Pass the same key as `X-Request-ID` to the ASPSP

### 7.2 Retry Strategy

| Failure Type | Retry? | Strategy |
|---|---|---|
| Network timeout to ASPSP | Yes | Exponential backoff: 1s, 2s, 4s (max 3 retries) |
| ASPSP returns 5xx | Yes | Exponential backoff with jitter, max 3 retries |
| ASPSP returns 4xx | No | Log error, fail immediately (client error) |
| SCA timeout | No | Mark as failed, user must restart |
| Duplicate detected | No | Return existing transaction |
| FX rate expired | No | Re-quote rate, user must re-confirm |

---

## 8. Pre-Payment Disclosure (PSD2 Art. 45/46)

Before initiating any payment, Drop must provide the user with clear information about costs and delivery. The `POST /api/transactions/disclosure` endpoint generates this.

### 8.1 Disclosure Content

| Information Item | PSD2 Article | Drop Implementation |
|---|---|---|
| Total amount debited | Art. 45(1)(a) | `totalCost` = amount + fee |
| Fee amount and percentage | Art. 45(1)(b) | `fee`, `feePercentage` |
| Exchange rate applied | Art. 45(1)(c) | `exchangeRate` |
| Amount received by recipient | Art. 45(1)(d) | `receiveAmount` in `receiveCurrency` |
| Estimated delivery time | Art. 45(1)(e) | `estimatedDelivery` |
| Currency of debit | Art. 45(1)(f) | Send currency (NOK) |
| Currency of credit | Art. 45(1)(g) | Receive currency |

### 8.2 Disclosure API Response

```json
{
  "amount": 2000,
  "fee": 10,
  "feePercentage": 0.5,
  "exchangeRate": 10.17,
  "receiveAmount": 20340,
  "receiveCurrency": "RSD",
  "estimatedDelivery": "2-4 business days",
  "totalCost": 2010
}
```

### 8.3 Delivery Time Estimates

| Transaction Type | Corridor | Estimate |
|---|---|---|
| QR Payment | Domestic (Norway) | "Instant" |
| Remittance | EEA (SEPA) | "1-2 business days" |
| Remittance | Non-EEA (SWIFT) | "2-4 business days" |

---

## 9. Transaction Integrity

### 9.1 Atomic Operations

All financial operations use database transactions to ensure atomicity. The `transaction()` function in `db.ts:123-179` wraps operations in `BEGIN`/`COMMIT` blocks with automatic `ROLLBACK` on error.

Key integrity checks:
- `WHERE balance >= ?` prevents overdraw
- PostgreSQL MVCC + `READ COMMITTED` isolation (default) prevents dirty reads; use `SERIALIZABLE` for phantom read protection
- Fee calculated and included in the single atomic debit

### 9.2 Consistency Guarantees

| Guarantee | Mechanism |
|---|---|
| No double-spend | `WHERE balance >= ?` in UPDATE + idempotency_key |
| No partial transactions | PostgreSQL `BEGIN`/`COMMIT` |
| No phantom reads | PostgreSQL MVCC snapshot isolation; use SERIALIZABLE isolation for full phantom read protection |
| No duplicate payments | Unique index on `idempotency_key` |
| No stale balances | `balance_synced_at` tracking + pre-payment AISP refresh |

---

## 10. Monitoring & Alerts

### 10.1 Key Metrics

| Metric | Threshold | Alert |
|---|---|---|
| Transaction success rate | < 95% over 1 hour | Critical alert |
| Average settlement time (SEPA) | > 48 hours | Warning |
| Average settlement time (SWIFT) | > 5 business days | Warning |
| Failed transaction rate | > 5% | Warning |
| Reconciliation mismatches | Any | Immediate alert |
| FX rate staleness | > 1 hour since last update | Warning |

### 10.2 Audit Trail

All payment operations are logged in the `audit_log` table:

| Action | Logged Data |
|---|---|
| `payment.initiated` | Transaction ID, amount, recipient, bank account |
| `payment.sca_completed` | Transaction ID, SCA method |
| `payment.completed` | Transaction ID, ASPSP status, settlement reference |
| `payment.failed` | Transaction ID, failure reason, ASPSP error |
| `payment.refund` | Original transaction ID, refund amount |

---

## 11. Cross-References

- **Open Banking AISP/PISP:** [open-banking-aisp-pisp.md](./open-banking-aisp-pisp.md) — Berlin Group API integration, consent lifecycle
- **BankID OIDC:** [bankid-oidc-integration.md](./bankid-oidc-integration.md) — Authentication (not payment SCA)
- **Security Architecture:** [../hld/security-architecture.md](../hld/security-architecture.md) — Fraud detection, AML screening
- **Remittance Flow (LLD):** [../lld/flow-remittance.md](../lld/flow-remittance.md) — Step-by-step remittance UX
- **Database Schema:** [../../backend/DATABASE-SCHEMA.md](../../backend/DATABASE-SCHEMA.md) — `transactions`, `exchange_rates`, `bank_accounts` tables
- **API Reference:** [../../backend/API-REFERENCE.md](../../backend/API-REFERENCE.md) — Transaction, disclosure, and rate endpoints
- **Compliance:** [../../security/COMPLIANCE.md](../../security/COMPLIANCE.md) — PSD2, AML readiness

# Sentry Observability

# Sentry Observability Integration

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

---

## Observability Data Flow

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

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

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

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

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

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

---

## Sentry Configuration

### SDK Setup

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

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

### Environment Variables

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

### Sentry Project Structure

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

---

## Data Scrubbing

**Source:** `sentry.ts:108-139`

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

### String Scrubbing Patterns

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

### Object Scrubbing

Sensitive keys are recursively scrubbed from event `extra` data, breadcrumbs, and request bodies:
- Keys containing: `password`, `pin`, `cardNumber`, `cvv`, `fødselsnummer`, `authorization`, `cookie`
- Values replaced with `[REDACTED]`

### Request Header Removal

The following headers are deleted from Sentry events entirely:
- `authorization` (Bearer tokens)
- `cookie` (session cookies)
- `set-cookie` (response cookies)

---

## Sentry SDK Functions

**Source:** `sentry.ts:141-244`

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

### Dual Output Pattern

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

---

## Alert System

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

### Slack Alerting

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

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

### Error Spike Detection

**Source:** `alerts.ts:65-83`

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

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

### Alert Message Format

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

---

## Structured Logging

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

### Log Entry Format

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

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

### Log Levels

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

### Request Context

Each request gets a unique `requestId` (from `x-request-id` header or `crypto.randomUUID()`). This ID is:
1. Set as a Hono context variable (`app.ts:34`)
2. Returned in the `x-request-id` response header (`app.ts:36`)
3. Included in all log entries for the request
4. Attached as a Sentry tag for cross-referencing

---

## Error Handling Flow

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

    Route->>Route: Business logic throws error

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

---

## SLO/SLI Definitions

### Service Level Indicators (SLIs)

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

### Service Level Objectives (SLOs)

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

### Performance Targets (from architecture document)

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

---

## Alert Rule Configuration

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

---

## Release Tracking

### Source Map Upload

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

### Release Metadata

Each release in Sentry includes:
- **Version:** `drop-api@{package.json version}` (e.g., `drop-api@0.1.0`)
- **Environment:** `development`, `staging`, or `production`
- **Commits:** Automatically linked via `--auto` flag
- **Source maps:** Uploaded for stack trace deobfuscation

---

## Cross-References

- **Error handler:** `src/drop-api/src/middleware/error-handler.ts` — Global error handler that triggers Sentry capture
- **Logger:** `src/drop-api/src/lib/logger.ts` — Structured JSON logging
- **Sentry SDK:** `src/drop-api/src/lib/sentry.ts` — Full Sentry integration with data scrubbing
- **Alert system:** `src/drop-api/src/lib/alerts.ts` — Slack webhook alerting with spike detection
- **Security architecture:** [SECURITY-ARCHITECTURE.md](../../security/SECURITY-ARCHITECTURE.md) — Security monitoring context
- **Deployment:** [deployment-architecture.md](../hld/deployment-architecture.md) — CloudWatch and infrastructure monitoring

# Sumsub KYC Integration

# Sumsub KYC/AML Integration

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

---

## KYC Verification Flow

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

    Note over User,SumsubSDK: Step 1: Initiate KYC

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

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

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

    Note over User,SumsubSDK: Step 3: Webhook Callback

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

---

## Applicant State Machine

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

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

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

    note right of pending
        Documents uploaded
        AI processing
    end note

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

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

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

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

### Status Mapping

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

---

## Webhook Architecture

### Webhook Endpoint

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

### Webhook Events

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

### Signature Verification

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

Verification logic:
1. Read raw request body
2. Compute `HMAC-SHA256(body, SUMSUB_SECRET_KEY)`
3. Compare with `X-Payload-Digest` header (timing-safe comparison)
4. Reject with 401 if signature mismatch

### Retry Policy

Sumsub retries webhook delivery on non-2xx responses:

| Attempt | Delay | Total Wait |
|---------|-------|-----------|
| 1 | Immediate | 0s |
| 2 | 30s | 30s |
| 3 | 2m | 2m 30s |
| 4 | 10m | 12m 30s |
| 5 | 30m | 42m 30s |
| 6 | 1h | 1h 42m 30s |
| 7 | 2h | 3h 42m 30s |
| 8 (final) | 4h | 7h 42m 30s |

After 8 failed attempts, the webhook is marked as failed in Sumsub dashboard.

---

## Idempotency Handling

Webhooks may be delivered multiple times. Drop handles this via the `screening_results` table:

1. On webhook receipt, check if `screening_results` already has an entry with matching `user_id` + `screening_type` + same `result`
2. If duplicate: return 200 OK immediately (acknowledge but don't process)
3. If new or changed result: process the update, insert new `screening_results` record

### Database Records Created Per Webhook

| Table | Record | Purpose |
|-------|--------|---------|
| `users` | UPDATE `kyc_status` | Primary KYC status |
| `screening_results` | INSERT | Detailed verification result with timestamp |
| `audit_log` | INSERT | Immutable audit trail |
| `notifications` | INSERT (if status changed) | User notification |

---

## Document Check Types

### Verification Level: `basic-kyc-level`

| Check | Type | Description | Required |
|-------|------|------------|----------|
| Document authenticity | `IDENTITY` | Verify ID document is genuine (not altered/forged) | Yes |
| Liveness check | `SELFIE` | Verify real person (not photo/video) | Yes |
| Face match | `SELFIE` | Match selfie to document photo | Yes |
| Data extraction | `IDENTITY` | OCR extraction of name, DOB, document number | Yes |
| Sanctions screening | `SCREENING` | Check against OFAC, UN, EU sanctions lists | Yes |
| PEP screening | `SCREENING` | Politically Exposed Persons database check | Yes |
| Adverse media | `SCREENING` | News and media screening for negative coverage | Optional |

### Accepted Document Types

| Document Type | Sumsub Code | Countries |
|--------------|-------------|-----------|
| Passport | `PASSPORT` | All |
| National ID card | `ID_CARD` | EEA countries |
| Driver's license | `DRIVERS` | Norway, Sweden, Denmark, Finland |
| Residence permit | `RESIDENCE_PERMIT` | Norway (non-citizen residents) |

---

## Risk Level Definitions

### Risk Level Matrix

| Risk Level | Score Range | Criteria | Drop Action |
|------------|-----------|---------|-------------|
| **Low** | 0-30 | All checks passed, no PEP/sanctions match, standard country | `kyc_status = 'approved'`, standard transaction limits |
| **Medium** | 31-60 | Minor issues (document quality, partial name match), non-sanctioned PEP | `kyc_status = 'approved'`, enhanced monitoring via `aml_alerts`, lower initial limits |
| **High** | 61-80 | Potential PEP match, high-risk country, document concerns | `kyc_status = 'pending'`, manual review required, flag in `screening_results` |
| **Critical** | 81-100 | Sanctions match, confirmed fraud indicators, age verification failure | `kyc_status = 'rejected'`, immediate block, create `aml_alerts` record, file potential STR |

### Risk Assessment Factors

| Factor | Weight | Source |
|--------|--------|--------|
| Document authenticity score | 30% | Sumsub AI analysis |
| Liveness/face match score | 20% | Sumsub biometric analysis |
| Country risk (origin/destination) | 20% | Drop internal risk scoring |
| PEP/sanctions screening | 20% | Sumsub screening databases |
| Transaction pattern analysis | 10% | Drop `aml_alerts` historical data |

### High-Risk Countries for Drop

Based on remittance corridor analysis and FATF grey/black lists:

| Category | Countries | Additional Controls |
|----------|-----------|-------------------|
| Standard | Norway, Sweden, Denmark, Finland, EU/EEA | Standard KYC |
| Enhanced due diligence | Turkey, Pakistan | Enhanced monitoring, lower limits |
| Restricted | FATF grey list countries | Manual review required |
| Blocked | OFAC/UN sanctioned countries | Automatic rejection |

---

## SDK Integration

### Web Integration (WebSDK)

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

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

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

### Mobile Integration (React Native SDK)

```typescript
// Initialize Sumsub React Native SDK in drop-mobile
import SNSMobileSDK from '@sumsub/react-native-mobilesdk-plugin';

async function launchSumsubMobile(accessToken: string) {
  const snsMobileSDK = SNSMobileSDK.init(accessToken, async () => {
    // Token expiry handler
    const response = await fetch(`${API_URL}/v1/user/kyc/refresh-token`, {
      headers: { Authorization: `Bearer ${authToken}` },
    });
    const data = await response.json();
    return data.token;
  })
  .withHandlers({
    onStatusChanged: (event) => {
      if (event.newStatus === 'Approved') {
        navigation.navigate('Dashboard');
      }
    },
    onError: (error) => {
      Sentry.captureException(error);
    },
  })
  .withLocale('nb')
  .withTheme({
    primaryColor: '#0B6E35',
  })
  .build();

  const result = await snsMobileSDK.launch();
  return result;
}
```

---

## Error Recovery

### Failure Scenarios and Recovery

| Failure | Detection | Recovery |
|---------|-----------|---------|
| Sumsub API timeout (applicant creation) | 30s timeout in `kyc.ts:44` | Return `rejected` with error message, user can retry |
| Sumsub API timeout (status check) | 30s timeout in `kyc.ts:148` | Return `rejected`, poll again on next request |
| Access token generation failure | Non-200 response from `/resources/accessTokens` | Return `pending` with applicant ID (applicant created but no widget) |
| Webhook delivery failure | Sumsub retry (8 attempts over 7h) | Automatic retry by Sumsub |
| Webhook signature mismatch | 401 response to webhook | Alert ops team, check `SUMSUB_SECRET_KEY` rotation |
| Database write failure on webhook | Transaction rollback | Return 500 to trigger Sumsub retry |
| Duplicate webhook | Idempotency check via `screening_results` | Acknowledge with 200, skip processing |
| SDK widget crash | `onError` callback | Capture in Sentry, show user-friendly error with retry option |

### Manual Recovery Procedures

If a user is stuck in `pending` status after Sumsub has completed review:

1. Check `screening_results` table for latest result
2. Query Sumsub API: `GET /resources/applicants/{externalUserId}/status`
3. If Sumsub shows `GREEN` but DB shows `pending`: manually update via admin endpoint
4. If Sumsub shows `RED`: communicate rejection reason to user

---

## Environment Variables

| Variable | Required | Description |
|----------|----------|-------------|
| `SUMSUB_API_URL` | Production | Sumsub API base URL (e.g., `https://api.sumsub.com`) |
| `SUMSUB_APP_TOKEN` | Production | Application token from Sumsub dashboard |
| `SUMSUB_SECRET_KEY` | Production | Secret key for HMAC signature verification |
| `SUMSUB_WEBHOOK_SECRET` | Production | Separate secret for webhook payload verification |
| `NEXT_PUBLIC_SERVICE_MODE` | All | When `mock`: uses auto-approve mock, no Sumsub calls |

---

## Demo Mode Behavior

**Source:** `lib/services/kyc.ts:26-28`

When `NEXT_PUBLIC_SERVICE_MODE=mock` (demo mode):
- `initiateKyc()` returns `{ status: "approved" }` immediately
- `checkKycStatus()` returns `{ status: "approved" }` immediately
- No Sumsub API calls are made
- Users created via BankID get `kyc_status = 'approved'` automatically (`bankid.ts:233`)

The mock Sumsub service (`mock-sumsub.ts`) provides a full simulation for UI development with applicant creation, document submission, and asynchronous verification (3-second delay, 90% approval rate).

---

## Cross-References

- **KYC service:** `src/drop-app/src/lib/services/kyc.ts` — Production KYC initiation and status check
- **Mock Sumsub:** `src/drop-app/src/lib/services/mock-sumsub.ts` — Demo mode mock implementation
- **BankID integration:** [AUTHENTICATION.md](../../backend/AUTHENTICATION.md) — BankID auto-approves KYC
- **Database schema:** [DATABASE-SCHEMA.md](../../backend/DATABASE-SCHEMA.md) — `screening_results`, `aml_alerts`, `users.kyc_status`
- **Compliance:** [COMPLIANCE.md](../../security/COMPLIANCE.md) — AML/KYC regulatory requirements
- **Security:** [SECURITY-ARCHITECTURE.md](../../security/SECURITY-ARCHITECTURE.md) — Role-based access, KYC status enforcement

# AISP Registration Serbia — NBS Response (Tok)

# AISP Registracija u Srbiji — NBS Odgovor

**Datum:** 2026-03-18
**Izvor:** Narodna Banka Srbije (platni.sistem@nbs.rs)
**Kontekst:** Odgovor na naš upit od 03.03.2026 u ime ALAI Holding AS (org.nr. 932 516 136)
**Namjena:** Osnivanje ALAI Tech d.o.o. u Srbiji za AISP uslugu na srpskom tržištu

---

## Ključni nalazi

### Zakonski okvir
- **Član 82a Zakona o platnim uslugama** (RS br. 139/2014, 44/2018, 64/2024) — posebna pravila za firme koje pružaju ISKLJUČIVO AISP uslugu
- Ako firma pruža i druge platne usluge, primjenjuje se član 82 (stroži zahtjevi)

### Postupak registracije
1. Osnovati d.o.o. u Srbiji (ALAI Tech d.o.o.)
2. Pripremiti dokumentaciju po članu 82 stav 1 (tačke 1, 2, 4, 5, 8, 10, 12, 15, 19, 20, 21, 23)
3. Zaključiti ugovor o osiguranju od odgovornosti sa društvom za osiguranje
4. Podnijeti zahtjev za registraciju Narodnoj banci Srbije

### Osiguranje (OBAVEZNO)
- Polisa mora pokrivati teritorije na kojima se pruža usluga
- Pokriva odgovornost za: neovlašćen pristup informacijama o platnom računu, pristup sa ciljem prevare, neovlašćeno korišćenje informacija
- Alternativa: drugo odgovarajuće sredstvo za pokriće odgovornosti

### Kapitalni zahtjev
- Za čisti AISP (samo informacijske usluge): **nema minimalnog kapitalnog zahtjeva** (za razliku od PISP)
- Ovo je značajna prednost za ulazak na tržište

## Zakonski link
- [Zakon o platnim uslugama (PDF)](https://www.nbs.rs/export/sites/NBS_site/documents/propisi/zakoni/zakon_o_platnim_uslugama_13082024.pdf)

## Napomena
Email od NBS je označen kao "UNUTRAŠNJA UPOTREBA" — puni tekst (9200+ chars) trunciran u našem sistemu. Potrebno dohvatiti kompletnu verziju za sve detalje o potrebnim dokumentima.

## Sljedeći koraci
1. Dohvatiti puni tekst emaila sa svim detaljima dokumentacije
2. Pripremiti listu potrebnih dokumenata po članu 82a
3. Istražiti opcije osiguranja od odgovornosti u Srbiji
4. Planirati osnivanje ALAI Tech d.o.o.