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