Low-Level Design Document Low-Level Design Document Project: Drop Module/Component: Transactions Module (Remittance + QR Payment) Version: 1.0 Date: 2026-02-23 Author: Petter Graff, Senior Enterprise Architect Status: Approved Reviewers: Alem Bašić (CEO), John (AI Director) Related HLD: HLD Document Document History Version Date Author Changes 0.1 2026-02-21 Banking Architecture Team Initial draft from source code 1.0 2026-02-23 Petter Graff Filled with real Drop data 1. Module Overview Module: transactions Service/Repo: drop-api — src/drop-api/src/routes/transactions.ts Team Owner: ALAI — Backend Single Responsibility: Processes all financial operations — remittance (international money transfer via PISP) and QR payments (domestic merchant payments via PISP) — using the PSD2 pass-through model where Drop never holds funds. Boundaries: Owns: Transaction records ( transactions table), exchange rates ( exchange_rates table), fee calculation, pre-payment disclosure, idempotency enforcement, PISP payment initiation orchestration Does NOT own: User authentication (auth module), recipient management ( recipients table owned by recipients route), bank account balance display (bank_accounts route / AISP), merchant registration (merchants route) Delegates to: BankID auth middleware (JWT validation), Open Banking PISP API (actual payment execution), audit_log (compliance side-effect), notifications (user alerting) Key Business Rules: Drop never deducts money from the Drop DB balance except as a cached AISP value — all real deductions happen at the user's bank via PISP Every transaction requires kyc_status = 'approved' on the initiating user Remittance amounts: 100 NOK minimum, 50,000 NOK maximum; fee = 0.5% of send amount QR payments: fee = merchant fee_rate (default 1%); validated via HMAC QR code idempotency_key (unique index on transactions ) prevents double-charging on retry 2. Class / Module Diagram classDiagram class TransactionsRoute { -authMiddleware: Middleware -rateLimiter: Middleware +POST /v1/transactions/remittance(body: RemittanceDto): Response +POST /v1/transactions/qr-payment(body: QRPaymentDto): Response +POST /v1/transactions/disclosure(body: DisclosureDto): Response +GET /v1/transactions(query: TransactionFilter): Response +GET /v1/transactions/:id(): Response -validateRemittanceInput(dto: RemittanceDto): void -validateQRPaymentInput(dto: QRPaymentDto): void } class RemittanceDto { +recipientId: string +amount: number +bankAccountId: string +currency: string } class QRPaymentDto { +merchantId: string +amount: number +qrData: string } class Transaction { +id: string +user_id: string +type: "remittance" | "qr_payment" +status: "processing" | "completed" | "failed" +amount: number +currency: string +fee: number +recipient_id: string +merchant_id: string +exchange_rate: number +send_amount: number +receive_amount: number +receive_currency: string +idempotency_key: string +created_at: string } class Database { <> +query(sql, params): T[] +getOne(sql, params): T +run(sql, params): RunResult +transaction(fn): void } class PISPClient { <> +initiatePayment(paymentRequest): PaymentResponse +getPaymentStatus(paymentId): PaymentStatus } TransactionsRoute --> RemittanceDto TransactionsRoute --> QRPaymentDto TransactionsRoute --> Transaction TransactionsRoute --> Database TransactionsRoute --> PISPClient 3. Database Schema 3.1 Tables transactions Purpose: Records all financial operations. Append-only — status updates are the only writes after creation. Column Type Nullable Default Constraints Description id TEXT NO — PK, format: tx_ Transaction identifier user_id TEXT NO — FK → users(id) Initiating user type TEXT NO — CHECK('remittance','qr_payment') Transaction type status TEXT NO 'processing' CHECK('processing','completed','failed') Payment status amount REAL NO — NOT NULL Send amount in NOK (stored in øre equivalent) currency TEXT YES 'NOK' — Source currency (always NOK at MVP) fee REAL YES 0 — Fee in NOK (0.5% remittance, merchant rate for QR) recipient_id TEXT YES NULL FK → recipients(id) For remittances; NULL for QR merchant_id TEXT YES NULL FK → merchants(id) For QR payments; NULL for remittance send_amount REAL YES NULL — Amount sent in source currency receive_amount REAL YES NULL — Amount received in destination currency receive_currency TEXT YES NULL — Destination currency (e.g., RSD, EUR) exchange_rate REAL YES NULL — Exchange rate at time of transaction description TEXT YES NULL — Optional user-provided description idempotency_key TEXT YES NULL UNIQUE Prevents duplicate payments on retry created_at TEXT NO datetime('now') — Transaction timestamp Indexes: Index Name Columns Type Rationale transactions_pkey id B-tree (PK) Primary key lookup idx_transactions_user user_id B-tree Filter all transactions per user (high frequency) idx_tx_idempotency idempotency_key Unique B-tree Prevent duplicate payment on API retry idx_transactions_recipient recipient_id B-tree Lookup by recipient idx_transactions_merchant merchant_id B-tree Lookup by merchant exchange_rates Purpose: Stores current NOK-to-foreign currency exchange rates for the 6 supported remittance corridors. Column Type Nullable Default Constraints Description id INTEGER NO auto PK Surrogate key from_currency TEXT NO — NOT NULL Always 'NOK' at MVP to_currency TEXT NO — NOT NULL Target currency (RSD, BAM, PLN, PKR, TRY, EUR) rate REAL NO — NOT NULL Exchange rate: 1 NOK = N target currency units updated_at TEXT YES — — Last rate update timestamp Indexes: Index Name Columns Type Rationale idx_rates_currency from_currency, to_currency Composite B-tree Fast rate lookup by currency pair 3.2 Enums (CHECK constraints in SQLite, native ENUMs in PostgreSQL migration) -- transaction type CHECK(type IN ('remittance', 'qr_payment')) -- transaction status CHECK(status IN ('processing', 'completed', 'failed')) 3.3 Migration Notes Migration: Included in db.ts initializeDatabase() — runs on startup (SQLite) or via separate migration script (PostgreSQL) Zero-downtime: YES — only INSERT and UPDATE status needed; CREATE INDEX CONCURRENTLY for PostgreSQL Backfill required: NO — new tables Estimated migration time: < 1 second (SQLite), < 5 seconds (PostgreSQL) 4. API Contract Base Path: /v1/transactions POST /v1/transactions/remittance Summary: Initiate international money transfer (PISP) from user's bank account to a saved recipient Authentication: Bearer JWT required ( authMiddleware ) Rate Limit: 10 req/60s per IP + 3 req/60s per user Request Body: { "recipientId": "rec_abc123def456gh78", "amount": 2000, "bankAccountId": "ba_abc123def456gh78", "currency": "NOK" } Success Response — 201 Created : { "data": { "id": "tx_rem_abc123def456gh78", "type": "remittance", "status": "processing", "amount": 2000, "fee": 10, "receiveAmount": 20340, "receiveCurrency": "RSD", "exchangeRate": 10.17, "estimatedDelivery": "2-4 business days", "scaRedirect": "https://dnb.no/sca/pay/abc123", "createdAt": "2026-02-23T10:00:00.000Z" } } Error Responses: Status Code Description 400 validation_error Missing or invalid fields (amount, recipientId) 401 unauthorized Missing or expired JWT 403 kyc_required User kyc_status is not approved 403 insufficient_balance Cached AISP balance < amount + fee 404 recipient_not_found recipientId does not belong to this user 409 duplicate_transaction Idempotency key collision — returns existing transaction 422 amount_out_of_range Amount < 100 NOK or > 50,000 NOK 429 rate_limited Exceeded 10 req/60s per IP or 3 req/60s per user 502 pisp_unavailable Open Banking PISP API unreachable 500 internal_error Unexpected server error POST /v1/transactions/qr-payment Summary: Initiate QR merchant payment (PISP) from user's bank account Authentication: Bearer JWT required Request Body: { "merchantId": "mer_abc123def456gh78", "amount": 450 } Success Response — 201 Created : { "data": { "id": "tx_qr_abc123def456gh78", "type": "qr_payment", "status": "completed", "amount": 450, "fee": 4.5, "merchantName": "Café Oslo AS", "createdAt": "2026-02-23T10:00:00.000Z" } } Error Responses: Status Code Description 400 validation_error Missing or invalid fields 401 unauthorized Missing or expired JWT 403 kyc_required User kyc_status not approved 404 merchant_not_found merchantId not found or inactive 500 internal_error Unexpected error POST /v1/transactions/disclosure Summary: Pre-payment disclosure — returns fee, exchange rate, receive amount (PSD2 Art. 45/46 compliance) Authentication: Bearer JWT required Request Body: { "type": "remittance", "amount": 2000, "recipientId": "rec_abc123def456gh78" } Success Response — 200 OK : { "data": { "sendAmount": 2000, "sendCurrency": "NOK", "fee": 10, "feePercentage": 0.5, "exchangeRate": 10.17, "receiveAmount": 20340, "receiveCurrency": "RSD", "totalCost": 2010, "estimatedDelivery": "2-4 business days" } } GET /v1/transactions Summary: List authenticated user's transactions with pagination Query Parameters: Parameter Type Default Description page integer 1 Page number (1-based) limit integer 20 Items per page (max 50) type string — Filter: remittance or qr_payment status string — Filter: processing , completed , failed Success Response — 200 OK : { "data": { "transactions": [ { "id": "tx_rem_abc123", "type": "remittance", "status": "completed", "amount": 2000, "fee": 10, "receiveAmount": 20340, "receiveCurrency": "RSD", "recipientName": "Marko Petrovic", "createdAt": "2026-02-23T10:00:00.000Z" } ], "total": 15, "page": 1, "limit": 20 } } 5. Algorithm Specifications 5.1 Fee Calculation — Remittance Purpose: Calculate 0.5% fee on remittance, rounded to 2 decimal places Complexity: Time O(1) | Space O(1) function calculateRemittanceFee(sendAmountNOK: number): number FEE_RATE = 0.005 // 0.5% fee = sendAmountNOK * FEE_RATE return Math.round(fee * 100) / 100 // Round to 2 decimal places function calculateReceiveAmount(sendAmountNOK: number, exchangeRate: number): number netSend = sendAmountNOK // Fee taken from send amount, not receive receive = netSend * exchangeRate return Math.round(receive) // Round to whole units of target currency Edge Cases: Minimum amount: 100 NOK → fee = 0.50 NOK Maximum amount: 50,000 NOK → fee = 250 NOK Rate not found: Return 404 — do not proceed to transaction 5.2 Idempotency Key Generation Purpose: Prevent double-charging on network retry or duplicate form submission Format: {userId}:{amount}:{recipientId}:{minuteTimestamp} function generateIdempotencyKey(userId, amount, recipientId): string minuteTimestamp = Math.floor(Date.now() / 60000) // Changes every 60s key = `${userId}:${amount}:${recipientId}:${minuteTimestamp}` return key // Unique index on transactions.idempotency_key prevents duplicate // If INSERT fails with UNIQUE constraint → return existing transaction 6. Sequence Diagrams 6.1 Remittance Initiation Flow sequenceDiagram autonumber actor Client as Client (Web/Mobile) participant RL as Rate Limiter participant Auth as Auth Middleware participant Route as Transactions Route participant DB as Database participant PISP as Open Banking PISP Client->>RL: POST /v1/transactions/remittance RL->>RL: Check rate_limits (10/IP, 3/user per 60s) alt Rate limit exceeded RL-->>Client: 429 Too Many Requests end RL->>Auth: Forward request Auth->>Auth: Extract JWT from Bearer header / cookie Auth->>DB: SELECT session WHERE token_hash = ? AND revoked = 0 Auth->>DB: SELECT user WHERE id = ? AND deleted_at IS NULL Auth-->>Route: user context {userId, role, kycStatus} Route->>Route: Validate body: recipientId, amount (100-50000), bankAccountId alt Validation fails Route-->>Client: 400 validation_error end alt kyc_status != 'approved' Route-->>Client: 403 kyc_required end Route->>DB: SELECT * FROM recipients WHERE id = ? AND user_id = ? alt Recipient not found Route-->>Client: 404 recipient_not_found end Route->>DB: SELECT rate FROM exchange_rates WHERE to_currency = ? Route->>DB: SELECT * FROM bank_accounts WHERE id = ? AND user_id = ? AND is_primary = 1 Route->>Route: Calculate fee (0.5%), total cost, receive amount alt balance < totalCost Route-->>Client: 403 insufficient_balance end Route->>DB: BEGIN TRANSACTION Route->>DB: UPDATE bank_accounts SET balance = balance - totalCostInOere WHERE balance >= ? Route->>DB: INSERT INTO transactions (status='processing', idempotency_key=?) Route->>DB: INSERT INTO audit_log (action='transaction.create') Route->>DB: INSERT INTO notifications (title='Overføring startet') Route->>DB: COMMIT Route->>PISP: POST /v1/payments/cross-border-credit-transfers PISP-->>Route: {paymentId, transactionStatus: "RCVD", scaRedirect} Route-->>Client: 201 {transactionId, status: "processing", scaRedirect} 6.2 QR Payment Flow sequenceDiagram autonumber actor Client as Client (Mobile) participant Route as Transactions Route participant DB as Database Client->>Route: POST /v1/transactions/qr-payment {merchantId, amount} Route->>DB: Verify JWT session Route->>DB: SELECT * FROM merchants WHERE id = ? AND status = 'active' alt Merchant not found Route-->>Client: 404 merchant_not_found end Route->>DB: SELECT * FROM bank_accounts WHERE user_id = ? AND is_primary = 1 Route->>Route: Calculate fee = amount * merchant.fee_rate Route->>Route: Calculate total = amount + fee Route->>DB: BEGIN TRANSACTION Route->>DB: UPDATE bank_accounts SET balance = balance - total Route->>DB: INSERT INTO transactions (type='qr_payment', status='completed') Route->>DB: INSERT INTO audit_log (action='qr_payment.create') Route->>DB: INSERT INTO notifications (title='Betaling registrert') Route->>DB: COMMIT Route-->>Client: 201 {transactionId, status: "completed", merchantName} 7. State Diagrams stateDiagram-v2 [*] --> processing: POST /v1/transactions/remittance (PISP initiated) processing --> completed: PISP webhook — payment confirmed by bank processing --> failed: PISP webhook — payment rejected (insufficient funds, SCA timeout, SCA cancelled) processing --> failed: 5-minute SCA timeout — no callback received completed --> [*] failed --> [*] Note: QR payments go directly from creation to completed (synchronous in MVP — no PISP webhook for domestic transfers in mock mode). State Transition Rules: From To Trigger Guard Condition Side Effect (none) processing POST /v1/transactions/remittance KYC approved, balance sufficient Deduct cached balance, create audit log, send notification processing completed PISP webhook or QR sync completion paymentId matches transaction Update status, send completion notification processing failed PISP webhook rejection or 5-min timeout paymentId matches, status RJCT Restore cached balance (re-sync AISP), send failure notification 8. Error Handling Strategy 8.1 Error Classification Error Type HTTP Status Retry? Log Level Alert? ValidationError 400 No INFO No UnauthorizedError 401 No WARN No KYCRequired 403 No INFO No InsufficientBalance 403 No INFO No RecipientNotFound 404 No INFO No DuplicateTransaction 409 No INFO No AmountOutOfRange 422 No INFO No RateLimited 429 After Retry-After WARN No PISPUnavailable 502 Yes (3x backoff) ERROR Yes (if sustained > 5 min) DatabaseError 500 Yes (1x) ERROR Yes UnexpectedError 500 No ERROR Yes 8.2 Error Response Format { "error": "kyc_required", "message": "Du må fullføre identitetsverifisering før du kan sende penger.", "details": [] } 8.3 Retry & Fallback Strategy PISP API call failure: → Retry with exponential backoff: [1s, 2s, 4s] → Max retries: 3 → Circuit breaker: Open after 3 failures in 60s window → 60s cooldown → Fallback: Return 502 to client — payment cannot proceed without PISP → Alert: Sentry alert if circuit remains open > 5 minutes → Idempotency: PISP call uses X-Request-ID = idempotency_key to prevent double-payment on retry 9. Concurrency & Thread Safety Concern Scenario Mitigation Double payment Client retries POST /v1/transactions/remittance after network timeout Unique index on idempotency_key — second INSERT fails with UNIQUE constraint → return existing transaction Balance race condition Two simultaneous payments from same account DB transaction with UPDATE bank_accounts SET balance = balance - X WHERE balance >= X — atomic check-and-deduct Exchange rate staleness Rate changes between disclosure and payment Rate locked at payment initiation time; user sees pre-payment disclosure; rate used is from DB at payment time 10. Performance Considerations Operation Target (p99) Current Baseline Optimization POST /v1/transactions/remittance < 500ms (local) + PISP latency ~50ms DB operations DB transaction atomic; PISP call is async from user perspective GET /v1/transactions < 100ms ~20ms Index idx_transactions_user on user_id ; pagination limits result set POST /v1/transactions/disclosure < 50ms ~10ms Two DB reads (recipient + exchange rate); no external API call POST /v1/transactions/qr-payment < 200ms ~40ms Synchronous completion in mock mode; PISP async in production Known bottlenecks: PISP API latency: 200-2000ms external call — mitigated by async SCA redirect pattern Exchange rate reads: High frequency but 6 rows only — fully cached in PostgreSQL buffer pool 11. Dependencies Internal Dependencies Dependency Type Purpose Fallback if unavailable middleware/auth.ts Synchronous JWT validation + user context None — request rejected with 401 middleware/rate-limit.ts Synchronous IP + user rate limiting None — request rejected with 429 lib/db.ts Required All data access (query, run, transaction) None — module unavailable External Dependencies Dependency Version Purpose Fallback if unavailable PostgreSQL 16 Primary data store SQLite (dev only) Open Banking PISP (Neonomics/ASPSP) Berlin Group v1.3.12+ Payment initiation None — return 502, payment cannot proceed Open Banking AISP Berlin Group v1.3.12+ Pre-payment balance verification Use cached bank_accounts.balance with staleness warning 12. Configuration Parameters Variable Type Default Required Description DATABASE_URL string — No (SQLite default) PostgreSQL connection string NEXT_PUBLIC_SERVICE_MODE string mock No mock = simulate PISP; production = real PISP calls OPEN_BANKING_API_URL string — Yes (prod) Neonomics or ASPSP base URL OPEN_BANKING_CLIENT_ID string — Yes (prod) eIDAS client identifier OPEN_BANKING_CLIENT_SECRET string — Yes (prod) eIDAS client secret 13. Testing Approach Test Type Tool Coverage Target Location Unit tests Vitest > 80% business logic src/drop-api/src/__tests__/unit/transactions/ Integration tests Supertest Key payment flows src/drop-api/src/__tests__/integration/transactions/ Key test scenarios: Remittance — success path (mock PISP) Remittance — KYC not approved → 403 Remittance — amount < 100 NOK → 422 Remittance — amount > 50,000 NOK → 422 Remittance — recipient not found → 404 Remittance — duplicate request (idempotency key) → 409 with existing transaction QR payment — success path QR payment — merchant inactive → 404 Disclosure — calculates fee, exchange rate, receive amount correctly PISP circuit breaker — 3 failures → open → 502 (integration test, Phase 2) Concurrent payment — balance race condition handled correctly (Phase 2) Approval Role Name Date Signature Author Petter Graff 2026-02-23 Module Owner John (AI Director) Security Review Tech Lead John (AI Director)