Перейти к содержанию

02 — Payment Flows

Two fundamental payment patterns exist in the system: instant settlement (same HTTP request) and async settlement (OutboxWorker completes later).


Pattern A — Instant P2P Transfer

User sends money to another OneWallet user. Funds move in < 500ms.

P2P Transfer Flow

What happens, step by step

Step What Why
1. Intent created PM stores the payment request with status CREATED Creates the idempotency anchor — same key = same result, always
2. VALIDATED Fees calculated, daily limits checked Rule Engine returns PRE fee (added to sender's debit) and POST fee (deducted from recipient's credit)
3. Funds frozen TigerBeetle PENDING transfers created — user.from → transit → user.to Atomic: all three transfers succeed together or none do
4. Funds posted TigerBeetle POST_PENDING in the same HTTP request transit.balance returns to zero — invariant guaranteed by TB math
5. History written tx_history + account_balance snapshot + intent_event audit entry — all in one PostgreSQL transaction If this commit fails, TB pending expires automatically and funds return
6. Response { status: "SETTLED", requiresMonitoring: false } Flutter shows confirmation immediately

Fee model — PRE vs POST

PRE fee (sender pays on top):
  User sends ฿1,000 → debit = ฿1,005 (฿5 fee added)
  Recipient receives ฿1,000

POST fee (recipient pays from amount):
  User sends ฿1,000 → debit = ฿1,000
  Recipient receives ฿950 (฿50 deducted)

Fee rules are configured in pm.fee_rule and can use CEL expressions. Changes take effect immediately without a deployment.


Pattern B — PromptPay Withdrawal (Async)

User withdraws to a PromptPay recipient on the BOT national network. Settlement confirmed asynchronously in 1–3 seconds.

PromptPay Withdrawal Flow

What happens, step by step

Step What Why
1–2. Validated Same fee + limits check as P2P Consistent rules regardless of channel
3. Funds frozen TigerBeetle PENDING — timeout = 0 External PSPs: funds never auto-expire. We control release explicitly.
4. IPPS query PM calls BOT IPPS network to verify recipient Returns lookupRef + receiverName + fee
5. IPPS confirm PM confirms the transfer on IPPS Returns responseId + settlementDate
6. Outbox record outbox_event { action: "post_pending" } written Durable instruction that survives a PM crash
7. Response { status: "AUTHORIZED", requiresMonitoring: true } PM responds immediately; Flutter starts monitoring
8. Flutter monitors Serverpod subscribes to Redis intent.{id} Real-time WebSocket-style stream, no polling
9. OutboxWorker Posts TB pending → writes history → publishes to Redis Happens ~1s later in background loop
10. Confirmed Flutter receives { status: "SETTLED" } User sees final confirmation with reference number

Real-time monitoring — requiresMonitoring

The requiresMonitoring flag tells Flutter whether to open a live status stream:

requiresMonitoring: false  →  Already SETTLED — show result immediately
requiresMonitoring: true   →  AUTHORIZED — open stream, wait for OutboxWorker

Serverpod opens a dedicated Redis SUBSCRIBE connection per stream. When the OutboxWorker publishes the final status, Serverpod forwards it to Flutter and closes the connection.


Intent Lifecycle

Intent Lifecycle

Terminal states

State Meaning Funds
SETTLED Payment complete Moved to recipient / external
FAILED Payment did not complete Returned to sender (TB void)
CANCELLED User cancelled before authorisation Never left sender

Crash Safety

If PM dies at any point during a payment:

PM died at Recovery mechanism
Before TB pending No funds were moved — safe to retry with same idempotency key
After TB pending, before PG commit Internal channels: TB pending expires (300s) → funds auto-return. External channels: Status Poller detects stale AUTHORIZED intent → inserts outbox_event → OutboxWorker resumes
After PG commit, before TB post OutboxWorker retries — TB returns already_posted (idempotent, not an error)
After TB post, before PG SETTLED OutboxWorker retries PG update — idempotent

No payment is ever lost. The combination of TigerBeetle's atomic operations, the outbox pattern, and the Status Poller covers every crash scenario.