Skip to main content

Low-Level Design (LLD)

Low-Level Design Document

Project: Drop{{PROJECT_NAME}} Module/Component: Transactions Module (Remittance + QR Payment){{MODULE_NAME}} Version: 1.0{{VERSION}} Date: 2026-02-23{{DATE}} Author: Petter Graff, Senior Enterprise Architect{{AUTHOR}} Status: Draft | In Review | Approved Reviewers: Alem Bašić (CEO), John (AI Director){{REVIEWERS}} Related HLD: HLD Document

Document History

Version Date Author Changes
0.1 2026-02-21{{DATE}} Banking Architecture Team{{AUTHOR}} Initial draft from source code
1.02026-02-23Petter GraffFilled with real Drop data

1. Module Overview

Module: transactions{{MODULE_NAME}} Service/Repo: drop-api — src/drop-api/src/routes/transactions.ts{{REPO_NAME}} Team Owner: ALAI — Backend{{TEAM_NAME}}

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.{{ONE_SENTENCE_WHAT_THIS_MODULE_DOES}}

Boundaries:

  • Owns: Transaction records (transactions table), exchange rates (exchange_rates table), fee calculation, pre-payment disclosure, idempotency enforcement, PISP payment initiation orchestration{{WHAT_THIS_MODULE_OWNS}}
  • 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){{WHAT_THIS_MODULE_DOES_NOT_OWN}}
  • Delegates to: BankID auth middleware (JWT validation), Open Banking PISP API (actual payment execution), audit_log (compliance side-effect), notifications (user alerting){{WHAT_IT_DELEGATES}}

Key Business Rules:

  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_1}}
  2. Every transaction requires kyc_status = 'approved' on the initiating user{{BUSINESS_RULE_2}}
  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{{BUSINESS_RULE_3}}

2. Class / Module Diagram

classDiagram
    class TransactionsRoute{{ServiceClassName}} {
        -authMiddleware:repository: Middleware{{RepositoryInterface}}
        -rateLimiter:eventBus: MiddlewareEventBus
        -logger: Logger
        +POSTcreate(dto: /v1/transactions/remittance(body: RemittanceDto)Create{{Entity}}Dto): ResponsePromise~{{Entity}}~
        +POSTfindById(id: /v1/transactions/qr-payment(body: QRPaymentDto)string): ResponsePromise~{{Entity}} | null~
        +POSTfindAll(filter: /v1/transactions/disclosure(body: DisclosureDto){{Filter}}Dto): ResponsePromise~PaginatedResult~{{Entity}}~~
        +GETupdate(id: /v1/transactions(query:string, TransactionFilter)dto: Update{{Entity}}Dto): ResponsePromise~{{Entity}}~
        +GETdelete(id: /v1/transactions/:id()string): ResponsePromise~void~
        -validateRemittanceInput(validate(dto: RemittanceDto)unknown): void
        -validateQRPaymentInput(dto:publishEvent(event: QRPaymentDto){{DomainEvent}}): voidPromise~void~
    }

    class RemittanceDto{{RepositoryInterface}} {
        +recipientId: string<<interface>>
        +amount:findById(id: numberstring): Promise~{{Entity}} | null~
        +bankAccountId:findMany(filter: string{{Filter}}): Promise~{{Entity}}[]~
        +currency:create(data: stringPartial~{{Entity}}~): Promise~{{Entity}}~
        +update(id: string, data: Partial~{{Entity}}~): Promise~{{Entity}}~
        +delete(id: string): Promise~void~
        +count(filter: {{Filter}}): Promise~number~
    }

    class QRPaymentDto {
        +merchantId: string
        +amount: number
        +qrData: string
    {Entity}}

    class Transaction {
        +id: string
        +user_id:createdAt: stringDate
        +type:updatedAt: "remittance"Date
        +deletedAt: Date | "qr_payment"null
        {{FIELD_1}}: {{TYPE_1}}
        {{FIELD_2}}: {{TYPE_2}}
        +status:isDeleted(): "processing" | "completed" | "failed"boolean
        +amount:toJSON(): 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{{EntityJSON}}
    }

    class DatabaseCreate{{Entity}}Dto {
        <<abstraction>>
        +query(sql, params){{FIELD_1}}: T[]{{TYPE_1}}
        +getOne(sql, params){{FIELD_2}}: T
        +run(sql, params): RunResult
        +transaction(fn): void{{TYPE_2}}
    }

    class PISPClient{{Controller}} {
        <<external>>-service: {{ServiceClassName}}
        +initiatePayment(paymentRequest)POST /{{resource}}(body: Create{{Entity}}Dto): PaymentResponseResponse
        +getPaymentStatus(paymentId)GET /{{resource}}/:id(): PaymentStatusResponse
        +GET /{{resource}}(query: {{Filter}}Dto): Response
        +PUT /{{resource}}/:id(body: Update{{Entity}}Dto): Response
        +DELETE /{{resource}}/:id(): Response
    }

    TransactionsRoute{{ServiceClassName}} --> RemittanceDto{{RepositoryInterface}}
    TransactionsRoute{{ServiceClassName}} --> QRPaymentDto{{Entity}}
    TransactionsRoute{{Controller}} --> Transaction{{ServiceClassName}}
    TransactionsRoute{{RepositoryInterface}} --..> Database
    TransactionsRoute --> PISPClient{{Entity}}

3. Database Schema

3.1 Tables

transactions{{TABLE_NAME_1}}

Purpose: Records all financial operations. Append-only — status updates are the only writes after creation.{{TABLE_PURPOSE}}

Column Type Nullable Default Constraints Description
id TEXTNOPK, format: tx_<hex16>Transaction identifier
user_idTEXTNOFK → users(id)Initiating user
typeTEXTNOCHECK('remittance','qr_payment')Transaction type
statusTEXTUUID NO 'processing'gen_random_uuid() CHECK('processing','completed','failed')PK PaymentPrimary 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 retrykey
created_at TEXTTIMESTAMPTZ NO datetime('now'NOW() TransactionRecord creation 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
transactions_pkey{{TABLE_NAME_1}}_pkey id B-tree (PK) Primary key lookup
idx_transactions_useridx_{{TABLE_NAME_1}}_{{COLUMN}} user_id{{COLUMN}} B-tree Filter all transactions per user (high frequency){{QUERY_RATIONALE}}
idx_tx_idempotencyidx_{{TABLE_NAME_1}}_deleted_at idempotency_keydeleted_at UniquePartial B-tree(WHERE deleted_at IS NULL) PreventSoft-delete duplicatefilter payment on API retry
idx_transactions_recipientrecipient_idB-treeLookup by recipient
idx_transactions_merchantmerchant_idB-treeLookup by merchantperformance

exchange_rates{{TABLE_NAME_2}}

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

Column Type Nullable Default Constraints Description
id INTEGERUUID NO autogen_random_uuid() PK SurrogatePrimary key
from_currency{{COLUMN_1}} TEXT{{TYPE}} NO{{YES/NO}} NOT NULLAlways 'NOK' at MVP
to_currency{{DEFAULT}} TEXT{{CONSTRAINTS}} NONOT 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{{DESCRIPTION}}

3.2 Enums (CHECK constraints in SQLite, native ENUMs in PostgreSQL migration)

--CREATE transactionTYPE type{{ENUM_NAME}} CHECK(typeAS INENUM (
    'remittance'{{VALUE_1}}',
    'qr_payment'))

-- transaction status
CHECK(status IN ({{VALUE_2}}'processing',
    'completed',{{VALUE_3}}'
'failed'));

3.3 Migration Notes

  • Migration:Migration Included infile: db.ts{{MIGRATION_FILE_NAME}}.sql initializeDatabase() — runs on startup (SQLite) or via separate migration script (PostgreSQL)
  • Zero-downtime: YES{{YES_NO}}only INSERT and UPDATE status needed; CREATE INDEX CONCURRENTLY for PostgreSQL{{NOTES}}
  • Backfill required: NO{{YES_NO}}new tables{{BACKFILL_STRATEGY}}
  • Estimated migration time: < 1 second (SQLite), < 5 seconds (PostgreSQL){{ESTIMATE}}

4. API Contract

Base Path: /v1/transactionsapi/v{{VERSION}}/{{RESOURCE}}

POST /v1/transactions/remittance{{resource}}

Summary: Initiate international money transfer (PISP) from user's bank account toCreate a savednew recipient{{entity}}

Authentication: Bearer JWT required (authMiddleware) Rate Limit: 10 req/60s per IP + 3 req/60s per user

Request Body:

{
  "recipientId"{{field1}}": "rec_abc123def456gh78"{{type_and_example}}",
  "amount": 2000,
  {{field2}}"bankAccountId": "ba_abc123def456gh78",
  {{type_and_example}}"currency": "NOK"
}

Success Response — 201 Created:

{
  "data": {
    "id": "tx_rem_abc123def456gh78"550e8400-e29b-41d4-a716-446655440000",
  "type"{{field1}}": "remittance",
    {{value}}"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:2024-01-01T00:00:00.000Z"
  }
}

Error Responses:

Status Code Description
400 validation_errorVALIDATION_ERROR MissingRequest orbody invalidfails fields (amount, recipientId)validation
401 unauthorizedUNAUTHORIZED Missing or expiredinvalid JWT
403 kyc_requiredFORBIDDEN UserInsufficient kyc_status is not approved
403insufficient_balanceCached AISP balance < amount + fee
404recipient_not_foundrecipientId does not belong to this userpermissions
409 duplicate_transactionCONFLICT Idempotency{{DUPLICATE_FIELD}} keyalready collision — returns existing transactionexists
422 amount_out_of_rangeBUSINESS_RULE_VIOLATION Amount{{BUSINESS_RULE}} <not 100 NOK or > 50,000 NOK
429rate_limitedExceeded 10 req/60s per IP or 3 req/60s per user
502pisp_unavailableOpen Banking PISP API unreachablemet
500 internal_errorINTERNAL_ERROR Unexpected server error

POSTGET /v1/transactions/qr-payment{{resource}}/:id

Summary: InitiateRetrieve QRa merchant{{entity}} paymentby (PISP) from user's bank accountID

Authentication:Path Bearer JWT required

Request Body:Parameters:
















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

Success Response — 201200 CreatedOK:

{
  "data": {
    "id": "tx_qr_abc123def456gh78"550e8400-e29b-41d4-a716-446655440000",
  "type"{{field1}}": "qr_payment",
    {{value}}"status": "completed",
    "amount": 450,
    "fee": 4.5,
    "merchantName": "Café Oslo AS",
  "createdAt": "2026-02-23T10:2024-01-01T00:00:00.000Z"
  }
}

Error Responses:

Status Code Description
400401 validation_errorUNAUTHORIZED Missing or invalid fields
401unauthorizedMissing or expired JWT
403kyc_requiredUser kyc_status not approved
404 merchant_not_foundNOT_FOUND merchantId{{entity}} 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{{resource}}

Summary: List authenticated user's transactions{{entities}} with pagination and filtering

Query Parameters:

Parameter Type Default Description
page integer 1 Page number (1-based)
limit integer 20 Items per page (maxmax: 50)100)
typesort string createdAt:desc Filter:Sort remittancefield orand qr_paymentdirection
status{{filter1}} string{{type}} Filter:Filter processing,by completed, failed{{filter1}}

Success Response — 200 OK:

{
  "data": {
    "transactions": [
      {
        "id": "tx_rem_abc123",
        "type": "remittance",
        "status": "completed",
        "amount": 2000,
        "fee": 10,
        "receiveAmount": 20340,
        "receiveCurrency": "RSD",
        "recipientName": "Marko Petrovic",
        "createdAt": "2026-02-23T10:00:00.000Z"
      }
    ],
  "total"pagination": 15,{
    "page": 1,
    "limit": 2020,
    "total": 0,
    "totalPages": 0
  }
}

5. Algorithm Specifications

5.1 Fee Calculation — Remittance{{ALGORITHM_NAME}}

Purpose: Calculate 0.5% fee on remittance, rounded to 2 decimal places{{WHAT_IT_DOES}} Complexity: Time O(1){{TIME_COMPLEXITY}}) | Space O(1){{SPACE_COMPLEXITY}})

function calculateRemittanceFee(sendAmountNOK:{{algorithmName}}(input: number){{InputType}}): number
    FEE_RATE = 0.005{{OutputType}}
    // 0.5%Step fee1: =Validate sendAmountNOKinput
    *if FEE_RATEinput returnis Math.round(feenull *or 100)invalid /then
        100throw ValidationError("{{VALIDATION_MESSAGE}}")
    end if

    // RoundStep to2: 2{{STEP_2_DESCRIPTION}}
    decimal places

function calculateReceiveAmount(sendAmountNOK: number, exchangeRate: number): number
    netSendresult = sendAmountNOKinitialize()
    for each item in input.items do
        if item.satisfies({{CONDITION}}) then
            result.add(transform(item))
        end if
    end for

    // FeeStep taken3: from{{STEP_3_DESCRIPTION}}
    send amount, not receive
    receivesorted = netSend * exchangeRate
    return Math.round(receive)result.sortBy({{SORT_CRITERIA}})

    // RoundStep to4: wholeApply unitsbusiness ofrules
    targetfor currencyeach rule in {{BUSINESS_RULES}} do
        sorted = rule.apply(sorted)
    end for

    return sorted
end function

Edge Cases:

  • MinimumEmpty amount:input: 100Return NOKempty result, feedo =not 0.50throw
  • NOK
  • Single item: {{SINGLE_ITEM_BEHAVIOR}}
  • Maximum amount:items: 50,000 NOK → fee = 250 NOK
  • Rate not found: Return 404 — do not proceedLimit to transaction{{MAX_ITEMS}}, log warning if exceeded

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 RemittanceCreate Initiation{{Entity}} Flow

sequenceDiagram
    autonumber
    actor Client
    as Client (Web/Mobile)
    participant RLGW as RateAPI LimiterGateway
    participant Auth as Auth Middleware
    participant RouteSVC as Transactions Route{{ServiceClassName}}
    participant DB as Database
    participant PISPMQ as OpenMessage BankingQueue
    PISPparticipant Email as Email Service

    Client->>RL:GW: POST /v1/transactions/remittance{{resource}} RL->>RL:{body}
    Check rate_limits (10/IP, 3/user per 60s)
    alt Rate limit exceeded
        RL-->>Client: 429 Too Many Requests
    end
    RL-GW->>Auth: Forward request
    Auth->>Auth: ExtractValidate 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:GW: userUser context {userId, role,roles}
    kycStatus}

    Route-GW->>Route:SVC: Validatecreate(dto, body:userContext)
    recipientId,SVC->>SVC: amount (100-50000), bankAccountIdvalidate(dto)
    alt Validation fails
        Route-SVC-->>Client: 400 validation_errorValidationError
    end
    alt kyc_status != 'approved'
        Route-->>Client: 403 kyc_required
    end

    Route->>DB: SELECT * FROM recipients WHERE id = ? AND user_id = ?
    alt Recipient not found
        Route-->>Client: 404 recipient_not_found
    end
    Route->>DB: SELECT rate FROM exchange_rates WHERE to_currency = ?
    Route->>DB: SELECT * FROM bank_accounts WHERE id = ? AND user_id = ? AND is_primary = 1

    Route->>Route: Calculate fee (0.5%), total cost, receive amount
    alt balance < totalCost
        Route-->>Client: 403 insufficient_balance
    end

    Route-SVC->>DB: BEGIN TRANSACTION
    Route->>DB: UPDATE bank_accounts SET balance = balance - totalCostInOere WHERE balance >= ?
    Route-SVC->>DB: INSERT INTO transactions{{TABLE_NAME_1}}
    (status='processing', idempotency_key=?)
    Route-DB-->>DB:SVC: INSERT{{entity}} INTOrecord
    audit_log (action='transaction.create')
    Route->>DB: INSERT INTO notifications (title='Overføring startet')
    Route-SVC->>DB: COMMIT
    Route-SVC->>PISP:MQ: POSTpublish("{{ENTITY_CREATED_EVENT}}", /v1/payments/cross-border-credit-transfersevent)
    PISP-MQ->>Email: Trigger confirmation email
    Email-->>Route:Client: Email delivered async
    SVC-->>GW: {paymentId,{entity}} transactionStatus:dto
    "RCVD", scaRedirect}

    Route-GW-->>Client: 201 {transactionId, status: "processing", scaRedirect}Created

6.2 QR Payment{{SECONDARY_FLOW_NAME}} Flow

sequenceDiagram
    autonumber
    actor Client
    participant SVC as Client (Mobile){{ServiceClassName}}
    participant RouteCache as Transactions RouteRedis
    participant DB as Database

    Client->>Route:SVC: POSTGET /v1/transactions/qr-payment{{resource}}/:id
    SVC->>Cache: GET {merchantId,{resource}}:{{id}}
    amount}alt Route-Cache hit
        Cache-->>DB:SVC: VerifyCached JWT{{entity}}
        sessionSVC-->>Client: Route-200 OK (from cache)
    else Cache miss
        Cache-->>SVC: null
        SVC->>DB: SELECT * FROM merchants{{TABLE_NAME_1}} WHERE id = ? AND status = 'active'$1
        alt Merchant notNot found
            Route-DB-->>SVC: null
            SVC-->>Client: 404 merchant_not_foundNot Found
        end
        Route-DB-->>DB:SVC: SELECT{{entity}} *record
        FROM bank_accounts WHERE user_id = ? AND is_primary = 1

    Route-SVC->>Route: Calculate fee = amount * merchant.fee_rate
    Route->>Route: Calculate total = amount + fee

    Route->>DB: BEGIN TRANSACTION
    Route->>DB: UPDATE bank_accountsCache: SET balance{{resource}}:{{id}} =TTL={{CACHE_TTL}}
        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-SVC-->>Client: 201200 {transactionId,OK
    status: "completed", merchantName}end

7. State Diagrams

stateDiagram-v2
    [*] --> processing:{{STATE_DRAFT}} POST: /v1/transactions/remittanceCreated

    (PISP initiated)

    processing{{STATE_DRAFT}} --> completed:{{STATE_PENDING}} PISP: webhooksubmit()
    — 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{{STATE_DRAFT}} --> [*] failed: 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}} --> [*]

Note: QR payments go directly from creation to completed (synchronous in MVP — no PISP webhook for domestic transfers in mock mode).

State Transition Rules:

From To Trigger Guard Condition Side Effect
(none)DRAFT processingPENDING POST /v1/transactions/remittancesubmit() KYCAll approved,required balancefields sufficientpopulated DeductNotify cached balance, create audit log, send notificationreviewers
processingPENDING completedACTIVE PISP webhook or QR sync completionapprove() paymentIdApprover matcheshas transaction{{PERMISSION}} role UpdateSend status,welcome send completion notificationemail
processingACTIVE failedSUSPENDED PISP webhook rejection or 5-min timeoutsuspend(reason) paymentIdAdmin matches, status RJCTonly RestoreLog cachedaudit balance (re-sync AISP), send failure notificationevent

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
KYCRequiredForbiddenError 403 No INFOWARN NoSuspicious patterns only
InsufficientBalance403NoINFONo
RecipientNotFoundNotFoundError 404 No INFO No
DuplicateTransactionConflictError 409 NoINFONo
AmountOutOfRange422NoINFONo
RateLimited429After Retry-After WARN No
PISPUnavailableExternalServiceError 502 Yes (3x backoff)3x) ERROR Yes (if sustained > 5 min)sustained)
DatabaseError 500 Yes (1x) ERROR Yes
UnexpectedError 500 No ERROR Yes

8.2 Error Response Format (RFC 7807)

{
  "error"type": "kyc_required",
  "message": "Du må fullføre identitetsverifisering før du kan sende penger.https://api.{{DOMAIN}}/errors/{{ERROR_CODE}}",
  "details"title": []"{{Human-readable error title}}",
  "status": 400,
  "detail": "{{Specific error message}}",
  "instance": "/{{resource}}/{{id}}",
  "traceId": "{{TRACE_ID}}"
}

8.3 Retry & Fallback Strategy

PISPExternal API call failure:
  → Retry with exponential backoff: [1s, 2s, 4s]
  → Max retries: 3
  → Circuit breaker: Open after 35 failures in 60s window
  → 60s cooldown
  → Fallback: Return 502 to client — payment cannot proceed without PISP{{FALLBACK_BEHAVIOR}}
  → Alert: Sentry alertPagerDuty 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
DoubleDuplicate paymentcreation ClientTwo retriesrequests POSTcreate /v1/transactions/remittancesame after{{entity}} network timeoutsimultaneously Unique indexconstraint on idempotency_key{{UNIQUE_FIELD}} + second INSERT fails with UNIQUEcatch constraint error return existing transaction409
BalanceOptimistic race conditionlocking Two simultaneousupdates payments fromto same accountrecord DB transaction with UPDATE bank_accounts SET balance = balance - X WHERE balance >= Xversion column atomic+ check-and-deductincrement
ExchangeRace ratecondition stalenessin {{FLOW}} Rate changes between disclosure and payment{{RACE_CONDITION_DESCRIPTION}} Rate{{MITIGATION}} locked(e.g., atDB-level paymentlock, initiationRedis time; user sees pre-payment disclosure; rate used is from DB at payment timeSETNX)

10. Performance Considerations

Operation Target (p99) Current Baseline Optimization
POSTGET /v1/transactions/remittance{{resource}}/:id < 500ms (local) + PISP latency50ms ~50ms DB operations{{BASELINE}} DBRedis transactioncache atomic; PISP call is async from user perspective(TTL={{TTL}})
GET /v1/transactions{{resource}} (list) < 100ms200ms ~20ms{{BASELINE}} IndexIndexed idx_transactions_userquery on+ user_id;cursor pagination limits result set
POST /v1/transactions/disclosure{{resource}} < 50ms300ms ~10ms{{BASELINE}} TwoAsync DBevent reads (recipient + exchange rate); no external API callpublishing
POSTBulk /v1/transactions/qr-paymentimport (1000 items) < 200ms5s ~40ms{{BASELINE}} SynchronousBatch completioninsert in mockchunks mode;of PISP async in production100

Known bottlenecks:

  • PISP{{BOTTLENECK_1}}: API latency: 200-2000ms external call — mitigated by async SCA redirect pattern{{MITIGATION}}
  • Exchange{{BOTTLENECK_2}}: rate reads: High frequency but 6 rows only — fully cached in PostgreSQL buffer pool{{MITIGATION}}

11. Dependencies

Internal Dependencies

Dependency Type Purpose Fallback if unavailable
middleware/auth.ts{{INTERNAL_SERVICE_1}} Synchronous JWT validation + user context{{PURPOSE}} None — request rejected with 401{{FALLBACK}}
middleware/rate-limit.ts{{SHARED_LIB}} SynchronousLibrary IP + user rate limiting{{PURPOSE}} None — request rejected with 429
lib/db.tsRequiredAll data accessN/A (query, run, transaction)None — module unavailablerequired)

External Dependencies

Dependency Version Purpose Fallback if unavailable
PostgreSQL 16{{VERSION}} Primary data store SQLiteNone (dev only)module unavailable
Open Banking PISP (Neonomics/ASPSP)Redis Berlin Group v1.3.12+{{VERSION}} PaymentCaching initiation+ session NoneDegrade gracefully return(skip 502, payment cannot proceedcache)
Open Banking AISP{{EXTERNAL_API}} Berlin Group v1.3.12+v{{VERSION}} Pre-payment balance verification{{PURPOSE}} Use cached bank_accounts.balance with staleness warning{{FALLBACK}}

12. Configuration Parameters

Variable Type Default Required Description
DATABASE_URL{{MODULE_NAME}}_MAX_PAGE_SIZE stringinteger 100 No (SQLite default) PostgreSQLMaximum connectionitems stringper page
NEXT_PUBLIC_SERVICE_MODE{{MODULE_NAME}}_CACHE_TTL_SECONDS stringinteger mock300 No mockCache =TTL simulatein PISP; production = real PISP callsseconds
OPEN_BANKING_API_URL{{MODULE_NAME}}_RETRY_ATTEMPTSinteger3NoExternal API retry count
{{EXTERNAL_API_KEY_VAR}} string Yes (prod) NeonomicsAPI orkey ASPSPfor base URL{{EXTERNAL_SERVICE}}
OPEN_BANKING_CLIENT_ID{{DB_CONNECTION_VAR}} string Yes (prod) eIDASDatabase clientconnection identifier
OPEN_BANKING_CLIENT_SECRETstringYes (prod)eIDAS client secret

13. Testing Approach

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

Key test scenarios:

  • RemittanceCreate — 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{{entity}} — success path
  • QRCreate payment{{entity}}merchantvalidation inactiveerror (missing 404required field)
  • DisclosureCreate {{entity}}calculatesconflict fee,(duplicate exchange rate, receive amount correctly{{UNIQUE_FIELD}})
  • PISPGet {{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 — circuit breaker — 3 failures → open → 502 (integration test, Phase 2)triggers
  • Concurrent paymentcreationbalanceduplicate race conditionconstraint 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)