Module Design
Module Design Document
Project:
{{PROJECT_NAME}}Drop Module:{{MODULE_NAME}}Payments Module (transactions+recipients+exchange_rates) Service:{{SERVICE_NAME}}drop-api —src/drop-api/src/routes/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)
Document History
| Version | Date | Author | Changes |
|---|---|---|---|
| 0.1 | Initial draft from source code analysis |
1. Module Overview & Responsibility
Module:
Layer: Application (routes) + Domain {{MODULE_NAME}}payments|(business Applicationlogic) |+ Infrastructure |(DB Presentationaccess)
Repository: , {{REPO_OR_MONOREPO_PATH}}src/drop-api/src/routes/transactions.tssrc/drop-api/src/routes/recipients.ts, src/drop-api/src/routes/rates.ts
Team Owner: {{TEAM_NAME}}ALAI — Backend
Single Responsibility Statement:
{{THE_MODULE_IS_RESPONSIBLE_FOR_ONE_THING}}The Payments module orchestrates all financial operations — remittance (international money transfer) and QR merchant payments — using the PSD2 pass-through model, meaning Drop never holds funds but initiates payments from the user's bank via PISP.
This module owns:
{{OWNED_RESOURCE_1}}Transaction lifecycle (datacreate,+status update, list, fetch)- Remittance business
logic)rules (fee calculation, FX rate application, PSD2 disclosure) {{OWNED_RESOURCE_2}}QR payment business rules (merchant fee calculation, HMAC QR validation)- Recipient management (CRUD for saved remittance contacts)
- Exchange rate lookup and caching (6 corridors)
- Idempotency enforcement for payment operations
- Pre-payment disclosure (PSD2 Art. 45/46)
This module does NOT own:
{{NOT_OWNED_1}}User authentication and session management — owned bymodule ({{OTHER_MODULE}}authroutes/auth.ts,lib/bankid.ts){{NOT_OWNED_2}}KYC/AML status — owned bymodule ({{OTHER_MODULE}}complianceroutes/user.ts+ Sumsub integration)- Bank account linking (AISP consent) — owned by
bankingmodule (routes/bank-accounts.ts) - Merchant registration — owned by
merchantsmodule (routes/merchants.ts) - Notifications delivery — owned by
notificationsmodule (routes/notifications.ts)
Why this is a separate module:
{{RATIONALE_FOR_SEPARATION}}Payments —is e.g.,the "Separatecore revenue-generating bounded context,context differentfor teamDrop. ownership,It differenthas scalingdistinct requirements"business rules (PSD2 compliance, FX calculation, PISP orchestration), its own data domain (transactions, recipients, exchange_rates), and its own compliance requirements (PSD2 Art. 45/46 disclosure, idempotency, AML monitoring). Separating it enables independent testing, focused security review, and future extraction to a dedicated service if transaction volume demands it (per ADR-005 extraction triggers).
2. Interface Definition (Public API)
2.1 Exported Service Interface
// PublicRemittance
interface exported by this module
export interface I{{ModuleName}}ServiceIRemittanceService {
/**
* Calculate pre-payment disclosure (fee, FX rate, receive amount)
* Required by PSD2 Art. 45/46 before every payment
* @throws {NotFoundError} if recipientId not found or not owned by user
* @throws {METHOD_1_DESCRIPTION}}NotFoundError} if exchange rate for currency not found
*/
calculateDisclosure(dto: DisclosureDto, userId: string): Promise<DisclosureResult>;
/**
* Initiate international remittance via PISP
* @throws {ForbiddenError} if KYC not approved
* @throws {ForbiddenError} if insufficient balance
* @throws {NotFoundError} if recipient not found
* @throws {ConflictError} if idempotency key collision (returns existing tx)
* @throws {ValidationError} if dtoamount isoutside invalid100-50000 *NOK @throws {ConflictError} if {{UNIQUE_FIELD}} already existsrange
*/
create(initiateRemittance(dto: Create{{Entity}}Dto,RemittanceDto, context:userId: RequestContext)string): Promise<{{Entity}}Transaction>;
/**
* Initiate QR merchant payment via PISP (domestic)
* @throws {ForbiddenError} if KYC not approved
* @throws {METHOD_2_DESCRIPTION}}NotFoundError} if merchant not found or inactive
*/
initiateQRPayment(dto: QRPaymentDto, userId: string): Promise<Transaction>;
/**
* List user's transactions with pagination and optional filters
*/
listTransactions(filter: TransactionFilter, userId: string): Promise<PaginatedTransactions>;
/**
* Get a single transaction by ID (must belong to requesting user)
* @throws {NotFoundError} if not found or access denied
*/
findById(id:getTransaction(txId: string, context:userId: RequestContext)string): Promise<{{Entity}}Transaction>;
}
/**/ *Recipients
interface IRecipientsService {{METHOD_3_DESCRIPTION}}
*/createRecipient(dto: findAll(filter:CreateRecipientDto, {{Filter}}Dto,userId: context: RequestContext)string): Promise<PaginatedResult<{{Entity}}>Recipient>;
/**listRecipients(userId: * {{METHOD_4_DESCRIPTION}}
* @throws {NotFoundError} if not found
* @throws {ForbiddenError} if user lacks permission
*/
update(id: string, dto: Update{{Entity}}Dto, context: RequestContext)string): Promise<{{Entity}}Recipient[]>;
/**
* {{METHOD_5_DESCRIPTION}}
* @throws {NotFoundError} if not found
*/
delete(id:getRecipient(recipientId: string, context:userId: RequestContext)string): Promise<Recipient>;
deleteRecipient(recipientId: string, userId: string): Promise<void>;
}
// DTOs exported for consumersTypes
export type Create{{Entity}}DtoRemittanceDto = {
recipientId: string;
amount: number; /*/ ...NOK, *100-50000
bankAccountId: string;
currency?: string; // Default: 'NOK'
};
export type Update{{Entity}}DtoQRPaymentDto = {
merchantId: string;
amount: number; /*/ ...NOK, */positive
};
export type {{Filter}}DtoDisclosureDto = {
/*type: ...'remittance';
*/amount: number;
recipientId: string;
};
export type {{Entity}}Transaction = {
id: string; /*/ ...tx_<hex16>
*/userId: string;
type: 'remittance' | 'qr_payment';
status: 'processing' | 'completed' | 'failed';
amount: number;
fee: number;
receiveAmount?: number;
receiveCurrency?: string;
exchangeRate?: number;
recipientId?: string;
merchantId?: string;
idempotencyKey?: string;
createdAt: string;
};
2.2 HTTP Endpoints (if applicable)
| Method | Path | Auth | Rate Limit | Description |
|---|---|---|---|---|
POST |
/ |
JWT | Initiate remittance | |
POST |
/v1/transactions/qr-payment |
JWT | 10/IP + 3/user per 60s | Initiate QR payment |
POST |
/v1/transactions/disclosure |
JWT | None specific | Pre-payment fee disclosure |
GET |
/ |
JWT | None specific | List |
GET |
/ |
JWT | None specific | Get transaction by ID |
|
/ |
JWT | None specific | List saved recipients |
POST |
/ |
JWT | None specific | Add recipient |
GET |
/v1/recipients/:id |
JWT | ||
specific |
| Get |||
DELETE |
/ |
JWT | Delete recipient | |
GET |
/v1/rates/:currency |
JWT | 120 req/60s per IP | Get exchange rate |
2.3 Events Published
The Payments module does not use an async event bus (monolith-first architecture per ADR-005). Side effects (notifications, audit log) are written synchronously within the same DB transaction.
| Audit trail | |
| Every ||
| User notification | |
| Transaction ||
| AML monitoring | |
| Triggered ||
| high-risk |
3. Internal Structure
{{MODULE_NAME}}/routes/
├── controllers/
│ └── {{entity}}.controller.transactions.ts # HTTP request handling, inputrate parsinglimiting, routes
├── services/recipients.ts # Recipient CRUD routes
└── rates.ts # Exchange rate lookup
lib/
├── db.ts # Dual-driver DB abstraction (query, run, transaction)
├── middleware/
│ ├── auth.ts # JWT verification, session validation
│ └── {{entity}}.service.rate-limit.ts # BusinessDB-backed logicIP ├──+ repositories/user │rate ├── {{entity}}.repository.ts # Data access interface
│limiting
└── {{entity}}.repository.pg.ts # PostgreSQL implementation
├── domain/
│ ├── {{entity}}.entity.ts # Domain entity / value objects
│ ├── {{entity}}.events.ts # Domain events
│openbanking/
└── {{entity}}.errors.neonomics.ts # Domain-specificPISP errorsclient ├──(Phase dto/2 │— ├──currently create-{{entity}}.dto.ts
│ ├── update-{{entity}}.dto.ts
│ └── {{entity}}-filter.dto.ts
├── mappers/
│ └── {{entity}}.mapper.ts # DB record ↔ domain entity ↔ DTO
├── __tests__/
│ ├── unit/
│ └── integration/
└── {{entity}}.module.ts # Module registration / DI wiringmock)
Layer rules (enforced by linting):rules:
ControllersRoutes only callServicesdb.tsfunctions (nevernoRepositoriesrawdirectly)SQL strings outside db.ts)ServicesAllonlySQLcallusesRepositoriesparameterizedandqueriespublish—Eventsno string interpolationDomainBusinessentitieslogichave(feenocalculation,frameworkvalidation)dependencieslives Mappersinliverouteathandlersserviceorlayerdedicated helper functions — not incontrollersdb.ts- External API calls (Neonomics PISP) called after DB transaction commits to ensure idempotency key is stored before external call
4. Database Schema
Primary Table: {{table_name}}transactions
| Column | Type | Nullable | Default | Constraints | Description |
|---|---|---|---|---|---|
id |
|
NO | — | PK, tx_<hex16> |
Transaction ID |
user_id |
TEXT |
NO | — | FK → users(id) |
Initiating user |
type |
TEXT |
NO | — | CHECK('remittance','qr_payment') | Payment type |
status |
TEXT |
NO | |
CHECK('processing','completed','failed') | Payment status |
amount |
REAL |
NO | — | NOT NULL | Amount in NOK |
currency |
TEXT |
YES | 'NOK' |
— | Source currency |
fee |
REAL |
YES | 0 |
— | Fee in NOK |
recipient_id |
TEXT |
YES | NULL | FK → recipients(id) |
Remittance target |
merchant_id |
TEXT |
YES | NULL | FK → merchants(id) |
QR payment target |
send_amount |
REAL |
YES | NULL | — | Amount in source currency |
receive_amount |
REAL |
YES | NULL | — | Amount in target currency |
receive_currency |
TEXT |
YES | NULL | — | Target currency |
exchange_rate |
REAL |
YES | NULL | — | Rate at payment time |
description |
TEXT |
YES | NULL | — | User description |
idempotency_key |
TEXT |
YES | NULL | UNIQUE | Duplicate prevention |
created_at |
TEXT |
NO | datetime('now') |
— | Creation timestamp |
Indexes:
-- PostgreSQL
CREATE INDEX CONCURRENTLY idx_transactions_user_id ON transactions(user_id);
-- Rationale: Every list query filters by user_id
CREATE UNIQUE INDEX idx_tx_idempotency ON transactions(idempotency_key)
WHERE idempotency_key IS NOT NULL;
-- Rationale: Prevent duplicate payments on retry
CREATE INDEX CONCURRENTLY idx_transactions_recipient ON transactions(recipient_id)
WHERE recipient_id IS NOT NULL;
CREATE INDEX CONCURRENTLY idx_transactions_merchant ON transactions(merchant_id)
WHERE merchant_id IS NOT NULL;
Secondary Table: recipients
| Column | Type | Nullable | Default | Constraints | Description |
|---|---|---|---|---|---|
id |
TEXT |
NO | — | PK, rec_<hex16> |
Recipient ID |
user_id |
TEXT |
NO | — | FK → users(id) |
Owner |
name |
TEXT |
NO | — | NOT NULL | Recipient full name |
country |
TEXT |
NO | — | NOT NULL | Country code (RS, BA, PL, PK, TR, EU) |
currency |
TEXT |
NO | — | NOT NULL | Target currency |
bank_account |
TEXT |
NO | — | NOT NULL | IBAN or local account number |
bank_name |
TEXT |
YES | NULL | — | Bank name (optional) |
created_at |
TEXT |
NO | datetime('now') |
— | Created timestamp |
Secondary Table: exchange_rates
| Column | Type | Nullable | Default | Constraints | Description |
|---|---|---|---|---|---|
id |
INTEGER |
NO | auto | PK | Surrogate key |
|
|
NO | — | NOT NULL | Always 'NOK' at MVP |
|
TEXT |
— | NOT NULL | RSD, BAM, PLN, PKR, TRY, EUR | |
rate |
REAL |
NO | — | NOT NULL | 1 NOK = N target units |
updated_at |
| | |||
| |
YES | — |
— | |
| | | |||
| | | |||
| | | |||
| | |
Indexes:
CREATE INDEX CONCURRENTLY idx_{{table_name}}_{{column}} ON {{table_name}}({{column}})
WHERE deleted_at IS NULL;
-- Rationale: {{WHY_THIS_INDEX}}
CREATE UNIQUE INDEX idx_{{table_name}}_{{unique_column}} ON {{table_name}}({{unique_column}})
WHERE deleted_at IS NULL;
-- Rationale: Enforce uniqueness for active records only
RLS (Row-Level Security):
-- TODO: Enable if multi-tenant
-- ALTER TABLE {{table_name}} ENABLE ROW LEVEL SECURITY;
-- CREATE POLICY {{table_name}}_tenant_isolation ON {{table_name}}
-- USING (tenant_id = current_setting('app.current_tenant_id')::uuid);
5. API Endpoints (Detailed)
POST /api/v{{V}}/{{resource}}v1/transactions/remittance
Request:
{
"{{field1}}"recipientId": "string (required, max 255 chars)"rec_abc123def456gh78",
"{{field2}}amount": 2000,
"bankAccountId": "number (required, > 0)"ba_abc123def456gh78",
"{{field3}}"currency": "string (optional, enum: [A, B, C])"NOK"
}
Validation:
recipientId: required, string, must exist inrecipientsWHEREuser_id = currentUseramount: required, number, 100 ≤ amount ≤ 50000bankAccountId: required, string, must exist inbank_accountsWHEREuser_id = currentUser
Success 201:
{
"data": {
"id": "uuid"tx_rem_abc123def456gh78",
"{{field1}}"type": "value"remittance",
"{{field2}}"status": 0,"processing",
"amount": 2000,
"fee": 10,
"receiveAmount": 20340,
"receiveCurrency": "RSD",
"exchangeRate": 10.17,
"estimatedDelivery": "2-4 business days",
"scaRedirect": "https://bank.no/sca/pay/...",
"createdAt": "ISO8601"2026-02-23T10:00:00.000Z"
}
}
POST /v1/transactions/disclosure
Request:
{
"type": "remittance",
"amount": 2000,
"recipientId": "rec_abc123def456gh78"
}
Event published:Success :{{entity}}.created200
{
"specversion": "1.0",
"type": "{{entity}}.created",
"source": "/{{resource}}",
"id": "{{EVENT_UUID}}",
"time": "ISO8601",
"data": {
"entityId"sendAmount": 2000,
"sendCurrency": "uuid"NOK",
"tenantId"fee": 10,
"feePercentage": 0.5,
"exchangeRate": 10.17,
"receiveAmount": 20340,
"receiveCurrency": "uuid"RSD",
"createdBy"totalCost": 2010,
"estimatedDelivery": "uuid",2-4 "{{field1}}":business "value"days"
}
}
6. Business Logic Specifications
6.1 Business Rules
| Rule ID | Rule | Enforced In | Error |
|---|---|---|---|
| BR-001 | kyc_status = 'approved' before initiating any payment |
(403) |
|
| BR-002 | (422) |
||
| BR-003 | N/A (business calculation) | ||
| BR-004 | QR payment fee = merchants.fee_rate (default 1%) |
Route calculation | N/A |
| BR-005 | Recipient must belong to the authenticated user | DB query WHERE user_id | recipient_not_found (404) |
| BR-006 | Cached bank balance must cover amount + fee | Route check | insufficient_balance (403) |
| BR-007 | Idempotency key uniqueness prevents duplicate payment on retry | UNIQUE DB constraint | Return existing transaction (409 or 200) |
| BR-008 | Pre-payment disclosure must be shown before every remittance (PSD2 Art. 45/46) | Frontend enforces; API provides via |
N/A |
| BR-009 | Drop never initiates PISP without recording transaction in DB first | Atomic transaction: INSERT tx → PISP call | Rollback on PISP error |
6.2 Validation Rules
| Field | Type | Required | Validation | Error Message |
|---|---|---|---|---|
|
string |
Yes | recipients |
" |
|
number |
Yes | " |
|
|
|
bank_accounts |
" |
|
merchantId |
string |
Yes (QR) | Exists in merchants WHERE status='active' |
"Butikk ikke funnet" |
6.3 Authorization Rules
| Operation | Required Role | Additional Conditions |
|---|---|---|
|
kyc_status = 'approved' |
|
user |
kyc_status = 'approved' |
|
| Get disclosure | user |
Must own recipient |
| List transactions | user |
Only own transactions (user_id = currentUser) |
| Add recipient | user |
Any authenticated user |
| Delete recipient | |
Must |
|
||
| | |
| ||
|
7. Event Publishing / Consuming
The Payments module operates in a monolith (ADR-005) — no async message bus. All side effects are synchronous within DB transactions:
7.1 EventsSide PublishedEffects on Payment Creation
| Audit trail | |
| resource_type='transaction' |
|
| User notification | |
| amount} under behandling' |
|
| AML monitoring | |
| post-commit by AML rules engine |
7.2 Events Consumed
| | ||
| |
Idempotency strategy:webhooks Allfrom consumersOpen checkBanking provider (Neonomics in production) → processed_eventsPOST /v1/webhooks/openbankingtable
GET /v1/transactions/:id8. Dependencies
8.1 Upstream (what this module depends on)
| Dependency | Type | Coupling | Reason |
|---|---|---|---|
|
Internal module | JWT validation, user |
|
|
Internal module | ||
lib/db.ts |
Shared library | Hard (required) | All data access |
| PostgreSQL / SQLite | Infrastructure | Primary storage | |
Neonomics) |
External |
Hard (prod) |
8.2 Downstream (what depends on this module)
| Consumer | What they use | Notes | |
|---|---|---|---|
(Next.js) |
REST API /v1/transactions/*, /v1/recipients/* |
Via | fetch |
(Expo) |
/v1/transactions/*, /v1/recipients/* |
Via |
|
compliance/aml module |
transactions table reads |
AML rules engine monitors transaction patterns |
9. Error Handling & Recovery
| Error Scenario | Handling | User Impact | Recovery | |
|---|---|---|---|---|
| DB connection lost | Retry |
Request fails |
Auto-recover when DB reconnects | |
Return processing |
Payment may or |
|||
| Duplicate submission | Detect via |
|||
| Return |
Clear error message with KYC link | User |
||
| Balance insufficient | Return 403 | Clear error with balance shown | User reduces amount or top-up bank account |
10. Configuration & Feature Flags
Environment Variables
| Variable | Type | Default | Description |
|---|---|---|---|
|
|
|
mock production = real Neonomics calls |
|
|
— |
|
|
|
— | eIDAS client ID for Neonomics |
OPEN_BANKING_CLIENT_SECRET |
|
eIDAS |
Feature Flags (environment variables)
| Flag | Type | Default | Description |
|---|---|---|---|
|
boolean |
true |
Toggle |
|
boolean |
false |
11. Monitoring & Health Checks
Health Check Endpoint
GET / (owned by health route, includes payment module indicators)health/{{module-name}}v1/health
{
"status": "healthy | degraded | unhealthy"ok",
"checks": {
"database"version": "healthy"0.1.0",
"cache"uptime": 3600,
"db": "healthy | degraded"connected",
"externalApi"dbLatencyMs": 1,
"timestamp": "healthy | degraded | unhealthy"
},
"latency": {
"database_ms": 5,
"cache_ms": 1
}2026-02-23T10:00:00.000Z"
}
Key Metrics (via Sentry + CloudWatch)
| Metric | Type | Alert Threshold | Dashboard |
|---|---|---|---|
Remittance rate |
Counter | ||
Remittance | |||
| |||
| |||
down) |
Counter | Any occurrence | |
| Transaction processing time | Histogram | p99 > 2000ms | CloudWatch |
kyc_required 403 rate |
Counter | > 20% of remittance attempts | Sentry |
insufficient_balance 403 rate |
Counter | > 30% of remittance attempts | Sentry |
12. Primary Flow — Sequence Diagram
sequenceDiagram
autonumber
participant C as ControllerClient (Web/Mobile)
participant SRL as ServiceRate Limiter
participant VAuth as ValidatorAuth Middleware
participant RRoute as RepositoryTransactions Route
participant DB as PostgreSQL
participant EBPISP as EventOpen BusBanking participantPISP Cache as Redis(Neonomics)
C->>V:RL: validate(dto)POST /v1/transactions/remittance
RL->>RL: Check rate_limits (10/IP, 3/user per 60s)
RL->>Auth: Forward if within limits
Auth->>DB: Verify JWT + session + user
Auth-->>Route: {userId, role, kycStatus}
Route->>Route: Validate: kycStatus='approved', amount 100-50000
alt InvalidValidation inputfails
V-Route-->>C: ValidationError
C-->>Client: 400 Bad Request400/403/422
end
V-Route->>DB: SELECT recipient WHERE id=? AND user_id=?
Route->>DB: SELECT exchange_rate WHERE to_currency=?
Route->>DB: SELECT bank_account WHERE id=? AND user_id=?
Route->>Route: Calculate fee, totalCost, receiveAmount
alt balance < totalCost
Route-->>C: Validated403 DTO
C->>S: create(dto, context)
S->>S: checkBusinessRules(dto)
alt Business rule violation
S-->>C: BusinessRuleError
C-->>Client: 422 Unprocessableinsufficient_balance
end
S-Route->>DB: BEGIN TRANSACTION
S-Route->>R:DB: create(entityData)UPDATE R-bank_accounts SET balance = balance - totalCostInOere
Route->>DB: INSERT INTO {{table_name}}transactions DB--(status='processing', idempotency_key=?)
Route->>R:DB: InsertedINSERT recordINTO R--audit_log
Route->>S:DB: {{Entity}}INSERT domainINTO objectnotifications
S-Route->>DB: COMMIT
S-Route->>EB:PISP: publish("{{entity}}.created",POST event)/v1/payments/sepa-credit-transfers Note(with overidempotency_key)
EB: Async — does not block response
S-PISP-->>Cache:Route: INVALIDATE{paymentId, relatedscaRedirect, keystransactionStatus: S-"RCVD"}
Route-->>C: {{Entity}} DTO
C-->>Client: 201 Created{transactionId, status: "processing", scaRedirect}
Approval
| Role | Name | Date | Signature |
|---|---|---|---|
| Author | Petter Graff | 2026-02-23 | |
| Module Owner | John (AI Director) | ||
| Tech Lead | John (AI Director) | ||
| Reviewer | Alem Bašić |