01 — System Architecture¶

Service Layers¶
1. Client Layer¶
| Client | How it connects | Purpose |
|---|---|---|
| Flutter App | HTTPS → nginx → Auth Center | Mobile wallet — all user actions |
| Admin Panel (SvelteKit) | HTTPS → nginx → Auth Center | Fee rules, user management, monitoring |
| Mini-App Backends | HMAC → PM directly (internal network) | Merchant charges/credits — Phase 2A |
Key point: Flutter never talks to the Payment Manager directly. nginx enforces JWT validation on every request before it reaches PM.
2. Public Gateway — nginx¶
nginx is the only public entry point. It does two things:
- Routing:
/api/pm/*requests (balance, history) go directly to PM after JWT validation. All other requests go to Auth Center. - Auth guard:
auth_requestsub-request to Serverpod validates the JWT and injectsX-User-Idheader before forwarding. PM never trusts the client's claimed identity.
3. Auth Center (Serverpod / Dart)¶
Remains responsible for everything identity-related:
- JWT issuance and refresh
- KYC verification workflow
- PII storage (names, phone numbers, documents)
- Device binding (biometrics + PIN)
- Push notifications
- Mini-App catalog
What Auth Center no longer does: payment logic, ledger operations, balance storage. These moved entirely to PM.
Auth Center calls PM via POST /intents with HMAC authentication and the user's ID in the X-User-Id header.
4. Payment Manager (Node.js / Fastify)¶
The new standalone payment service. All financial logic lives here.
Internal modules¶
| Module | Responsibility |
|---|---|
| HMAC Middleware | Validates every incoming request — no trusted callers |
| Router | Maps (operationType, amount) to a channel name via pm.payment_route table |
| Saga Orchestrator | Drives the intent state machine: CREATED → VALIDATED → AUTHORIZED → SETTLED |
| Rule Engine (CEL) | Evaluates fee rules — PRE fees (sender pays) and POST fees (recipient pays) |
| Limits Engine | Enforces daily/monthly transaction caps per user, cached in Redis |
| Channels | Pluggable payment channel implementations (see below) |
| Outbox Worker | Background loop — finalises async payments, writes history, notifies clients |
| Status Poller | Crash recovery — detects stuck payments and resumes them |
Channel architecture¶
Adding a new payment method = creating one new channel file. The core Saga, Rule Engine, and Limits Engine never change.
| Channel | Operation types | Settlement |
|---|---|---|
INTERNAL_P2P |
P2P_TRANSFER | Instant (same HTTP request) |
IPPS_TRANSFER |
IPPS_WITHDRAWAL, THAI_QR_PAY | Async — OutboxWorker |
QP_TRANSFER (Phase 2B) |
QP_TOPUP | Async via Redis Streams |
| future | VISA_DIRECT, SWIFT, MINIAPP_* | Pluggable |
5. TigerBeetle — Financial Ledger¶
TigerBeetle is a purpose-built financial database. It replaces blnkfinance as the source of truth for all balances and transfers.
Why TigerBeetle:
| Property | What it means |
|---|---|
| Atomic linked transfers | A P2P transfer of sender → transit → recipient either fully succeeds or fully fails. No partial state. |
| Immutable append-only | No UPDATE or DELETE on transfers. The ledger history is permanent. |
| Two-phase transfers | Funds can be frozen (PENDING) then confirmed (POST) or cancelled (VOID). Perfect for async PSP flows. |
| Deterministic account IDs | Account ID = uuidv5("onewallet-tb", account_name). IDs are verifiable at any time without extra state. |
transit.balance = 0 invariant |
Every batch of linked transfers is balanced. The transit account always returns to zero after settlement — enforced mathematically by TB. |
6. Data Layer¶
| Store | What lives there |
|---|---|
PostgreSQL pm schema |
Intent records, fee rules, payment routes, transaction history, outbox events, PSP mapping, account balance snapshots |
PostgreSQL public schema |
Users, profiles, KYC, devices — Serverpod only, PM never writes here |
| Redis / Valkey | Pub/sub for real-time intent status (intent.{id}), Redis Streams for PSP job queues (Phase 2B), daily limits cache |
Key Design Decisions¶
Single entry point — POST /intents¶
Every payment from every caller goes through one endpoint with one auth scheme. No special internal routes, no bypass paths.
HMAC for all service-to-service calls¶
Every caller — Auth Center, nginx gateway, mini-app backends — must sign requests with a registered secret. The signing string includes timestamp, method, path, and body hash, preventing replay attacks.
PII never reaches PM¶
PM receives only userId (an integer). Names, phone numbers, and KYC data stay in Serverpod's public schema. This limits PDPA exposure and simplifies the payment schema.
metadata.tags — risk classification without cross-schema reads¶
Auth Center passes user context (e.g., ["kyc_verified", "vip"]) in the metadata.tags field of POST /intents. PM uses these tags to apply the correct fee rules and limits without ever reading from public.*.