03 — Compliance & Security¶

Bank of Thailand Requirements¶
Payment Systems Act B.E. 2560 (PSA 2017)¶
The Act requires licensed payment service providers to ensure real-time settlement finality, full transaction traceability, and system resilience.
| Requirement | Implementation | Where |
|---|---|---|
| Real-time settlement finality | TigerBeetle atomic transfers — PENDING → POST is a single atomic operation. No intermediate states visible to users. | TigerBeetle |
| Transaction traceability | Every status change is recorded in pm.intent_event with timestamp, actor, and reason. Raw PSP responses stored in payload JSONB. |
pm.intent_event |
| Unique transaction reference | Each intent has an immutable intentId (UUID) and an idempotencyKey. The trace_id = intent_id in every log line and OTLP span. |
PM + all logs |
| System resilience | OutboxWorker crash recovery, Status Poller, TigerBeetle pending timeout, Redis Streams DLQ for external adapters. | PM background workers |
| Audit log retention | pm.intent_event and pm.tx_history are append-only. TigerBeetle transfers are physically immutable. |
PostgreSQL + TigerBeetle |
AML / CTF — Anti-Money Laundering¶
| Requirement | Implementation | Where |
|---|---|---|
| Transaction limits | Daily and monthly caps enforced in the Limits Engine before funds are frozen. Limit exceeded → 429, no TB operation created. | pm.payment_limit + Redis |
| User risk classification | Auth Center passes metadata.tags (kyc_verified, vip, merchant_share, …) with every intent. Fee rules and limits use tagsInclude/tagsExclude to apply different rules to different user segments. |
Rule Engine + Limits Engine |
| Suspicious activity detection | Limits Engine records running totals in Redis with day-level precision. Breaches generate structured log entries with intent_id and user_id for ops review. Phase 2C: dedicated reconciliation alerts table. |
Redis + pino structured logs |
| Full payment history | pm.tx_history stores every debit, credit, and fee row with direction, amount, currency, timestamp, and intent reference. Queryable for any user, any time range. |
pm.tx_history |
PDPA — Personal Data Protection Act¶
A critical architectural decision: PM contains zero PII.
Auth Center (Serverpod / public schema):
✓ Full name, phone number, national ID hash
✓ KYC documents (encrypted, Garage S3)
✓ Device fingerprints, biometric keys
Payment Manager (pm schema):
✓ userId (integer — an opaque reference)
✓ amounts, currencies, timestamps
✗ No names, no phone numbers, no documents
Why this matters for PDPA:
- A PDPA erasure request only affects the public schema in Auth Center. PM data does not need to change.
- A data breach in PM exposes transaction amounts and timestamps — not personal identifiable information.
- KYC documents are stored encrypted in a separate Garage S3 bucket with independent access controls.
display name fields (fromName, toName, comment) in B6.1 are user-provided display labels — not resolved PII. They are passed by the caller (Auth Center) and treated as payment memo text.
Consumer Protection¶
| Requirement | Implementation |
|---|---|
| No duplicate charges | idempotencyKey is unique per payment. Submitting the same key twice returns the original result — no second charge, no error. |
| Funds safety during failures | TigerBeetle PENDING holds funds safely. They are only released on explicit POST (success) or VOID (failure/cancel). Auto-expire (300s timeout) applies to internal channels as a failsafe. |
| Transparent fee disclosure | POST /intents/quote returns fee breakdown before the user commits. preFeeAmount and postFeeAmount are returned separately in every intent response so the app can show exactly who pays what. |
| Cancellation | Intents in CREATED status can be cancelled by the user. Funds are only frozen at AUTHORIZED — no lock-in before confirmation. |
Strong Customer Authentication (BOT / NatBank)¶
| Requirement | Status | Implementation |
|---|---|---|
| PIN 6 digits | ✅ Enforced | Required at app launch, stored hashed, never transmitted |
| Biometric authentication | ✅ Enforced | Face ID / fingerprint for transaction confirmation |
| Auto-lock after 5 minutes idle | ✅ Enforced | Dart timer in Flutter; reactivation requires PIN re-entry |
| Device binding | ✅ Enforced | JWT tied to device key. Token on new device requires re-authentication |
| Short-lived JWT | ✅ Enforced | Access tokens expire in 15 minutes; refresh rotation prevents token theft |
Security Architecture¶
HMAC Authentication — Service-to-Service¶
Every call into PM is signed. There are no "internal trusted" callers.
Signing string:
X-Timestamp + "\n" + METHOD + "\n" + PATH + "\n" + sha256_hex(body)
Headers:
X-Service-Id: auth-center | nginx-gateway | miniapp-tours | ...
X-Timestamp: unix seconds (±60s window — replay protection)
X-Signature: hex(hmac_sha256(signing_string, service_secret))
X-User-Id: 12345 (required for user-context calls)
Registered service principals:
| Service ID | Who | Permissions |
|---|---|---|
auth-center |
Serverpod | All user payment types, userId required |
nginx-gateway |
nginx | Read-only (balance, history) |
miniapp-{id} |
Merchant backend | MINIAPP_CHARGE, MINIAPP_CREDIT for own merchant only |
internal-pm |
PM cron jobs | System movements, no X-User-Id |
Secrets are stored hashed (bcrypt) in pm.service_key. Rotation = new row, old row deactivated.
Network Isolation¶
POST /intentsis blocked at nginx on the public ingress — mini-app backends can only call it from the internal network- PSP adapters have no direct access to TigerBeetle — only through PM
- Admin Panel has no direct access to PM's pm schema — only through Auth Center / PM API
Observability — Every Transaction is Traceable¶
trace_id = intent_id (set at the start of POST /intents)
Propagation:
Serverpod → PM: X-Trace-Id header (W3C traceparent)
PM → TigerBeetle: user_data_128 = intentId (on every transfer)
PM → Redis Streams: trace_id field in every message
PM → logs: intent_id in every pino log line
Export: OTLP → Jaeger / Grafana Tempo
Full chain visible in one trace:
Flutter request → Auth Center → PM → TigerBeetle → IPPS → OutboxWorker