Low-Level Design Document
Middleware Lifecycle — Low-Level Design Document
Document:Project:LLD-MIDDLEWAREDrop Module/Component: Transactions Module (Remittance + QR Payment) Version: 1.0 Date: 2026-02-23 Author: Petter Graff, Senior Enterprise Architect Status: ApprovedLast updated:Reviewers:2026-02-21Alem Bašić (CEO), John (AI Director)Author:Related HLD:StandardsHLDArchitectApplies to:Drop API (Hono) —Documentsrc/drop-api/src/app.ts
OverviewDocument History
The Drop API uses Hono as its HTTP framework. Middleware is organized into two layers: global middleware applied to every request via app.use("*"), and per-route middleware applied within individual route handlers. This document describes the complete execution order.
Source of truth: src/drop-api/src/app.ts
Middleware Execution Order
flowchart TD
A["Incoming HTTP Request"] --> B["1. CORS Middleware\n(global)"]
B --> C["2. Request ID Middleware\n(global)"]
C --> D["3. Client IP Middleware\n(global)"]
D --> E["4. Route Matching\n(/v1/* or /api/*)"]
E --> F{Route found?}
F -->|No| G["404 Not Found"]
F -->|Yes| H["5. Per-Route Middleware\n(auth / rateLimit / featureGate)"]
H --> I["6. Route Handler"]
I --> J["Response"]
I -.->|Error thrown| K["Global Error Handler\n(app.onError)"]
K --> J
style A fill:#f5f5f5,stroke:#333
style B fill:#ffd93d,stroke:#333
style C fill:#ffd93d,stroke:#333
style D fill:#ffd93d,stroke:#333
style H fill:#6bcb77,stroke:#333
style I fill:#4d96ff,stroke:#333,color:#fff
style K fill:#ff6b6b,stroke:#333,color:#fff
Global Middleware (Applied to Every Request)
These are registered in app.ts with app.use("*") and execute in registration order, top-to-bottom.
1. CORS (hono/cors)
Source: app.ts:23-30
Configures Cross-Origin Resource Sharing headers for browser-based clients.
| Author | Changes | ||
|---|---|---|---|
|
Banking Architecture Team |
Initial draft from source code | |
|
The credentials: true setting is required because the web app sends JWT tokens in httpOnly cookies. Empty strings from unset env vars are filtered out.
2. Request ID
Source: app.ts:33-38
Generates or propagates a unique request identifier for distributed tracing.
| Petter |
| |
| |
|
Downstream middleware and route handlers access the request ID via c.get("requestId") for structured logging and audit trails.
3. Client IP
Source: app.ts:41-47
Extracts the originating client IP address from proxy headers.
| ||
| ||
|
The extracted IP is stored as c.get("clientIp") and used by rate limiting and audit logging.
Note: The rate-limit.ts module also exports a getClientIp(c) helper that performs the same extraction. Some route handlers use getClientIp(c) directly instead of c.get("clientIp").
4. Global Error Handler
Source: app.ts:50, middleware/error-handler.ts:16-23
Registered via app.onError(globalErrorHandler). This is not middleware in the traditional sense — it is an error boundary that catches any unhandled exceptions thrown during request processing.
| |
Filled with | Drop
The error handler never leaks stack traces or internal details to the client.
Route Mounting
Source: app.ts:53-72
All API routes are mounted under a versioned prefix:
| |
|
Both mount points serve the identical route handlers — /api is an alias for /v1.
Mounted Route Groups
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | data |
Per-Route1. MiddlewareModule Overview
Per-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
is(JWTappliedvalidation),withinOpenindividualBankingroutePISPfiles,APInot(actualglobally.paymentItexecution),executesaudit_log (compliance side-effect), notifications (user alerting)
afterKey Business Rules:
- Drop never deducts money from the
globalDropmiddlewareDBchain.balanceAuthenticationexcept 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
(middleware/auth.ts-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
ThreePurpose: variants,Records all followingfinancial operations. Append-only — status updates are the sameonly pattern:writes extractafter JWT, verify token + session, set c.set("user", ...).creation.
| ||
| | |
| |
Flow:
Extract bearer token fromAuthorizationheader or cookieVerify JWT signature (HS256) and check session insessionstableIf invalid or expired: return401 UnauthorizedIf role mismatch (merchant/admin variants): return403 ForbiddenSetc.set("user", authUser)for downstream handlers
Rate Limiting (middleware/rate-limit.ts)
Rate limiting is not a Hono middleware function — it is a utility called inline within route handlers.
// Example from transactions.ts
if (!(await rateLimit(ip, 10))) {
return c.json({ error: "rate_limited", message: "Too many requests" }, 429);
}
| Default | Description | ||||
|---|---|---|---|---|---|
|
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 ( |
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
user:{id}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 |
|
string |
— | Filter: remittance qr_payment |
status |
string |
— | Filter: processing, completed, failed |
RateSuccess limitResponse state is persisted in the— rate_limits200 OK database table (SQLite/PostgreSQL). Expired entries are cleaned up every 100 checks.
Per-endpoint limits::
| |||
| | ||
| |||
| | ||
| |||
| |||
| |||
| |||
| |||
|
Feature Gates (lib/feature-flags.ts)
Feature gates control access to unreleased functionality. Like rate limiting, they are called inline within route handlers, not as Hono middleware.
//{
Example from cards.ts
if (!isEnabled("virtualCards"))data": {
return"transactions": c.json([
{
error:"id": "not_found"tx_rem_abc123",
message:"type": "Featureremittance",
not"status": available""completed",
"amount": 2000,
"fee": 10,
"receiveAmount": 20340,
"receiveCurrency": "RSD",
"recipientName": "Marko Petrovic",
"createdAt": "2026-02-23T10:00:00.000Z"
}
],
404);"total": 15,
"page": 1,
"limit": 20
}
}
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| |
FlagsPurpose: areCalculate read0.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 environmentsend variablesamount, (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: FF_VIRTUAL_CARDS=true{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 fallbackUNIQUE toconstraint compiled→ defaults.return Theexisting featureGate()transaction
helper throws an HTTPException(404) for disabled features, which the global error handler catches.
Complete6. Request Lifecycle (Sequence Diagram)Diagrams
6.1 Remittance Initiation Flow
sequenceDiagram
participantautonumber
actor Client
participant CORS as CORS Middleware
participant ReqID as Request ID Middleware
participant IP as Client IP Middleware(Web/Mobile)
participant RouterRL as HonoRate RouterLimiter
participant Auth as Auth Middleware
participant RLRoute as Rate Limiter
participant FG as Feature Gate
participant Handler asTransactions Route Handler
participant DB as Database
participant ErrHPISP as ErrorOpen HandlerBanking PISP
Client->>CORS:RL: HTTPPOST Request
CORS->>CORS: Check origin, set CORS headers
CORS->>ReqID: next()
ReqID->>ReqID: Extract/generate x-request-id
ReqID->>IP: next()
IP->>IP: Extract client IP from headers
IP->>Router: next()
Router->>Router: Match route (/v1/*transactions/remittance
or /api/*)
alt Public route (health, rates)
Router->>Handler: Direct execution
else Authenticated route
Router->>Auth: authMiddleware / adminMiddleware / merchantMiddleware
Auth->>DB: Verify JWT + session
alt Token invalid
Auth-->>Client: 401 Unauthorized
else Token valid
Auth->>Auth: Set c.user
Auth-RL->>RL: Check rate limitrate_limits (inline)10/IP, 3/user per 60s)
alt Rate limit exceeded
RL-->>Client: 429 Too Many Requests
else Within limitend
RL->>FG:Auth: CheckForward featurerequest
flagAuth->>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 (if100-50000), applicable)bankAccountId
alt FeatureValidation disabledfails
FG-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 Featurerecipient_not_found
notend
available
else Feature enabled
FG->>Handler: Execute route logic
Handler-Route->>DB: Query/mutationSELECT Handler-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: JSON403 responseinsufficient_balance
end
endRoute->>DB: endBEGIN endTRANSACTION
NoteRoute->>DB: overUPDATE Handler,ErrH:bank_accounts IfSET anybalance error= isbalance thrown- Handler-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-->>ErrH:Route: Unhandled{paymentId, errortransactionStatus: ErrH->>ErrH:"RCVD", LogscaRedirect}
+ Sentry report
ErrH-Route-->>Client: 500201 Internal{transactionId, Serverstatus: Error"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}
Input7. ValidationState Diagrams
Input
stateDiagram-v2
validation[*] is--> notprocessing: middlewarePOST /v1/transactions/remittance (PISP initiated)
processing --> completed: PISP webhook — itpayment isconfirmed aby collectionbank
ofprocessing utility--> functionsfailed: 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 middleware/validation.tscalled— directlyno byPISP routewebhook handlers.for domestic transfers in mock mode).
State Transition Rules:
| 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 |
|---|---|---|---|
|
DB transaction atomic; PISP call is async from user perspective | ||
|
|
Index idx_transactions_user on user_id; pagination limits result set |
|
|
Two DB reads (recipient + exchange rate); no external API call | ||
|
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 |
|
None — request rejected with 429 | ||
|
None — module unavailable |
External Dependencies
| Dependency | Version | Purpose | Fallback if unavailable |
|---|---|---|---|
| PostgreSQL | 16 | Primary data store | SQLite (dev only) |
Open Banking PISP (Neonomics/ASPSP) |
None — return 502, payment cannot proceed | ||
Open Banking AISP |
|||
verification |
bank_accounts.balance | staleness ||
|
Cross-References12. 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:
SecurityArchitectureRemittance —Trustsuccessboundaries,pathSTRIDE,(mockapplication security controlsPISP)AuthenticationRemittance —JWT,KYCsessionnotmanagement,approvedBankID→OIDC403APIReferenceRemittance —Endpointamountspecifications<and100securityNOKrequirements→ 422LoginAuthentication FlowRemittance —BankIDamountOIDC>authentication50,000detailNOK → 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) |