Skip to main content

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 {{DATE}}2026-02-21 {{AUTHOR}}Banking Architecture Team Initial draft from source code
1.02026-02-23Petter GraffFilled with real Drop data

1. Module Overview

Module: {{MODULE_NAME}}transactions Service/Repo: {{REPO_NAME}}drop-api — src/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 (transactions table), exchange rates (exchange_rates table), 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 (recipients table 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:

  1. {{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
  2. {{BUSINESS_RULE_2}}Every transaction requires kyc_status = 'approved' on the initiating user
  3. {{BUSINESS_RULE_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 {{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 UUIDTEXTNOPK, format: tx_<hex16>Transaction identifier
user_idTEXTNOFK → users(id)Initiating user
typeTEXTNOCHECK('remittance','qr_payment')Transaction type
statusTEXT NO gen_random_uuid()'processing' PKCHECK('processing','completed','failed') PrimaryPayment keystatus
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_at TIMESTAMPTZTEXT NO NOW(datetime('now') Record creationTransaction timestamp
updated_atTIMESTAMPTZNONOW()Last update timestamp
deleted_atTIMESTAMPTZYESNULLSoft delete timestamp
{{COLUMN_1}}{{TYPE}}{{YES/NO}}{{DEFAULT}}{{CONSTRAINTS}}{{DESCRIPTION}}
{{COLUMN_2}}{{TYPE}}{{YES/NO}}{{DEFAULT}}{{CONSTRAINTS}}{{DESCRIPTION}}
{{FK_COLUMN}}UUIDNOFK → {{OTHER_TABLE}}(id)Reference to {{OTHER_TABLE}}

Indexes:

Index Name Columns Type Rationale
{{TABLE_NAME_1}}_pkeytransactions_pkey id B-tree (PK) Primary key lookup
idx_{{TABLE_NAME_1}}_{{COLUMN}}idx_transactions_user {{COLUMN}}user_id B-tree {{QUERY_RATIONALE}}Filter all transactions per user (high frequency)
idx_{{TABLE_NAME_1}}_deleted_atidx_tx_idempotency deleted_atidempotency_key PartialUnique (WHERE deleted_at IS NULL)B-tree Soft-deletePrevent filterduplicate performancepayment on API retry
idx_transactions_recipientrecipient_idB-treeLookup by recipient
idx_transactions_merchantmerchant_idB-treeLookup 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 UUIDINTEGER NO gen_random_uuid()auto PK PrimarySurrogate key
{{COLUMN_1}}from_currency {{TYPE}}TEXT {{YES/NO}}NONOT NULLAlways 'NOK' at MVP
to_currency {{DEFAULT}}TEXT {{CONSTRAINTS}}NO {{DESCRIPTION}}NOT 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)

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.ts initializeDatabase() — runs on startup (SQLite) or via separate migration script (PostgreSQL)
  • Zero-downtime: {{YES_NO}}YES{{NOTES}}only INSERT and UPDATE status needed; CREATE INDEX CONCURRENTLY for 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 VALIDATION_ERRORvalidation_error RequestMissing bodyor failsinvalid validationfields (amount, recipientId)
401 UNAUTHORIZEDunauthorized Missing or invalidexpired JWT
403 FORBIDDENkyc_required InsufficientUser permissionskyc_status is not approved
403insufficient_balanceCached AISP balance < amount + fee
404recipient_not_foundrecipientId does not belong to this user
409 CONFLICTduplicate_transaction {{DUPLICATE_FIELD}}Idempotency alreadykey existscollision — returns existing transaction
422 BUSINESS_RULE_VIOLATIONamount_out_of_range {{BUSINESS_RULE}}Amount not< met100 NOK or > 50,000 NOK
429rate_limitedExceeded 10 req/60s per IP or 3 req/60s per user
502pisp_unavailableOpen Banking PISP API unreachable
500 INTERNAL_ERRORinternal_error 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:

{
  
"merchantId": "mer_abc123def456gh78", "amount": 450
ParameterTypeDescription
idUUIDThe {{entity}} identifier

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
401400 UNAUTHORIZEDvalidation_error Missing or invalid fields
401unauthorizedMissing or expired JWT
403kyc_requiredUser kyc_status not approved
404 NOT_FOUNDmerchant_not_found {{entity}}merchantId 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 /{{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 (max:max 100)50)
sorttype string createdAt:desc SortFilter: fieldremittance andor directionqr_payment
{{filter1}}status {{type}}string FilterFilter: byprocessing, {{filter1}}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:

  • EmptyMinimum input:amount: Return100 emptyNOK result, dofee not= throw
  • 0.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
DRAFT(none) PENDINGprocessing submit()POST /v1/transactions/remittance AllKYC requiredapproved, fieldsbalance populatedsufficient NotifyDeduct reviewerscached balance, create audit log, send notification
PENDINGprocessing ACTIVEcompleted approve()PISP webhook or QR sync completion ApproverpaymentId hasmatches {{PERMISSION}} roletransaction SendUpdate welcomestatus, emailsend completion notification
ACTIVEprocessing SUSPENDEDfailed suspend(reason)PISP webhook rejection or 5-min timeout AdminpaymentId onlymatches, status RJCT LogRestore auditcached eventbalance (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
ForbiddenErrorKYCRequired 403 No WARNINFO Suspicious patterns onlyNo
NotFoundErrorInsufficientBalance403NoINFONo
RecipientNotFound 404 No INFO No
ConflictErrorDuplicateTransaction 409 NoINFONo
AmountOutOfRange422NoINFONo
RateLimited429After Retry-After WARN No
ExternalServiceErrorPISPUnavailable 502 Yes (3x)3x backoff) ERROR Yes (if sustained)sustained > 5 min)
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
DuplicateDouble creationpayment TwoClient requestsretries createPOST same/v1/transactions/remittance {{entity}}after simultaneouslynetwork timeout Unique constraintindex on {{UNIQUE_FIELD}}idempotency_key + catchsecond INSERT fails with UNIQUE constraint error 409return existing transaction
OptimisticBalance lockingrace condition Two updatessimultaneous topayments from same recordaccount DB transaction with versionUPDATE bank_accounts SET balance = balance - X WHERE balance >= X column +atomic check-and-incrementdeduct
RaceExchange conditionrate in {{FLOW}}staleness {{RACE_CONDITION_DESCRIPTION}}Rate changes between disclosure and payment {{MITIGATION}}Rate (e.g.,locked DB-levelat lock,payment Redisinitiation SETNX)time; user sees pre-payment disclosure; rate used is from DB at payment time

10. Performance Considerations

Operation Target (p99) Current Baseline Optimization
GETPOST /{{resource}}/:idv1/transactions/remittance < 50ms500ms (local) + PISP latency {{BASELINE}}~50ms DB operations RedisDB cachetransaction (TTL={{TTL}})atomic; PISP call is async from user perspective
GET /{{resource}}v1/transactions (list) < 200ms100ms {{BASELINE}}~20ms IndexedIndex queryidx_transactions_user +on cursoruser_id; pagination limits result set
POST /{{resource}}v1/transactions/disclosure < 300ms50ms {{BASELINE}}~10ms AsyncTwo eventDB publishingreads (recipient + exchange rate); no external API call
BulkPOST import (1000 items)/v1/transactions/qr-payment < 5s200ms {{BASELINE}}~40ms BatchSynchronous insertcompletion in chunksmock ofmode; 100PISP async in production

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
{{INTERNAL_SERVICE_1}}middleware/auth.ts Synchronous {{PURPOSE}}JWT validation + user context {{FALLBACK}}None — request rejected with 401
{{SHARED_LIB}}middleware/rate-limit.ts LibrarySynchronous {{PURPOSE}}IP + user rate limiting N/ANone — request rejected with 429
lib/db.tsRequiredAll data access (required)query, run, transaction)None — module unavailable

External Dependencies

Dependency Version Purpose Fallback if unavailable
PostgreSQL {{VERSION}}16 Primary data store NoneSQLite (dev module unavailableonly)
RedisOpen Banking PISP (Neonomics/ASPSP) {{VERSION}}Berlin Group v1.3.12+ CachingPayment + sessioninitiation DegradeNone gracefully (skipreturn cache)502, payment cannot proceed
{{EXTERNAL_API}}Open Banking AISP v{{VERSION}}Berlin Group v1.3.12+ {{PURPOSE}}Pre-payment balance verification {{FALLBACK}}Use cached bank_accounts.balance with staleness warning

12. Configuration Parameters

Variable Type Default Required Description
{{MODULE_NAME}}_MAX_PAGE_SIZEDATABASE_URL integerstringNo (SQLite default)PostgreSQL connection string
NEXT_PUBLIC_SERVICE_MODE 100stringmock No Maximummock items= persimulate pagePISP; production = real PISP calls
{{MODULE_NAME}}_CACHE_TTL_SECONDSinteger300NoCache TTL in seconds
{{MODULE_NAME}}_RETRY_ATTEMPTSinteger3NoExternal API retry count
{{EXTERNAL_API_KEY_VAR}}OPEN_BANKING_API_URL string Yes (prod) APINeonomics keyor forASPSP {{EXTERNAL_SERVICE}}base URL
{{DB_CONNECTION_VAR}}OPEN_BANKING_CLIENT_ID string Yes (prod) DatabaseeIDAS connectionclient identifier
OPEN_BANKING_CLIENT_SECRETstringYes (prod)eIDAS client secret

13. Testing Approach

Test Type Tool Coverage Target Location
Unit tests Jest / Vitest > {{UNIT_COVERAGE}}%80% business logic src/{{module}}/drop-api/src/__tests__/unit/transactions/
Integration tests Supertest + TestContainers Key payment flows src/{{module}}/drop-api/src/__tests__/integration/
Contract testsPactAll public APIssrc/{{module}}/__tests__/contract/transactions/

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}}paymentvalidationmerchant errorinactive (missing required field)404
  • Create {{entity}}Disclosureconflictcalculates (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, sorting
  •  Update {{entity}} — success
  •  Update {{entity}} — not found
  •  Delete {{entity}} — success (soft delete)
  •  State transition — valid transition
  •  State transition — invalid transition (guard fails)
  •  External service failure —PISP circuit breaker triggers— 3 failures → open → 502 (integration test, Phase 2)
  • Concurrent creationpaymentduplicatebalance constraintrace 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)