Skip to main content

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 | Approved Reviewers: {{REVIEWERS}}Alem Bašić (CEO)

Document History

Version Date Author Changes
0.1 {{DATE}}2026-02-23 {{AUTHOR}}Platform Architect (AI) Initial draft from source code analysis

1. Service Overview

The Payment Service is Drop's core business logic module, responsible for:

  1. Remittance — international money transfers from Norway to 5 corridors (Serbia, Bosnia, Poland, Pakistan, Turkey)
  2. 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.ts
  • src/drop-app/src/app/api/transactions/qr-payment/route.ts
  • src/drop-app/src/app/api/transactions/disclosure/route.ts
  • src/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

/context name}} Name}} 3.45
PropertyDestination ValueCurrencyExchange Rate (illustrative)Fee
Service nameSerbia {{service-name}}RSD11.7 NOK/RSD0.5%
Bounded contextBosnia {{DomainBAM 1.04 boundedNOK/BAM 0.5%
RepositoryPoland {{https://github.com/org/service-name}}PLN0.41 NOK/PLN0.5%
Owner teamPakistan {{TeamPKR 26.8 NOK/PKR0.5%
On-callTurkey {{PagerDuty rotation / team contact}}
RunbookTRY {{https://wiki.domain.com/runbooks/service-name}}
Tech stackNOK/TRY {{Node.js 20 + NestJS + PostgreSQL + Redis}}0.5%

Purpose:

QR

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

API
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

MethodRecipient country PathETA
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 amount
  • fee — Drop fee (0.5% remittance / 1.0% QR)
  • feePercentage — percentage
  • exchangeRate — NOK to destination currency
  • receiveAmount — amount recipient receives
  • receiveCurrency — destination currency
  • estimatedDelivery — ETA string
  • totalCost — 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

100–50,000max 1–100,000maxinMustMust
Field DescriptionValidation AuthRule
GETamount (remittance)validateAmount() /{{service}}/health HealthNOK, check None2 decimal places
GETamount (QR payment)validateAmount() /{{service}}/{{resource}} ListNOK, {{resources}} Bearer2 JWTdecimal places
GETrecipientId /{{service}}/{{resource}}/:idownership check GetMust byexist ID Bearerrecipients JWTtable for current user
POSTmerchantId /{{service}}/{{resource}}existence check Create Bearerexist JWTin merchants table
PATCHbankAccountId /{{service}}/{{resource}}/:idownership check Update Bearer JWT
DELETE/{{service}}/{{resource}}/:idDeleteBearer JWT

Internal endpoints (service-to-service only, no external access):

MethodPathDescriptionAuth
GET/internal/{{resource}}/:idBulk lookup by IDsService API key

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

Event TypeTriggerTopic / QueueConsumer(s)
{{domain}}.{{entity}}.createdEntity created{{topic-name}}{{service-a, service-b}}
{{domain}}.{{entity}}.updatedEntity updated{{topic-name}}{{service-a}}
{{domain}}.{{entity}}.deletedSoft delete{{topic-name}}{{service-b}}

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

Event TypeSource ServiceHandler Action
{{domain}}.{{entity}}.created{{source-service}}{{Action this service takes}}
{{domain}}.{{entity}}.deleted{{source-service}}{{Action — e.g., cascade delete}}

Consumer group: {{service-name}}-consumer Idempotency: All handlers are idempotent (duplicate events produce same result).


4. Database

4.1 Technology & Rationale

PropertyValue
Database{{PostgreSQL 16}}
ORM{{Prisma 5}}
Rationale{{Why this DB was chosen}}
Hosting{{AWS RDS / Supabase / Self-hosted}}
Replication{{1 primary + 2 read replicas}}
Backup{{Daily snapshot + WAL archiving}}
EncryptionAt rest andexist in transitbank_accounts for current user

4.2

8. 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 API
  • Write access: ONLY this service writes to its tables
  • Direct 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)

ServiceError PurposeHTTP Status CriticalityFallback
{{auth-service}}JWT validationCriticalCache valid tokens 5 min
{{notification-service}}Send emailsNon-criticalQueue for retry
{{{{EXTERNAL_API}}}}{{Purpose}}{{Critical/Non-critical}}{{Fallback strategy}}

5.2 Downstream Services (Services That Depend On This)

ServiceHow it uses this serviceImpact if this service is down
{{order-service}}Validate user exists before creating orderCannot create orders
{{notification-service}}Resolve user email for deliveryCannot send user notifications

5.3 External APIs & Third-Party

ServicePurposeRate LimitCredentials
{{SendGrid}}Transactional email100 req/sVault: sendgrid/api-key
{{Stripe}}Payment processingVault: stripe/secret-key

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

PropertyDevStagingProduction
Replicas12{{min: 3, max: 10}}
CPU request100m250m500m
CPU limit500m1000m2000m
Memory request128Mi256Mi512Mi
Memory limit512Mi1Gi2Gi
Port400040004000

Kubernetes manifest location: {{k8s/{{service-name}}/}} Helm chart: {{charts/{{service-name}}/}} Docker image: {{registry.domain.com/service-name}}


7. Scaling Strategy

DimensionStrategyCode Trigger
HorizontalMissing (replicas)required fields HPA: CPU > 70% OR RPS > 1000400 Automaticbad_requestnull/undefined required field
VerticalNo (resources)bank account VPA recommendations reviewed monthly400 Manualno_bank_accountUser has no linked bank account
DatabaseInsufficient balance Read replicas for SELECT queries402 Manualinsufficient_balancebalance < (amount + fee)
CacheKYC not approved Redis Cluster when > 10GB RAM403 Manualkyc_requiredkyc_status !== 'approved'
Recipient not found404not_foundRecipient doesn't belong to user
Unsupported currency422validation_errorNo exchange rate for currency
Rate limited429rate_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

transactioncreatesanauditlog
INSERT 
INTO user_id, details) 'transaction_created', 'transaction', $detailsJson);
Metric Target Measuremententry:

Window
Availability99.9%audit_log (8.7haction, downtime/year)Rollingresource_type, 30resource_id, days
P50 response time< 50ms1 hour
P95 response time< 200ms1 hour
P99 response time< 500ms1 hour
Error rateVALUES (5xx)<$userId, 0.1%1$txId, hour

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

implementation:balanceistrackedinown bank_accounts.balance),
Metric Threshold AlertDrop's Severity Channel
Error ratedatabase (5xx) >debited 1% for 5 minP1PagerDuty
P99 latency> 1s for 5 minP2Slack #alerts
CPU utilization> 85% for 10 minP3Slack #alerts
Memory utilization> 80%P3Slack #alerts
DB connection pool> 80%P2PagerDuty
Queue depth> 10,000 itemsP2Slack #alerts
atomically.

Dashboard:Target architecture (requires Open Banking provider):

  1. Initiate PISP payment at provider API
  2. User's actual bank account is debited (not Drop's DB record)
  3. Provider webhook confirms settlement
  4. Drop updates transaction status from {{https:processing → completed//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.



Approval

Alem
Role Name Date Signature
Author Platform Architect (AI) 2026-02-23
Service OwnerReviewer
ArchitectApprover
SRE LeadBašić