Service Design
Service Design Document — Payment Service
Project:
{{PROJECT_NAME}}Drop Service:{{SERVICE_NAME}}Payment Service (Remittance + QR Payments) Version:{{VERSION}}0.1.0 Date:{{DATE}}2026-02-23 Author:{{AUTHOR}}Platform Architect (AI) Status:Draft |In Review| ApprovedReviewers:{{REVIEWERS}}Alem Bašić (CEO)
Document History
| Version | Date | Author | Changes |
|---|---|---|---|
| 0.1 | Initial draft from source code analysis |
1. Service Overview
The Payment Service is Drop's core business logic module, responsible for:
- Remittance — international money transfers from Norway to 5 corridors (Serbia, Bosnia, Poland, Pakistan, Turkey)
- QR Payments — instant in-store payments to registered merchants
Drop uses a PSD2 pass-through model — it never holds customer money. Payments are PISP-initiated directly from the user's bank account. The service orchestrates: balance verification → fee calculation → atomic debit + transaction record creation.
Source files:
src/drop-app/src/app/api/transactions/remittance/route.tssrc/drop-app/src/app/api/transactions/qr-payment/route.tssrc/drop-app/src/app/api/transactions/disclosure/route.tssrc/drop-app/src/lib/db.ts(transaction + bank account operations)
2. Domain Model
2.1 Core Entities
User
├── has many BankAccount (AISP-read from user's real bank)
├── has many Recipient (saved international recipients)
├── has many Transaction
└── has one Merchant (optional — if registered as merchant)
Transaction
├── type: "remittance" | "qr_payment"
├── status: "processing" | "completed" | "failed"
├── sendAmount + sendCurrency (NOK)
├── receiveAmount + receiveCurrency (destination)
├── exchangeRate
├── fee (NOK)
└── links to: Recipient (remittance) OR Merchant (QR)
BankAccount
├── balance (AISP-read cache — NOT Drop-held funds)
├── isPrimary
└── bankName, accountNumber, currency
2.2 Supported Currency Corridors
| Exchange Rate (illustrative) | Fee | ||
|---|---|---|---|
RSD |
11.7 NOK/RSD | 0.5% | |
|
1.04 |
0.5% | |
PLN |
0.41 NOK/PLN | 0.5% | |
|
26.8 NOK/PKR | 0.5% | |
| |||
| 3.45 |||
0.5% |
Purpose:
TODO: 2-3 sentences. What does this service do? What business capability does it own? What is explicitly OUT of scope?
Bounded context:Payments: ThisNOK serviceonly, ownsfee the1%, {{DOMAIN}}instant domain. It is the single source of truth for {{entities owned}}. Other services must NOT directly access this service's database — they must call its API or subscribe to its events.settlement.
2. Service Responsibility & Ownership
This service IS responsible for:
{{Primary responsibility 1}}{{Primary responsibility 2}}{{Primary responsibility 3}}
This service is NOT responsible for:
{{Out-of-scope concern 1 — handled by service X}}{{Out-of-scope concern 2}}
Data ownership:
Owns:{{users, user_profiles, user_preferences tables}}Does NOT own:{{orders (belongs to order-service)}}
3. InterfaceRemittance DefinitionService Design
3.1 RESTFlow
POST Endpoints/api/transactions/remittance
│
├── 1. requireAuth() — verify JWT cookie + session not revoked
├── 2. Rate limit check (10/min per IP)
├── 3. KYC gate — verify user.kyc_status === 'approved'
├── 4. Validate request body
│ ├── amount: 100–50,000 NOK, max 2 decimal places
│ └── recipientId: must belong to current user
├── 5. Load recipient → extract currency (e.g., RSD)
├── 6. Look up exchange rate for currency
├── 7. Calculate fee (0.5% of amount, rounded to 2 decimals)
├── 8. Load bank account (bankAccountId or primary)
├── 9. Verify balance >= (amount + fee)
├── 10. ATOMIC DATABASE TRANSACTION:
│ ├── Debit bank_accounts.balance by (amount + fee)
│ └── INSERT transaction record (status: 'processing')
└── 11. Return 201 with transaction details
3.2 Fee Calculation
const fee = Math.round(amount * 0.005 * 100) / 100; // 0.5%, 2 decimal places
const total = amount + fee;
const receiveAmount = Math.round(amount * exchangeRate * 100) / 100;
3.3 ETA Logic
| EEA countries | "1-2 business days" |
| Non-EEA countries | "2-4 business days" |
Note: Serbia, Bosnia are non-EEA. Poland is EEA. Pakistan, Turkey are non-EEA.
3.4 Transaction Status Flow
processing → completed (when PISP provider confirms settlement)
processing → failed (on PISP rejection or bank rejection)
Current implementation: Status starts as processing. Settlement tracking (webhooks from PISP provider) is pending — requires Open Banking provider integration.
4. QR Payment Service Design
4.1 Flow
POST /api/transactions/qr-payment
│
├── 1. requireAuth() — verify JWT + session
├── 2. Rate limit check (10/min per IP)
├── 3. Validate request body
│ ├── merchantId: must exist
│ └── amount: 1–100,000 NOK, max 2 decimal places
├── 4. Load merchant
├── 5. Get user's primary bank account
├── 6. Calculate fee (1% of amount)
├── 7. Verify balance >= (amount + fee)
├── 8. ATOMIC DATABASE TRANSACTION:
│ ├── Debit bank_accounts.balance by (amount + fee)
│ └── INSERT transaction record (status: 'completed')
└── 9. Return 201 with transaction details
4.2 Fee Calculation
const fee = Math.round(amount * 0.01 * 100) / 100; // 1%, 2 decimal places
4.3 QR Code Format
Merchant QR codes encode: drop://pay/{merchantId}
The mobile app scans this URI, extracts merchantId, and pre-fills the QR payment form.
5. Pre-Payment Disclosure
Endpoint: POST /api/transactions/disclosure
The disclosure endpoint provides full fee transparency BEFORE a payment is initiated, complying with Finansavtaleloven requirements (users must see costs before confirming).
Response includes:
amount— send amountfee— Drop fee (0.5% remittance / 1.0% QR)feePercentage— percentageexchangeRate— NOK to destination currencyreceiveAmount— amount recipient receivesreceiveCurrency— destination currencyestimatedDelivery— ETA stringtotalCost— amount + fee
6. Database Operations
6.1 Atomic Transaction Pattern
All payment operations use database transactions to ensure atomicity:
BEGIN;
UPDATE bank_accounts
SET balance = balance - $1
WHERE id = $2 AND user_id = $3 AND balance >= $1;
INSERT INTO transactions (id, user_id, type, status, send_amount, ...)
VALUES ($1, $2, 'remittance', 'processing', ...);
COMMIT;
If either operation fails, the entire transaction rolls back — preventing partial state (debit without record, or record without debit).
6.2 Balance Check
Balance is checked atomically in the UPDATE statement (WHERE balance >= required_amount). If the UPDATE affects 0 rows, the transaction fails with insufficient_balance error.
6.3 Key Queries
-- Get transaction with exchange rate detail
SELECT t.*, r.name as recipient_name, r.country as recipient_country,
er.rate as exchange_rate
FROM transactions t
LEFT JOIN recipients r ON t.recipient_id = r.id
LEFT JOIN exchange_rates er ON er.currency = r.currency
WHERE t.id = $1 AND t.user_id = $2;
7. Validation Rules
| Field | ||||
|---|---|---|---|---|
(remittance) |
validateAmount() |
| 100–50,000 ||
(QR payment) |
validateAmount() |
| 1–100,000 | max |
|
ownership check |
recipients |
||
|
existence check |
merchants table |
||
|
ownership check |
|||
| |
Internal endpoints (service-to-service only, no external access):
| |
Full API reference: See api-reference.md or {{OpenAPI URL}}
3.2 gRPC Service Definition (if applicable)
// proto/{{service_name}}.proto
syntax = "proto3";
package {{service_name}};
service {{ServiceName}}Service {
rpc Get{{Resource}} (Get{{Resource}}Request) returns ({{Resource}});
rpc List{{Resources}} (List{{Resources}}Request) returns (List{{Resources}}Response);
rpc Create{{Resource}} (Create{{Resource}}Request) returns ({{Resource}});
}
message {{Resource}} {
string id = 1;
string name = 2;
string created_at = 3;
}
message Get{{Resource}}Request {
string id = 1;
}
TODO: Remove or populate gRPC section based on actual communication protocol.
3.3 Events Published
| | | |
| | | |
| | |
Example published event:
{
"specversion": "1.0",
"type": "{{domain}}.{{entity}}.created",
"source": "{{service-name}}",
"id": "evt_01HX7...",
"time": "2024-01-15T10:30:00Z",
"datacontenttype": "application/json",
"data": {
"id": "{{UUID}}",
"{{field}}": "{{value}}"
}
}
Full event schemas: See event-schema-documentation.md
3.4 Events Consumed
| | |
| | |
Consumer group: {{service-name}}-consumer
Idempotency: All handlers are idempotent (duplicate events produce same result).
4. Database
4.1 Technology & Rationale
| |
| |
| |
| |
| |
| |
bank_accounts for current user |
4.28. SchemaError Overview
erDiagram
USERS {
uuid id PK
string email UK
string name
string role
string status
timestamp created_at
timestamp updated_at
timestamp deleted_at
}
USER_PROFILES {
uuid id PK
uuid user_id FK
string avatar_url
string bio
jsonb settings
timestamp updated_at
}
USER_SESSIONS {
uuid id PK
uuid user_id FK
string refresh_token_hash
string ip_address
timestamp expires_at
timestamp created_at
}
USERS ||--o| USER_PROFILES : has
USERS ||--o{ USER_SESSIONS : has
TODO: Update schema to reflect actual tables. Add missing tables.
4.3 Data Ownership Boundaries
Read access:Any service may query via this service's APIWrite access:ONLY this service writes to its tablesDirect DB access:FORBIDDEN for all other services
Cross-service data pattern:
Service A needs user name:
→ GET /users/:id via HTTP (NOT direct DB query)
→ Or subscribe to user.updated events and cache locally
5. DependenciesHandling
5.1 Upstream Services (Services This Depends On)
| |||
| |||
| | | |
5.2 Downstream Services (Services That Depend On This)
| ||
|
5.3 External APIs & Third-Party
| | ||
| |
5.4 Dependency Diagram
graph LR
ThisService["{{service-name}}"]
subgraph "Upstream (depends on)"
AuthService["auth-service"]
ExternalAPI["external-api"]
end
subgraph "Downstream (depended on by)"
OrderService["order-service"]
NotifService["notification-service"]
end
AuthService --> ThisService
ExternalAPI --> ThisService
ThisService --> OrderService
ThisService --> NotifService
6. Deployment Configuration
| |||
Kubernetes manifest location: {{k8s/{{service-name}}/}}
Helm chart: {{charts/{{service-name}}/}}
Docker image: {{registry.domain.com/service-name}}
7. Scaling Strategy
| Trigger | |||
|---|---|---|---|
bad_request |
null/undefined required field | ||
no_bank_account |
User has no linked bank account | ||
insufficient_balance |
balance < (amount + fee) |
||
kyc_required |
kyc_status !== 'approved' |
||
| Recipient not found | 404 | not_found |
Recipient doesn't belong to user |
| Unsupported currency | 422 | validation_error |
No exchange rate for currency |
| Rate limited | 429 | rate_limited |
> 10 req/min per IP |
Stateless confirmation: This service stores NO session state in memory — safe to scale horizontally.
8. Health Check & Readiness Probes
livenessProbe:
httpGet:
path: /health/live
port: 4000
initialDelaySeconds: 30
periodSeconds: 10
failureThreshold: 3
readinessProbe:
httpGet:
path: /health/ready
port: 4000
initialDelaySeconds: 10
periodSeconds: 5
failureThreshold: 3
startupProbe:
httpGet:
path: /health/startup
port: 4000
failureThreshold: 30
periodSeconds: 10
9. SLAAudit CommitmentsTrail
Every
SLAAML breach escalation: Alert → PagerDutymonitoring: {{on-call rotation}}aml_alerts→table Incidentis declaredchecked atfor SLAhigh-value breachtransactions risk.(> NOK 100,000 equivalent per day, per regulatory requirements).
10. MonitoringFuture: &Open AlertingBanking RulesIntegration (PISP)
Current
| |||
| |||
| |||
|
Dashboard:Target architecture (requires Open Banking provider):
- Initiate PISP payment at provider API
- User's actual bank account is debited (not Drop's DB record)
- Provider webhook confirms settlement
- Drop updates transaction status from
→{{https:processingcompleted//monitoring.domain.com/dashboards/service-name}}failed
AISP balance refresh: balance in bank_accounts should be refreshed via AISP API on each login or dashboard load.
11.Related RunbookDocuments
- API Reference
- Backend
location:Architecture
- External
QuickServicesreferenceIntegration - Source:
commonSERVICES.md
Runbook{{https://wiki.domain.com/runbooks/{{service-name}}}}
Approval
| Role | Name | Date | Signature |
|---|---|---|---|
| Author | Platform Architect (AI) | 2026-02-23 | |
| Alem | |||
| Bašić |