# Low-Level Design Document

# Low-Level Design Document

> **Project:** Drop
> **Module/Component:** Transactions Module (Remittance + QR Payment)
> **Version:** 1.0
> **Date:** 2026-02-23
> **Author:** Petter Graff, Senior Enterprise Architect
> **Status:** Approved
> **Reviewers:** Alem Bašić (CEO), John (AI Director)
> **Related HLD:** [HLD Document](./hld.md)

## Document History
| Version | Date | Author | Changes |
|---------|------|--------|---------|
| 0.1     | 2026-02-21 | Banking Architecture Team | Initial draft from source code |
| 1.0     | 2026-02-23 | Petter Graff | Filled with real Drop data |

---

## 1. Module Overview

**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 (JWT validation), Open Banking PISP API (actual payment execution), audit_log (compliance side-effect), notifications (user alerting)

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

```mermaid
classDiagram
    class TransactionsRoute {
        -authMiddleware: Middleware
        -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`

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

| Column | Type | Nullable | Default | Constraints | Description |
|--------|------|----------|---------|-------------|-------------|
| `id` | `TEXT` | NO | — | PK, format: `tx_<hex16>` | Transaction identifier |
| `user_id` | `TEXT` | NO | — | FK → `users(id)` | Initiating user |
| `type` | `TEXT` | NO | — | CHECK('remittance','qr_payment') | Transaction type |
| `status` | `TEXT` | NO | `'processing'` | CHECK('processing','completed','failed') | Payment status |
| `amount` | `REAL` | NO | — | NOT NULL | Send amount in NOK (stored in øre equivalent) |
| `currency` | `TEXT` | YES | `'NOK'` | — | Source currency (always NOK at MVP) |
| `fee` | `REAL` | YES | `0` | — | Fee in NOK (0.5% remittance, merchant rate for QR) |
| `recipient_id` | `TEXT` | YES | NULL | FK → `recipients(id)` | For remittances; NULL for QR |
| `merchant_id` | `TEXT` | YES | NULL | FK → `merchants(id)` | For QR payments; NULL for remittance |
| `send_amount` | `REAL` | YES | NULL | — | Amount sent in source currency |
| `receive_amount` | `REAL` | YES | NULL | — | Amount received in destination currency |
| `receive_currency` | `TEXT` | YES | NULL | — | Destination currency (e.g., RSD, EUR) |
| `exchange_rate` | `REAL` | YES | NULL | — | Exchange rate at time of transaction |
| `description` | `TEXT` | YES | NULL | — | Optional user-provided description |
| `idempotency_key` | `TEXT` | YES | NULL | UNIQUE | Prevents duplicate payments on retry |
| `created_at` | `TEXT` | NO | `datetime('now')` | — | Transaction timestamp |

**Indexes:**
| Index Name | Columns | Type | Rationale |
|-----------|---------|------|-----------|
| `transactions_pkey` | `id` | B-tree (PK) | Primary key lookup |
| `idx_transactions_user` | `user_id` | B-tree | Filter all transactions per user (high frequency) |
| `idx_tx_idempotency` | `idempotency_key` | Unique B-tree | Prevent duplicate payment on API retry |
| `idx_transactions_recipient` | `recipient_id` | B-tree | Lookup by recipient |
| `idx_transactions_merchant` | `merchant_id` | B-tree | Lookup by merchant |

#### `exchange_rates`

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

| Column | Type | Nullable | Default | Constraints | Description |
|--------|------|----------|---------|-------------|-------------|
| `id` | `INTEGER` | NO | auto | PK | Surrogate key |
| `from_currency` | `TEXT` | NO | — | NOT NULL | Always 'NOK' at MVP |
| `to_currency` | `TEXT` | NO | — | NOT NULL | Target currency (RSD, BAM, PLN, PKR, TRY, EUR) |
| `rate` | `REAL` | NO | — | NOT NULL | Exchange rate: 1 NOK = N target currency units |
| `updated_at` | `TEXT` | YES | — | — | Last rate update timestamp |

**Indexes:**
| Index Name | Columns | Type | Rationale |
|-----------|---------|------|-----------|
| `idx_rates_currency` | `from_currency, to_currency` | Composite B-tree | Fast rate lookup by currency pair |

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

```sql
-- transaction type
CHECK(type IN ('remittance', 'qr_payment'))

-- transaction status
CHECK(status IN ('processing', 'completed', 'failed'))
```

### 3.3 Migration Notes

- Migration: Included in `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:**
```json
{
  "recipientId": "rec_abc123def456gh78",
  "amount": 2000,
  "bankAccountId": "ba_abc123def456gh78",
  "currency": "NOK"
}
```

**Success Response — `201 Created`:**
```json
{
  "data": {
    "id": "tx_rem_abc123def456gh78",
    "type": "remittance",
    "status": "processing",
    "amount": 2000,
    "fee": 10,
    "receiveAmount": 20340,
    "receiveCurrency": "RSD",
    "exchangeRate": 10.17,
    "estimatedDelivery": "2-4 business days",
    "scaRedirect": "https://dnb.no/sca/pay/abc123",
    "createdAt": "2026-02-23T10:00:00.000Z"
  }
}
```

**Error Responses:**
| Status | Code | Description |
|--------|------|-------------|
| `400` | `validation_error` | Missing or invalid fields (amount, recipientId) |
| `401` | `unauthorized` | Missing or expired JWT |
| `403` | `kyc_required` | User `kyc_status` is not `approved` |
| `403` | `insufficient_balance` | Cached AISP balance < amount + fee |
| `404` | `recipient_not_found` | recipientId does not belong to this user |
| `409` | `duplicate_transaction` | Idempotency key collision — returns existing transaction |
| `422` | `amount_out_of_range` | Amount < 100 NOK or > 50,000 NOK |
| `429` | `rate_limited` | Exceeded 10 req/60s per IP or 3 req/60s per user |
| `502` | `pisp_unavailable` | Open Banking PISP API unreachable |
| `500` | `internal_error` | Unexpected server error |

---

### `POST /v1/transactions/qr-payment`

**Summary:** Initiate QR merchant payment (PISP) from user's bank account

**Authentication:** Bearer JWT required

**Request Body:**
```json
{
  "merchantId": "mer_abc123def456gh78",
  "amount": 450
}
```

**Success Response — `201 Created`:**
```json
{
  "data": {
    "id": "tx_qr_abc123def456gh78",
    "type": "qr_payment",
    "status": "completed",
    "amount": 450,
    "fee": 4.5,
    "merchantName": "Café Oslo AS",
    "createdAt": "2026-02-23T10:00:00.000Z"
  }
}
```

**Error Responses:**
| Status | Code | Description |
|--------|------|-------------|
| `400` | `validation_error` | Missing or invalid fields |
| `401` | `unauthorized` | Missing or expired JWT |
| `403` | `kyc_required` | User `kyc_status` not `approved` |
| `404` | `merchant_not_found` | merchantId not found or inactive |
| `500` | `internal_error` | Unexpected error |

---

### `POST /v1/transactions/disclosure`

**Summary:** Pre-payment disclosure — returns fee, exchange rate, receive amount (PSD2 Art. 45/46 compliance)

**Authentication:** Bearer JWT required

**Request Body:**
```json
{
  "type": "remittance",
  "amount": 2000,
  "recipientId": "rec_abc123def456gh78"
}
```

**Success Response — `200 OK`:**
```json
{
  "data": {
    "sendAmount": 2000,
    "sendCurrency": "NOK",
    "fee": 10,
    "feePercentage": 0.5,
    "exchangeRate": 10.17,
    "receiveAmount": 20340,
    "receiveCurrency": "RSD",
    "totalCost": 2010,
    "estimatedDelivery": "2-4 business days"
  }
}
```

---

### `GET /v1/transactions`

**Summary:** List authenticated user's transactions with pagination

**Query Parameters:**
| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `page` | `integer` | `1` | Page number (1-based) |
| `limit` | `integer` | `20` | Items per page (max 50) |
| `type` | `string` | — | Filter: `remittance` or `qr_payment` |
| `status` | `string` | — | Filter: `processing`, `completed`, `failed` |

**Success Response — `200 OK`:**
```json
{
  "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": 15,
    "page": 1,
    "limit": 20
  }
}
```

---

## 5. Algorithm Specifications

### 5.1 Fee Calculation — Remittance

**Purpose:** Calculate 0.5% fee on remittance, rounded to 2 decimal places
**Complexity:** Time O(1) | Space O(1)

```pseudocode
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 send amount, 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:** `{userId}:{amount}:{recipientId}:{minuteTimestamp}`

```pseudocode
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 Remittance Initiation Flow

```mermaid
sequenceDiagram
    autonumber
    actor Client as Client (Web/Mobile)
    participant RL as Rate Limiter
    participant Auth as Auth Middleware
    participant Route as Transactions Route
    participant DB as Database
    participant PISP as Open Banking PISP

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

    Route->>Route: Validate body: recipientId, amount (100-50000), bankAccountId
    alt Validation fails
        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 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
    Route->>DB: UPDATE bank_accounts SET balance = balance - 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-->>Route: {paymentId, transactionStatus: "RCVD", scaRedirect}

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

### 6.2 QR Payment Flow

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

---

## 7. State Diagrams

```mermaid
stateDiagram-v2
    [*] --> processing: POST /v1/transactions/remittance (PISP initiated)

    processing --> completed: PISP webhook — 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 --> [*]
    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 |
|------|-----|---------|-----------------|-------------|
| (none) | processing | POST /v1/transactions/remittance | KYC approved, balance sufficient | Deduct cached balance, create audit log, send notification |
| processing | completed | PISP webhook or QR sync completion | paymentId matches transaction | Update status, send completion notification |
| processing | failed | PISP webhook rejection or 5-min timeout | paymentId matches, status RJCT | Restore cached balance (re-sync AISP), send failure notification |

---

## 8. Error Handling Strategy

### 8.1 Error Classification

| Error Type | HTTP Status | Retry? | Log Level | Alert? |
|-----------|------------|--------|-----------|--------|
| ValidationError | 400 | No | INFO | No |
| UnauthorizedError | 401 | No | WARN | No |
| KYCRequired | 403 | No | INFO | No |
| InsufficientBalance | 403 | No | INFO | No |
| RecipientNotFound | 404 | No | INFO | No |
| DuplicateTransaction | 409 | No | INFO | No |
| AmountOutOfRange | 422 | No | INFO | No |
| RateLimited | 429 | After Retry-After | WARN | No |
| PISPUnavailable | 502 | Yes (3x backoff) | ERROR | Yes (if sustained > 5 min) |
| DatabaseError | 500 | Yes (1x) | ERROR | Yes |
| UnexpectedError | 500 | No | ERROR | Yes |

### 8.2 Error Response Format

```json
{
  "error": "kyc_required",
  "message": "Du må fullføre identitetsverifisering før du kan sende penger.",
  "details": []
}
```

### 8.3 Retry & Fallback Strategy

```
PISP API call failure:
  → Retry with exponential backoff: [1s, 2s, 4s]
  → Max retries: 3
  → Circuit breaker: Open after 3 failures in 60s window → 60s cooldown
  → Fallback: Return 502 to client — payment cannot proceed without PISP
  → Alert: Sentry alert if circuit remains open > 5 minutes
  → Idempotency: PISP call uses X-Request-ID = idempotency_key to prevent double-payment on retry
```

---

## 9. Concurrency & Thread Safety

| Concern | Scenario | Mitigation |
|---------|----------|------------|
| Double payment | Client retries POST /v1/transactions/remittance after network timeout | Unique index on `idempotency_key` — second INSERT fails with UNIQUE constraint → return existing transaction |
| Balance race condition | Two simultaneous payments from same account | DB transaction with `UPDATE bank_accounts SET balance = balance - X WHERE balance >= X` — atomic check-and-deduct |
| Exchange rate staleness | Rate changes between disclosure and payment | Rate locked at payment initiation time; user sees pre-payment disclosure; rate used is from DB at payment time |

---

## 10. Performance Considerations

| Operation | Target (p99) | Current Baseline | Optimization |
|-----------|-------------|-----------------|--------------|
| `POST /v1/transactions/remittance` | < 500ms (local) + PISP latency | ~50ms DB operations | DB transaction atomic; PISP call is async from user perspective |
| `GET /v1/transactions` | < 100ms | ~20ms | Index `idx_transactions_user` on `user_id`; pagination limits result set |
| `POST /v1/transactions/disclosure` | < 50ms | ~10ms | Two DB reads (recipient + exchange rate); no external API call |
| `POST /v1/transactions/qr-payment` | < 200ms | ~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
| Dependency | Type | Purpose | Fallback if unavailable |
|-----------|------|---------|------------------------|
| `middleware/auth.ts` | Synchronous | JWT validation + user context | None — request rejected with 401 |
| `middleware/rate-limit.ts` | Synchronous | IP + user rate limiting | None — request rejected with 429 |
| `lib/db.ts` | Required | All data access (query, run, transaction) | None — module unavailable |

### External Dependencies
| Dependency | Version | Purpose | Fallback if unavailable |
|-----------|---------|---------|------------------------|
| PostgreSQL | 16 | Primary data store | SQLite (dev only) |
| Open Banking PISP (Neonomics/ASPSP) | Berlin Group v1.3.12+ | Payment initiation | None — return 502, payment cannot proceed |
| Open Banking AISP | Berlin Group v1.3.12+ | Pre-payment balance verification | Use cached `bank_accounts.balance` with staleness warning |

---

## 12. Configuration Parameters

| Variable | Type | Default | Required | Description |
|---------|------|---------|----------|-------------|
| `DATABASE_URL` | `string` | — | No (SQLite default) | PostgreSQL connection string |
| `NEXT_PUBLIC_SERVICE_MODE` | `string` | `mock` | No | `mock` = simulate PISP; `production` = real PISP calls |
| `OPEN_BANKING_API_URL` | `string` | — | Yes (prod) | Neonomics or ASPSP base URL |
| `OPEN_BANKING_CLIENT_ID` | `string` | — | Yes (prod) | eIDAS client identifier |
| `OPEN_BANKING_CLIENT_SECRET` | `string` | — | Yes (prod) | eIDAS client secret |

---

## 13. Testing Approach

| Test Type | Tool | Coverage Target | Location |
|-----------|------|-----------------|----------|
| Unit tests | Vitest | > 80% business logic | `src/drop-api/src/__tests__/unit/transactions/` |
| Integration tests | Supertest | Key payment flows | `src/drop-api/src/__tests__/integration/transactions/` |

**Key test scenarios:**
- [x] Remittance — success path (mock PISP)
- [x] Remittance — KYC not approved → 403
- [x] Remittance — amount < 100 NOK → 422
- [x] Remittance — amount > 50,000 NOK → 422
- [x] Remittance — recipient not found → 404
- [x] Remittance — duplicate request (idempotency key) → 409 with existing transaction
- [x] QR payment — success path
- [x] QR payment — merchant inactive → 404
- [x] Disclosure — calculates fee, exchange rate, receive amount correctly
- [ ] PISP circuit breaker — 3 failures → open → 502 (integration test, Phase 2)
- [ ] Concurrent payment — balance race condition handled correctly (Phase 2)

---

## Approval
| Role | Name | Date | Signature |
|------|------|------|-----------|
| Author | Petter Graff | 2026-02-23 | |
| Module Owner | John (AI Director) | | |
| Security Review | | | |
| Tech Lead | John (AI Director) | | |