Low-Level Design (LLD)
Low-Level Design Document
Project:
{{PROJECT_NAME}}Drop Module/Component:{{MODULE_NAME}}Transactions Module (Remittance + QR Payment) Version:{{VERSION}}1.0 Date:{{DATE}}2026-02-23 Author:{{AUTHOR}}Petter Graff, Senior Enterprise Architect Status:Draft | In Review |Approved Reviewers:{{REVIEWERS}}Alem Bašić (CEO), John (AI Director) Related HLD: HLD Document
Document History
| Version | Date | Author | Changes |
|---|---|---|---|
| 0.1 | Initial draft from source code | ||
| 1.0 | 2026-02-23 | Petter Graff | Filled with real Drop data |
1. Module Overview
Module:
Service/Repo: {{MODULE_NAME}}transactions — {{REPO_NAME}}drop-apisrc/drop-api/src/routes/transactions.ts
Team Owner: {{TEAM_NAME}}ALAI — Backend
Single Responsibility: {{ONE_SENTENCE_WHAT_THIS_MODULE_DOES}}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:
{{WHAT_THIS_MODULE_OWNS}}Transaction records (transactionstable), exchange rates (exchange_ratestable), fee calculation, pre-payment disclosure, idempotency enforcement, PISP payment initiation orchestration - Does NOT own:
{{WHAT_THIS_MODULE_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:
{{WHAT_IT_DELEGATES}}BankID auth middleware (JWT validation), Open Banking PISP API (actual payment execution), audit_log (compliance side-effect), notifications (user alerting)
Key Business Rules:
{{BUSINESS_RULE_1}}Drop never deducts money from the Drop DB balance except as a cached AISP value — all real deductions happen at the user's bank via PISP{{BUSINESS_RULE_2}}Every transaction requireskyc_status = 'approved'on the initiating user{{BUSINESS_RULE_3}}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 {{ServiceClassName}}TransactionsRoute {
-repository:authMiddleware: {{RepositoryInterface}}Middleware
-eventBus:rateLimiter: EventBusMiddleware
+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
-logger: Logger
+create(validateRemittanceInput(dto: Create{{Entity}}Dto): Promise~{{Entity}}~
+findById(id: string): Promise~{{Entity}} | null~
+findAll(filter: {{Filter}}Dto): Promise~PaginatedResult~{{Entity}}~~
+update(id: string, dto: Update{{Entity}}Dto): Promise~{{Entity}}~
+delete(id: string): Promise~void~
-validate(dto: unknown)RemittanceDto): void
-publishEvent(event:validateQRPaymentInput(dto: {{DomainEvent}})QRPaymentDto): Promise~void~void
}
class {{RepositoryInterface}}RemittanceDto {
<<interface>>+recipientId: string
+findById(id:amount: string): Promise~{{Entity}} | null~number
+findMany(filter:bankAccountId: {{Filter}}): Promise~{{Entity}}[]~string
+create(data:currency: Partial~{{Entity}}~): Promise~{{Entity}}~
+update(id: string, data: Partial~{{Entity}}~): Promise~{{Entity}}~
+delete(id: string): Promise~void~
+count(filter: {{Filter}}): Promise~number~string
}
class QRPaymentDto {{Entity}
+merchantId: string
+amount: number
+qrData: string
}
class Transaction {
+id: string
+createdAt:user_id: Datestring
+updatedAt:type: Date
+deletedAt: Date"remittance" | null
{{FIELD_1}}: {{TYPE_1}}
{{FIELD_2}}: {{TYPE_2}}"qr_payment"
+isDeleted():status: boolean"processing" | "completed" | "failed"
+toJSON():amount: {{EntityJSON}}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 Create{{Entity}}DtoDatabase {
{{FIELD_1}}<<abstraction>>
+query(sql, params): {{TYPE_1}}T[]
{{FIELD_2}}+getOne(sql, params): {{TYPE_2}}T
+run(sql, params): RunResult
+transaction(fn): void
}
class {{Controller}}PISPClient {
-service: {{ServiceClassName}}<<external>>
+POST /{{resource}}(body: Create{{Entity}}Dto)initiatePayment(paymentRequest): ResponsePaymentResponse
+GET /{{resource}}/:id()getPaymentStatus(paymentId): Response
+GET /{{resource}}(query: {{Filter}}Dto): Response
+PUT /{{resource}}/:id(body: Update{{Entity}}Dto): Response
+DELETE /{{resource}}/:id(): ResponsePaymentStatus
}
{{ServiceClassName}}TransactionsRoute --> {{RepositoryInterface}}RemittanceDto
{{ServiceClassName}}TransactionsRoute --> {{Entity}}QRPaymentDto
{{Controller}}TransactionsRoute --> {{ServiceClassName}}Transaction
{{RepositoryInterface}}TransactionsRoute ..--> {{Entity}}Database
TransactionsRoute --> PISPClient
3. Database Schema
3.1 Tables
{{TABLE_NAME_1}}transactions
Purpose: {{TABLE_PURPOSE}}Records all financial operations. Append-only — status updates are the only writes after creation.
| Column | Type | Nullable | Default | Constraints | Description |
|---|---|---|---|---|---|
id |
|
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 | |
||
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 |
|
NO | |
— | |
| | | |||
| | | |||
| | | |||
| | | |||
| | |
Indexes:
| Index Name | Columns | Type | Rationale |
|---|---|---|---|
|
id |
B-tree (PK) | Primary key lookup |
|
|
B-tree | |
|
|
||
idx_transactions_recipient |
recipient_id |
B-tree | Lookup by recipient |
idx_transactions_merchant |
merchant_id |
B-tree | Lookup by merchant |
{{TABLE_NAME_2}}exchange_rates
Purpose: {{TABLE_PURPOSE}}Stores current NOK-to-foreign currency exchange rates for the 6 supported remittance corridors.
| Column | Type | Nullable | Default | Constraints | Description |
|---|---|---|---|---|---|
id |
|
NO | auto |
PK | |
|
|
— | NOT NULL | Always 'NOK' at MVP | |
to_currency |
|
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)
CREATE-- TYPEtransaction {{ENUM_NAME}}type
ASCHECK(type ENUMIN (
'{{VALUE_1}}'remittance', '{{VALUE_2}}qr_payment'))
-- transaction status
CHECK(status IN ('processing', '{{VALUE_3}}completed', '
failed');)
3.3 Migration Notes
MigrationMigration:file:Included in{{MIGRATION_FILE_NAME}}.sqldb.tsinitializeDatabase()— runs on startup (SQLite) or via separate migration script (PostgreSQL)- Zero-downtime:
{{YES_NO}}YES —{{NOTES}}onlyINSERTandUPDATE statusneeded;CREATE INDEX CONCURRENTLYfor PostgreSQL - Backfill required:
{{YES_NO}}NO —{{BACKFILL_STRATEGY}}new tables - Estimated migration time:
{{ESTIMATE}}< 1 second (SQLite), < 5 seconds (PostgreSQL)
4. API Contract
Base Path: /api/v{{VERSION}}/{{RESOURCE}}v1/transactions
POST /{{resource}}v1/transactions/remittance
Summary: CreateInitiate international money transfer (PISP) from user's bank account to a newsaved {{entity}}recipient
Authentication: Bearer JWT required (authMiddleware)
Rate Limit: 10 req/60s per IP + 3 req/60s per user
Request Body:
{
"{{field1}}"recipientId": "{{type_and_example}}"rec_abc123def456gh78",
"{{field2}}amount": 2000,
"bankAccountId": "{{type_and_example}}ba_abc123def456gh78",
"currency": "NOK"
}
Success Response — 201 Created:
{
"data": {
"id": "550e8400-e29b-41d4-a716-446655440000"tx_rem_abc123def456gh78",
"{{field1}}"type": "{{value}}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": "2024-01-01T00:2026-02-23T10:00:00.000Z"
}
}
Error Responses:
| Status | Code | Description |
|---|---|---|
400 |
|
|
401 |
|
Missing or |
403 |
|
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 |
|
|
422 |
|
|
429 |
rate_limited |
Exceeded 10 req/60s per IP or 3 req/60s per user |
502 |
pisp_unavailable |
Open Banking PISP API unreachable |
500 |
|
Unexpected server error |
GETPOST /{{resource}}/:idv1/transactions/qr-payment
Summary: RetrieveInitiate aQR {{entity}}merchant bypayment ID(PISP) from user's bank account
PathAuthentication: Parameters:Bearer JWT required
Request Body:
| |
Success Response — :200201 OKCreated
{
"data": {
"id": "550e8400-e29b-41d4-a716-446655440000"tx_qr_abc123def456gh78",
"{{field1}}"type": "{{value}}qr_payment",
"status": "completed",
"amount": 450,
"fee": 4.5,
"merchantName": "Café Oslo AS",
"createdAt": "2024-01-01T00:2026-02-23T10:00:00.000Z"
}
}
Error Responses:
| Status | Code | Description |
|---|---|---|
|
|
Missing or invalid fields |
401 |
unauthorized |
Missing or expired JWT |
403 |
kyc_required |
User kyc_status not approved |
404 |
|
|
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 /{{resource}}v1/transactions
Summary: List {{entities}}authenticated user's transactions with pagination and filtering
Query Parameters:
| Parameter | Type | Default | Description |
|---|---|---|---|
page |
integer |
1 |
Page number (1-based) |
limit |
integer |
20 |
Items per page ( |
|
string |
— |
remittance qr_payment |
|
|
— | 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"
}
],
"pagination"total": {15,
"page": 1,
"limit": 20,
"total": 0,
"totalPages": 020
}
}
5. Algorithm Specifications
5.1 {{ALGORITHM_NAME}}Fee Calculation — Remittance
Purpose: {{WHAT_IT_DOES}}Calculate 0.5% fee on remittance, rounded to 2 decimal places
Complexity: Time O({{TIME_COMPLEXITY}})1) | Space O({{SPACE_COMPLEXITY}})1)
function {{algorithmName}}(input:calculateRemittanceFee(sendAmountNOK: {{InputType}})number): {{OutputType}}number
FEE_RATE = 0.005 // Step0.5%
1:fee Validate= inputsendAmountNOK if* inputFEE_RATE
isreturn nullMath.round(fee or* invalid100) then/ throw ValidationError("{{VALIDATION_MESSAGE}}")
end if100 // StepRound 2:to {{STEP_2_DESCRIPTION}}2 resultdecimal places
function calculateReceiveAmount(sendAmountNOK: number, exchangeRate: number): number
netSend = initialize()
for each item in input.items do
if item.satisfies({{CONDITION}}) then
result.add(transform(item))
end if
end forsendAmountNOK // StepFee 3:taken {{STEP_3_DESCRIPTION}}from sortedsend amount, not receive
receive = result.sortBy({{SORT_CRITERIA}})netSend * exchangeRate
return Math.round(receive) // StepRound 4:to Applywhole businessunits rulesof fortarget each rule in {{BUSINESS_RULES}} do
sorted = rule.apply(sorted)
end for
return sorted
end functioncurrency
Edge Cases:
EmptyMinimuminput:amount:Return100emptyNOKresult,→dofeenot=throw0.50 Single item:{{SINGLE_ITEM_BEHAVIOR}}NOK- Maximum
items:amount:Limit50,000 NOK → fee = 250 NOK - Rate not found: Return 404 — do not proceed to
{{MAX_ITEMS}}, log warning if exceededtransaction
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 CreateRemittance {{Entity}}Initiation Flow
sequenceDiagram
autonumber
actor Client as Client (Web/Mobile)
participant GWRL as APIRate GatewayLimiter
participant Auth as Auth Middleware
participant SVCRoute as {{ServiceClassName}}Transactions Route
participant DB as Database
participant MQPISP as MessageOpen QueueBanking participant Email as Email ServicePISP
Client->>GW:RL: POST /{{resource}}v1/transactions/remittance
{body}RL->>RL: GW-Check rate_limits (10/IP, 3/user per 60s)
alt Rate limit exceeded
RL-->>Client: 429 Too Many Requests
end
RL->>Auth: ValidateForward 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-->>GW:Route: Useruser context {userId, roles}role, GW-kycStatus}
Route->>SVC:Route: create(dto,Validate userContext)body: SVC->>SVC:recipientId, validate(dto)amount (100-50000), bankAccountId
alt Validation fails
SVC-Route-->>Client: 400 ValidationErrorvalidation_error
end
SVC-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
SVC-Route->>DB: UPDATE bank_accounts SET balance = balance - totalCostInOere WHERE balance >= ?
Route->>DB: INSERT INTO {{TABLE_NAME_1}}transactions DB--(status='processing', idempotency_key=?)
Route->>SVC:DB: {{entity}}INSERT recordINTO SVC-audit_log (action='transaction.create')
Route->>DB: INSERT INTO notifications (title='Overføring startet')
Route->>DB: COMMIT
SVC-Route->>MQ:PISP: publish("{{ENTITY_CREATED_EVENT}}",POST event)/v1/payments/cross-border-credit-transfers
MQ->>Email: Trigger confirmation email
Email-PISP-->>Client: Email delivered async
SVC-->>GW:Route: {{entity}}paymentId, dtotransactionStatus: GW-"RCVD", scaRedirect}
Route-->>Client: 201 Created{transactionId, status: "processing", scaRedirect}
6.2 {{SECONDARY_FLOW_NAME}}QR Payment Flow
sequenceDiagram
autonumber
actor Client as Client (Mobile)
participant SVCRoute as {{ServiceClassName}}Transactions participant Cache as RedisRoute
participant DB as Database
Client->>SVC:Route: GETPOST /v1/transactions/qr-payment {{resource}}/:idmerchantId, SVC-amount}
Route->>Cache:DB: GETVerify {{resource}}:{{id}}JWT altsession
Cache hit
Cache-->>SVC: Cached {{entity}}
SVC-->>Client: 200 OK (from cache)
else Cache miss
Cache-->>SVC: null
SVC-Route->>DB: SELECT * FROM {{TABLE_NAME_1}}merchants WHERE id = $1? AND status = 'active'
alt NotMerchant not found
DB-->>SVC: null
SVC-Route-->>Client: 404 Not Foundmerchant_not_found
end
DB--Route->>SVC:DB: {{entity}}SELECT record* SVC-FROM bank_accounts WHERE user_id = ? AND is_primary = 1
Route->>Cache:Route: Calculate fee = amount * merchant.fee_rate
Route->>Route: Calculate total = amount + fee
Route->>DB: BEGIN TRANSACTION
Route->>DB: UPDATE bank_accounts SET {{resource}}:{{id}}balance TTL={{CACHE_TTL}}= SVC-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: 200201 OK{transactionId, endstatus: "completed", merchantName}
7. State Diagrams
stateDiagram-v2
[*] --> {{STATE_DRAFT}}processing: :POST Created/v1/transactions/remittance {{STATE_DRAFT}}(PISP initiated)
processing --> {{STATE_PENDING}}completed: :PISP submit()webhook {{STATE_DRAFT}}— 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 --> [*]
: delete()
{{STATE_PENDING}} --> {{STATE_ACTIVE}} : approve()
{{STATE_PENDING}} --> {{STATE_REJECTED}} : reject(reason)
{{STATE_PENDING}} --> {{STATE_DRAFT}} : recall()
{{STATE_ACTIVE}} --> {{STATE_SUSPENDED}} : suspend(reason)
{{STATE_ACTIVE}} --> {{STATE_COMPLETED}} : complete()
{{STATE_ACTIVE}} --> {{STATE_CANCELLED}} : cancel(reason)
{{STATE_SUSPENDED}} --> {{STATE_ACTIVE}} : reactivate()
{{STATE_SUSPENDED}} --> {{STATE_CANCELLED}} : cancel(reason)
{{STATE_REJECTED}} --> {{STATE_DRAFT}} : revise()
{{STATE_COMPLETED}} --> [*]
{{STATE_CANCELLED}}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 |
|---|---|---|---|---|
POST /v1/transactions/remittance |
||||
PISP webhook or QR sync completion |
|
|||
PISP webhook rejection or 5-min timeout |
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 |
| 403 | No | |||
| 403 | No | INFO | No | |
| RecipientNotFound | 404 | No | INFO | No |
| 409 | No | INFO | No | |
| AmountOutOfRange | 422 | No | INFO | No |
| RateLimited | 429 | After Retry-After | WARN | No |
| 502 | Yes ( |
ERROR | Yes (if |
|
| DatabaseError | 500 | Yes (1x) | ERROR | Yes |
| UnexpectedError | 500 | No | ERROR | Yes |
8.2 Error Response Format (RFC 7807)
{
"type"error": "https://api.{{DOMAIN}}/errors/{{ERROR_CODE}}kyc_required",
"message": "Du må fullføre identitetsverifisering før du kan sende penger.",
"title"details": "{{Human-readable error title}}",
"status": 400,
"detail": "{{Specific error message}}",
"instance": "/{{resource}}/{{id}}",
"traceId": "{{TRACE_ID}}"[]
}
8.3 Retry & Fallback Strategy
ExternalPISP API call failure:
→ Retry with exponential backoff: [1s, 2s, 4s]
→ Max retries: 3
→ Circuit breaker: Open after 53 failures in 60s window → 60s cooldown
→ Fallback: {{FALLBACK_BEHAVIOR}}Return 502 to client — payment cannot proceed without PISP
→ Alert: PagerDutySentry 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 |
|---|---|---|
Unique |
||
| Two |
DB transaction with |
|
10. Performance Considerations
| Operation | Target (p99) | Current Baseline | Optimization |
|---|---|---|---|
|
< |
||
GET / |
< |
idx_transactions_user user_id; pagination limits result set |
|
POST / |
< |
||
POST |
< |
Known bottlenecks:
{{BOTTLENECK_1}}:PISP{{MITIGATION}}API latency: 200-2000ms external call — mitigated by async SCA redirect pattern{{BOTTLENECK_2}}:Exchange{{MITIGATION}}rate reads: High frequency but 6 rows only — fully cached in PostgreSQL buffer pool
11. Dependencies
Internal Dependencies
| Dependency | Type | Purpose | Fallback if unavailable |
|---|---|---|---|
|
Synchronous | ||
|
|||
lib/db.ts |
Required | All data access ( |
None — module unavailable |
External Dependencies
| Dependency | Version | Purpose | Fallback if unavailable |
|---|---|---|---|
| PostgreSQL | 16 |
Primary data store | |
Berlin Group v1.3.12+ |
|||
Open Banking AISP |
Berlin Group v1.3.12+ |
bank_accounts.balance with staleness warning |
12. Configuration Parameters
| Variable | Type | Default | Required | Description |
|---|---|---|---|---|
|
|
— | No (SQLite default) | PostgreSQL connection string |
NEXT_PUBLIC_SERVICE_MODE |
|
mock |
No | mock production = real PISP calls |
| | | ||
| | | ||
|
string |
— | Yes (prod) | |
|
string |
— | Yes (prod) | |
OPEN_BANKING_CLIENT_SECRET |
string |
— | Yes (prod) | eIDAS client secret |
13. Testing Approach
| Test Type | Tool | Coverage Target | Location |
|---|---|---|---|
| Unit tests | > |
src/ |
|
| Integration tests | Supertest |
Key payment flows | src/ |
|
Key test scenarios:
-
CreateRemittance{{entity}}— 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
-
CreateQR{{entity}}payment —validationmerchanterrorinactive(missing→required field)404 -
Create {{entity}}Disclosure —conflictcalculates(duplicatefee,{{UNIQUE_FIELD}})exchange rate, receive amount correctly -
Get {{entity}} by ID — found Get {{entity}} by ID — not found (404)List {{entities}} — with filters, pagination, sortingUpdate {{entity}} — successUpdate {{entity}} — not foundDelete {{entity}} — success (soft delete)State transition — valid transitionState transition — invalid transition (guard fails)External service failure —PISP circuit breakertriggers— 3 failures → open → 502 (integration test, Phase 2)- Concurrent
creationpayment —duplicatebalanceconstraintrace 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) |