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.

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.

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¶

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.