Skip to main content

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: Approved Last updated:Reviewers: 2026-02-21Alem Bašić (CEO), John (AI Director) Author:Related HLD: StandardsHLD Architect Applies to: Drop API (Hono) — src/drop-api/src/app.tsDocument


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.

http://localhost:3001,
SettingVersion ValueDateAuthorChanges
Allowed origins0.1 http://localhost:3000,2026-02-21 Banking process.env.APP_URLArchitecture TeamInitial draft from source code
Credentials1.0 true (cookies sent cross-origin)

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
BehaviorDetail
Header checked2026-02-23 x-request-id
FallbackGraff crypto.randomUUID()
Context variablec.get("requestId")
Response headerx-request-id (echoed back)

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.

PriorityHeaderProcessing
1stx-real-ipTrimmed
2ndx-forwarded-forFirst entry, trimmed
Fallback127.0.0.1

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.

Drop
Error TypeResponse
HTTPException (Hono)Returns the exception's status and message
All other errorsLogs via logger.error, reports to Sentry via captureError, returns 500 Internal Server ErrorFilled with genericreal message

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:

Mount PointPurpose
/v1/*Primary API path (mobile + new clients)
/api/*Backward compatibility during migration

Both mount points serve the identical route handlers — /api is an alias for /v1.

Mounted Route Groups

PathRoute ModulePrimary Middleware
/v1/authauthRoutesRate limiting (inline)
/v1/healthhealthRoutesNone (public)
/v1/transactionstransactionRoutesauthMiddleware + rate limiting
/v1/recipientsrecipientRoutesauthMiddleware
/v1/ratesrateRoutesNone (public)
/v1/cardscardRoutesauthMiddleware + feature gate
/v1/merchantsmerchantRoutesmerchantMiddleware
/v1/settingssettingsRoutesauthMiddleware
/v1/notificationsnotificationRoutesauthMiddleware
/v1/useruserRoutesauthMiddleware
/v1/adminadminRoutesadminMiddleware + rate limiting
/v1/consentsconsentRoutesauthMiddleware
/v1/complaintscomplaintRoutesauthMiddleware
/v1/croncronRoutesVaries
/v1/withdrawalwithdrawalRoutesauthMiddlewaredata

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 (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 is(JWT appliedvalidation), withinOpen individualBanking routePISP files,API not(actual globally.payment Itexecution), executesaudit_log (compliance side-effect), notifications (user alerting)

afterKey Business Rules:

  1. Drop never deducts money from the globalDrop middlewareDB chain.

    balance

    Authenticationexcept as a cached AISP value — all real deductions happen at the user's bank via PISP

  2. Every transaction requires kyc_status = 'approved' on the initiating user
  3. Remittance amounts: 100 NOK minimum, 50,000 NOK maximum; fee = 0.5% of send amount
  4. QR payments: fee = merchant fee_rate (default 1%); validated via HMAC QR code
  5. idempotency_key (unique index on transactions) 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.

MiddlewareColumn Role CheckType Used ByNullable
authMiddlewareAny authenticated userMost routes (transactions, recipients, settings, etc.)
merchantMiddlewarerole === 'merchant'Merchant routes
adminMiddlewarerole === 'admin'Admin routes (audit, screening, STR)

Flow:

  1. Extract bearer token from Authorization header or cookie
  2. Verify JWT signature (HS256) and check session in sessions table
  3. If invalid or expired: return 401 Unauthorized
  4. If role mismatch (merchant/admin variants): return 403 Forbidden
  5. Set c.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);
}
limit
Default ParameterConstraints Description
ipid RateTEXT NOPK, format: tx_<hex16>Transaction identifier
user_idTEXTNOFK → users(id)Initiating user
typeTEXTNOCHECK('remittance','qr_payment')Transaction type
statusTEXTNO'processing'CHECK('processing','completed','failed')Payment status
amountREALNONOT NULLSend amount in NOK (stored in øre equivalent)
currencyTEXTYES'NOK'Source currency (always NOK at MVP)
feeREALYES0Fee in NOK (0.5% remittance, merchant rate for QR)
recipient_idTEXTYESNULLFK → recipients(id)For remittances; NULL for QR
merchant_idTEXTYESNULLFK → merchants(id)For QR payments; NULL for remittance
send_amountREALYESNULLAmount sent in source currency
receive_amountREALYESNULLAmount received in destination currency
receive_currencyTEXTYESNULLDestination currency (e.g., RSD, EUR)
exchange_rateREALYESNULLExchange rate at time of transaction
descriptionTEXTYESNULLOptional user-provided description
idempotency_keyTEXTYESNULLUNIQUEPrevents duplicate payments on retry
created_atTEXTNOdatetime('now')Transaction timestamp

Indexes:

IP,sometimes
Index NameColumnsTypeRationale
transactions_pkeyidB-tree (PK)Primary key lookup
idx_transactions_useruser_idB-treeFilter all transactions per user (usuallyhigh clientfrequency)
idx_tx_idempotencyidempotency_keyUnique B-treePrevent duplicate payment on API retry
idx_transactions_recipientrecipient_idB-treeLookup by recipient
idx_transactions_merchantmerchant_idB-treeLookup by merchant

exchange_rates

Purpose: Stores current NOK-to-foreign currency exchange rates for the 6 supported remittance corridors.

ColumnTypeNullableDefaultConstraintsDescription
idINTEGERNOautoPKSurrogate key
from_currencyTEXTNONOT NULLAlways 'NOK' at MVP
to_currencyTEXTNONOT NULLTarget currency (RSD, BAM, PLN, PKR, TRY, EUR)
rateREALNONOT NULLExchange rate: 1 NOK = N target currency units
updated_atTEXTYESLast rate update timestamp

Indexes:

Index NameColumnsTypeRationale
idx_rates_currencyfrom_currency, to_currencyComposite B-treeFast 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.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:

StatusCodeDescription
400validation_errorMissing or invalid fields (amount, recipientId)
401unauthorizedMissing or expired JWT
403kyc_requiredUser kyc_status is not approved
403insufficient_balanceCached AISP balance < amount + fee
404recipient_not_foundrecipientId does not belong to this user
409duplicate_transactionIdempotency key collision — returns existing transaction
422amount_out_of_rangeAmount < 100 NOK or > 50,000 NOK
429rate_limitedExceeded 10 req/60s per IP or 3 req/60s per user
502pisp_unavailableOpen Banking PISP API unreachable
500internal_errorUnexpected 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:

StatusCodeDescription
400validation_errorMissing or invalid fields
401unauthorizedMissing or expired JWT
403kyc_requiredUser kyc_status not approved
404merchant_not_foundmerchantId not found or inactive
500internal_errorUnexpected 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:

requests durationin=1minute)
ParameterTypeDefaultDescription
pageinteger1Page number (1-based)
limit Maximuminteger 20Items per windowpage (max 50)
windowMstype Windowstring Filter: msremittance (default:or 60000qr_payment
statusstringFilter: 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::

EndpointKeyLimitWindow
POST /transactions/remittanceIP10/min60s
POST /transactions/remittanceuser:{id}3/min60s
POST /transactions/qr-paymentIP10/min60s
POST /transactions/qr-paymentuser:{id}3/min60s
GET /admin/auditIP30/min60s
GET /admin/screeningIP30/min60s
POST /admin/screeningIP10/min60s
GET /admin/strIP30/min60s
POST /admin/strIP10/min60s
PATCH /admin/strIP10/min60s

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
  }
}

5.

AlgorithmSpecifications

5.1

FeeCalculation
Flag DefaultControls
virtualCardsfalseCard creation, listing, detail, cancellation
physicalCardsfalsePhysical card ordering
cardDetailsfalseCard detail endpoint
cardFreezefalseCard freeze/unfreeze
cardPinfalseCard PIN management
spendingLimitsfalseSpending limit management
notificationstrueNotification endpoints
merchantDashboardtrueMerchant dashboard
Remittance

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 webhookitpayment 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 middleware/validation.tsMVP called directlyno byPISP routewebhook handlers.for domestic transfers in mock mode).

State Transition Rules:

By
FunctionFrom PurposeTo UsedTrigger Guard ConditionSide Effect
(none)processingPOST /v1/transactions/remittanceKYC approved, balance sufficientDeduct cached balance, create audit log, send notification
processingcompletedPISP webhook or QR sync completionpaymentId matches transactionUpdate status, send completion notification
processingfailedPISP webhook rejection or 5-min timeoutpaymentId matches, status RJCTRestore cached balance (re-sync AISP), send failure notification

8. Error Handling Strategy

8.1 Error Classification

Error TypeHTTP StatusRetry?Log LevelAlert?
ValidationError400NoINFONo
UnauthorizedError401NoWARNNo
KYCRequired403NoINFONo
InsufficientBalance403NoINFONo
RecipientNotFound404NoINFONo
DuplicateTransaction409NoINFONo
AmountOutOfRange422NoINFONo
RateLimited429After Retry-AfterWARNNo
PISPUnavailable502Yes (3x backoff)ERRORYes (if sustained > 5 min)
DatabaseError500Yes (1x)ERRORYes
UnexpectedError500NoERRORYes

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

ConcernScenarioMitigation
Double paymentClient retries POST /v1/transactions/remittance after network timeoutUnique index on idempotency_key — second INSERT fails with UNIQUE constraint → return existing transaction
Balance race conditionTwo simultaneous payments from same accountDB transaction with UPDATE bank_accounts SET balance = balance - X WHERE balance >= X — atomic check-and-deduct
Exchange rate stalenessRate changes between disclosure and paymentRate locked at payment initiation time; user sees pre-payment disclosure; rate used is from DB at payment time

10. Performance Considerations

fieldsprofile accounts
OperationTarget (p99)Current BaselineOptimization
sanitizeText(text,POST maxLength)/v1/transactions/remittance Strip< HTML500ms tags,(local) control+ characters,PISP truncatelatency All~50ms textDB inputoperations DB transaction atomic; PISP call is async from user perspective
validatePhone(phone)GET /v1/transactions International< phone format (+ prefix, 8-15 digits)100ms User~20ms Index idx_transactions_user on user_id; pagination limits result set
validateAmount(amount)POST /v1/transactions/disclosure Positive< number, max 2 decimal places50ms Transactions~10msTwo DB reads (recipient + exchange rate); no external API call
validateIBAN(iban)POST /v1/transactions/qr-payment ISO< 13616 IBAN checksum validation200ms Bank~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

DependencyTypePurposeFallback if unavailable
middleware/auth.tsSynchronousJWT validation + user contextNone — request rejected with 401
validatePIN(pin)middleware/rate-limit.ts Exactly 4 digitsSynchronous CardIP PIN+ user rate limitingNone — request rejected with 429
validateEmail(email)lib/db.ts Basic email formatRequired RegistrationAll data access (query, run, transaction)None — module unavailable

External Dependencies

Pre-paymentbalance staleness
DependencyVersionPurposeFallback if unavailable
PostgreSQL16Primary data storeSQLite (dev only)
validateCurrency(currency)Open Banking PISP (Neonomics/ASPSP) Whitelist:Berlin EUR,Group USD, GBP, BAM, CHF, PLN, NOK, RSD, TRY, PKRv1.3.12+ TransactionsPayment initiationNone — return 502, payment cannot proceed
validateName(name)Open Banking AISP Non-empty,Berlin containsGroup letters, no script injectionv1.3.12+ Recipients
validateLanguage(lang)verification Whitelist:Use nb,cached en,bank_accounts.balance bs,with sq Settings
auditLog(...)Insert audit trail recordAll significant actionswarning

Cross-References12. Configuration Parameters

VariableTypeDefaultRequiredDescription
DATABASE_URLstringNo (SQLite default)PostgreSQL connection string
NEXT_PUBLIC_SERVICE_MODEstringmockNomock = simulate PISP; production = real PISP calls
OPEN_BANKING_API_URLstringYes (prod)Neonomics or ASPSP base URL
OPEN_BANKING_CLIENT_IDstringYes (prod)eIDAS client identifier
OPEN_BANKING_CLIENT_SECRETstringYes (prod)eIDAS client secret

13. Testing Approach

Test TypeToolCoverage TargetLocation
Unit testsVitest> 80% business logicsrc/drop-api/src/__tests__/unit/transactions/
Integration testsSupertestKey payment flowssrc/drop-api/src/__tests__/integration/transactions/

Key test scenarios:

  • Security ArchitectureRemittanceTrustsuccess boundaries,path STRIDE,(mock application security controlsPISP)
  • Authentication RemittanceJWT,KYC sessionnot management,approved BankID OIDC403
  • API ReferenceRemittanceEndpointamount specifications< and100 securityNOK requirements→ 422
  • Login Authentication FlowRemittanceBankIDamount OIDC> authentication50,000 detailNOK → 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

RoleNameDateSignature
AuthorPetter Graff2026-02-23
Module OwnerJohn (AI Director)
Security Review
Tech LeadJohn (AI Director)