IPPS / PromptPay External Transfer¶
Overview¶
The IPPS transfer flow sends funds from a OneWallet user to an external Thai bank account or PromptPay-registered mobile number via the IPPS PPXC API. Unlike internal P2P, this flow is asynchronous: Payment Manager places a TigerBeetle pending hold, then an in-process psp-worker drives the two-step IPPS state machine (query → confirm). Settlement happens when the outbox worker finalizes the pending TB transfer after IPPS confirms.
Phase 1 architecture: PSP logic runs in-process inside PM as a worker role (WORKER_ROLES=psp-worker). There is no separate IPPS adapter service; the IppsDriver makes HTTP calls directly to the IPPS API. PostgreSQL (pm.psp_tx_map + FOR UPDATE SKIP LOCKED) is the coordination queue between API and worker roles. Redis is used only for final status pub/sub to Flutter — not as a job queue.
Phase 2B (planned): IPPS logic moves to an autonomous adapter process communicating via stream.ipps.jobs / stream.ipps.results Redis Streams.
Participants¶
| Service | Role in This Flow |
|---|---|
| Flutter App | Initiates transfer; receives status via Redis pub/sub or polls GET /intents/:id |
| Auth Center (Serverpod) | Authenticates user; calls POST /intents on PM with HMAC; subscribes to intent.{id} for streamStatus() |
| nginx | Routes /api/pm/* to PM after auth_request validation |
PM API (WORKER_ROLES=api) |
Receives POST /intents, runs saga (CREATED→VALIDATED→AUTHORIZED), creates TB pending + psp_tx_map(NEW), returns 201 AUTHORIZED |
PM psp-worker (WORKER_ROLES=psp-worker) |
Polls pm.psp_tx_map; drives IPPS state machine (query→confirm→inquiry); writes outbox_event |
PM outbox-worker (WORKER_ROLES=outbox-worker) |
Polls pm.outbox_event; executes TB post_pending or void_pending; writes tx_history; publishes Redis SETTLED/FAILED |
| TigerBeetle | Holds sender funds (pending, tbPendingTimeout=0); finalizes on post-pending; releases on void-pending |
| PostgreSQL | pm.intent, pm.psp_tx_map (PSP state machine), pm.outbox_event, pm.tx_history |
| IPPS PPXC API | External PromptPay gateway: query (recipient lookup) + confirm (execute transfer) + inquiry (recovery) |
| Redis (pub/sub) | Best-effort: PUBLISH intent.{id} to Flutter after SETTLED/FAILED and after IPPS query (intermediate display-name update) |
Diagram¶

Prerequisites¶
- Sender is authenticated (valid JWT/session in Auth Center)
- Sender has completed KYC — external payments require
kyc_verifiedtag inmetadata.tags - Sender's IPPS wallet is registered:
pm.tb_account_map.ipps_wallet_id IS NOT NULL - Sender's TigerBeetle account
user.{senderId}.THBexists inpm.tb_account_map - System account
system.nostro.ipps.THBexists inpm.tb_account_map - Payment route for
IPPS_WITHDRAWAL/THAI_QR_PAY+ amount range is active inpm.payment_route - Sender's available balance ≥ transfer amount + PRE fees (enforced by TigerBeetle at pending creation)
operationTypes for IPPS¶
| operationType | IPPS Tag | Receiver types |
|---|---|---|
IPPS_WITHDRAWAL |
Tag 29 (Fund-Out) | MSISDN, NATID, EWALLETID, BANKAC |
THAI_QR_PAY |
Tag 30 (Bill Payment) | BILLERID |
Both route to IPPS_TRANSFER channel via pm.payment_route.
Steps¶
Step 1: User Initiates Transfer¶
Service: Flutter App
Action: User selects recipient (phone / PromptPay ID / QR code), enters amount, confirms. App generates an idempotencyKey and calls Auth Center.
Step 2: Auth Center Forwards Intent to PM¶
Service: Auth Center (Serverpod)
Action: Calls POST /intents on Payment Manager with HMAC authentication.
POST /intents
X-Service-Id: auth-center
X-Timestamp: <unix epoch>
X-Signature: <hmac-sha256>
X-User-Id: <senderId>
{
"idempotencyKey": "<uuid>",
"operationType": "IPPS_WITHDRAWAL",
"amount": 50000,
"currency": "THB",
"externalRef": "0851728106",
"fromName": "Somchai K.",
"live": true,
"metadata": {
"tags": ["kyc_verified"],
"ipps": { "receiverType": "MSISDN" }
}
}
channel is NOT sent by the caller — PM resolves it from pm.payment_route via (operationType, amount).
Step 3: PM — Idempotency Check¶
Service: Payment Manager (API)
Action: Queries pm.intent by (idempotency_key, service_id). If found, returns original response.
Step 4: PM — Route Resolution¶
Service: Payment Manager (API)
Action: resolveChannel('IPPS_WITHDRAWAL', 50000) → queries pm.payment_route → channel = 'IPPS_TRANSFER'.
Step 5: Saga CREATED → VALIDATED¶
Service: Payment Manager (API)
Action:
1. INSERT pm.intent (status='CREATED')
2. PRE fees calculated (sender-paid, included in TB hold)
3. POST fees calculated (recipient-deducted at settle)
4. Limits check against pm.payment_limit + Redis daily cache
5. Fee TB accounts resolved
6. INSERT pm.intent_event (VALIDATED)
Step 6: Validate IPPS Wallet Registration¶
Service: Payment Manager (API)
Action: SELECT ipps_wallet_id FROM pm.tb_account_map WHERE user_id = :senderId AND account_type = 'user'
If ipps_wallet_id IS NULL → return HTTP 400 IPPS_NOT_REGISTERED. No TB transfer created.
Step 7: TigerBeetle PENDING Hold (AUTHORIZED)¶
Service: Payment Manager (API) → TigerBeetle
Action: Creates PENDING linked batch. tbPendingTimeout = 0 — no automatic expiry.
// amount = intent.amount + preFees.totalFee
createTransfers([
{ flags: PENDING|LINKED, debit: user.{id}.THB, credit: system.transit.ipps.THB, amount: total },
{ flags: PENDING|LINKED, debit: transit, credit: system.nostro.ipps.THB, amount: net },
// PRE fee splits (if any):
{ flags: PENDING, debit: transit, credit: system.revenue.THB, amount: fee },
])
user_data_128 = intentId on every transfer — links TB record to pm.intent without extra state.
Step 8: psp_tx_map Row Inserted¶
Service: Payment Manager (API) Action: In the same DB transaction as the AUTHORIZED status update:
UPDATE pm.intent SET status='AUTHORIZED', tb_transfer_ids=$1 WHERE id=$2;
INSERT INTO pm.intent_event (intent_id, status_from, status_to) VALUES (:id, 'VALIDATED', 'AUTHORIZED');
INSERT INTO pm.psp_tx_map (intent_id, psp_name, state) VALUES (:id, 'IPPS', 'NEW');
Step 9: PM Returns 201 AUTHORIZED¶
Service: Payment Manager (API) Response:
{
"intentId": "<uuid>",
"status": "AUTHORIZED",
"channel": "IPPS_TRANSFER",
"preFeeAmount": "500",
"postFeeAmount": "0",
"requiresMonitoring": true,
"createdAt": "2026-04-30T10:00:00.000Z"
}
requiresMonitoring: true → Flutter subscribes to intent.{id} via Auth Center streamStatus().
Step 10: psp-worker Picks Up the Job¶
Service: PM psp-worker (in-process, WORKER_ROLES=psp-worker)
Action: Polls pm.psp_tx_map in a loop. Pickup query uses FOR UPDATE SKIP LOCKED with write-ahead state flip:
-- Atomic claim + state flip in one TX:
UPDATE pm.psp_tx_map
SET state='QUERY_PENDING', leased_by=:workerId, leased_at=now()
WHERE id IN (
SELECT id FROM pm.psp_tx_map
WHERE psp_name='IPPS' AND state='NEW'
ORDER BY created_at LIMIT 10
FOR UPDATE SKIP LOCKED
)
RETURNING *;
After COMMIT, psp-worker calls IppsDriver.
Step 11: psp-worker — IPPS Query¶
Service: PM psp-worker → IPPS API
Action: IppsDriver.query() — recipient lookup.
POST /wallet-transfer/query
x-api-key: <key>
{
"walletId": "<senderIppsWalletId>",
"amount": 500.00,
"receiverType": "MSISDN",
"value": "0851728106"
}
Response: { rqUID, lookupRef, receiverBank, receiverNameEn, receiverDisplayName, ... }
On success → psp_tx_map.state = 'QUERIED', saves lookupRef, queryRqUid, receiverDisplayName.
PM publishes intermediate status to Redis (best-effort): PUBLISH intent.{id} { status:'AUTHORIZED', toName: receiverDisplayName } — Flutter updates "Recipient: นายสมชาย..." display.
Note: Query is NOT idempotent (Q-IPPS-5 — fresh lookupRef on each call), but safe to repeat since no money moves.
Step 12: psp-worker — IPPS Confirm¶
Service: PM psp-worker → IPPS API
Action: Write-ahead CONFIRM_PENDING, then call IppsDriver.confirm():
POST /wallet-transfer/confirm
x-api-key: <key>
{ "lookupRef": "<ref>", "walletId": "<senderWalletId>", ... }
Response: { rqUID, responseId, settlementDate, feeAmount, ... }
On success → psp_tx_map.state = 'CONFIRMED', saves confirmRqUid, responseId, settlementDate.
Then (in one DB TX):
UPDATE pm.psp_tx_map SET state='CONFIRMED', confirm_rq_uid=:rqUID, ... WHERE id=:id;
INSERT INTO pm.outbox_event (intent_id, action='post_pending', status='pending');
INSERT INTO pm.intent_event (intent_id, status_from='CONFIRM_PENDING', status_to='CONFIRMED');
CRITICAL — Confirm is NOT idempotent (Q-IPPS-2, SIT-confirmed 2026-04-29): two confirms with the same lookupRef create two independent transactions → double-charge. Worker NEVER re-confirms after unknown outcome — only uses inquiry.
Step 13: outbox-worker — TB Post-Pending (SETTLED)¶
Service: PM outbox-worker (WORKER_ROLES=outbox-worker)
Action: Polls pm.outbox_event WHERE status='pending'. For action='post_pending':
// POST_PENDING batch — amount=AMOUNT_MAX means "post in full" (TB ≥ 0.16)
createTransfers([
{ flags: POST_PENDING|LINKED, pending_id: ids[0], amount: AMOUNT_MAX }, // user → transit full
{ flags: POST_PENDING|LINKED, pending_id: ids[1], amount: AMOUNT_MAX }, // transit → nostro.ipps
{ flags: POST_PENDING, pending_id: ids[N], amount: AMOUNT_MAX }, // last PRE fee split
// POST fee (if any) — direct transfer from transit surplus:
{ flags: 0, debit: transit, credit: system.revenue.THB, amount: postFee },
])
On success — one DB transaction:
UPDATE pm.intent SET status='SETTLED', to_name=:receiverDisplayName, updated_at=now();
-- writeSettlement: INSERT pm.tx_history (DEBIT sender, CREDIT nostro, CREDIT each fee)
UPDATE pm.outbox_event SET status='processed';
INSERT INTO pm.intent_event (...SETTLED);
Then (best-effort): PUBLISH intent.{id} { status: 'SETTLED', settlementDate: 'YYYYMMDD' }
Step 14: Flutter Receives SETTLED¶
Service: Flutter App ← Auth Center ← Redis
Action: Auth Center's streamStatus() receives the Redis publish and pushes to Flutter. App displays "Transfer successful" with settlementDate.
If live=true was NOT set or Redis is unavailable → Flutter polls GET /intents/:id on a timer.
psp_tx_map State Machine¶
NEW
→ QUERY_PENDING (write-ahead before IPPS query)
→ QUERIED (query ok, lookupRef saved)
→ QUERY_PENDING (transient error — retry on next lease expiry)
→ FAILED (permanent IPPS error → outbox void_pending)
QUERIED
→ CONFIRM_PENDING (write-ahead before IPPS confirm)
→ CONFIRMED (confirm ok, confirmRqUid saved → outbox post_pending)
→ INQUIRING (confirm unknown outcome, lease expired → call inquiry)
→ FAILED (permanent IPPS error → outbox void_pending)
INQUIRING
→ CONFIRMED (inquiry SUCCESS → outbox post_pending)
→ FAILED (inquiry FAILED → outbox void_pending)
→ INQUIRING (inquiry PENDING — retry on lease expiry)
→ MANUAL_REVIEW (max retries exceeded, or 404 on inquiry)
MANUAL_REVIEW ← terminal until ops force-resolves
CONFIRMED ← terminal, outbox-worker posts TB
FAILED ← terminal, outbox-worker voids TB
Lease expiry: PSP_LEASE_SEC=10s (first attempt), PSP_RETRY_LEASE_SEC=30s (subsequent). FOR UPDATE SKIP LOCKED prevents two workers taking the same row.
Recovery Scenarios¶
Crash Between Confirm Response and Save (Orphan-rqUID → MANUAL_REVIEW)¶
PM crashed after receiving IPPS confirm HTTP response but before saving confirmRqUid to psp_tx_map. After restart:
- Worker sees state=CONFIRM_PENDING, confirm_rq_uid IS NULL, retry_count > 0
- inquiry(confirmRqUid) is impossible — confirmRqUid is unknown
- IPPS inquiry only accepts confirm-level rqUID (Q-IPPS-13, NEGATIVE 2026-04-29)
- Worker → state = MANUAL_REVIEW, last_error = 'orphan_lookup_ref'
- Ops: contact IPPS support with lookupRef, get actual outcome, force-resolve via admin tool
Confirm Fails with Network Error → Inquiry¶
Worker receives transport error on confirm → classifies as 'inquire' → waits for lease expiry → calls inquiry with confirmRqUid:
- SUCCESS → state=CONFIRMED, outbox post_pending
- FAILED → state=FAILED, outbox void_pending
- PENDING → state=INQUIRING, retry on lease expiry
- 404 → confirmRqUid not found on IPPS → MANUAL_REVIEW
PM Restart with AUTHORIZED Intent (No outbox_event)¶
Startup reconciler on PM boot:
- IPPS_TRANSFER has tbPendingTimeout=0 — never auto-voids
- Stale AUTHORIZED intent older than 24h with no outbox_event → pm.alert { type: 'stale_external_intent' } → ops review
- NOT auto-voided (unlike INTERNAL_P2P which gets voided on startup)
OutboxWorker Crash Mid-Settlement¶
TB post_pending is idempotent — pending_transfer_already_posted is treated as success. Worker resumes safely on restart.
Error Cases¶
| Scenario | Where Detected | Outcome |
|---|---|---|
| Recipient not registered on PromptPay | IPPS Query (Step 11) | state=FAILED + outbox void_pending → intent FAILED |
| IPPS wallet not registered for sender | PM Saga (Step 6) | HTTP 400 IPPS_NOT_REGISTERED; no TB transfer created |
| Insufficient balance | TigerBeetle PENDING (Step 7) | TB error exceeds_credits → HTTP 400; no psp_tx_map row created |
| IPPS server error (5xx/network) on confirm | psp-worker (Step 12) | classifyError → 'inquire' → recovery via inquiry |
| Confirm crash between response and save | psp-worker | MANUAL_REVIEW — orphan-rqUID, ops must resolve |
| Bank E005/E007 (funds/limits at IPPS) | IPPS Confirm | classifyError → 'fail' → state=FAILED → TB void |
| SIT 5-tx daily quota exhausted | IPPS (Step 11 or 12) | 'retry' class error → cycles until quota resets; dev can use npm run ipps-smoke |
SIT Environment Notes¶
- 5 transactions per day — hard limit shared across the team; use
npm run ipps-smokesparingly - Inquiry returns canned mock — SIT inquiry responses are not real state; use only for format checks
- Display name has debug suffix:
"นายพร้อมเพย์ เทส : CreditLookupResponse : LOOKUP_SUCCESS"— written verbatim tointent.to_nameandtx_history.to_name; production will return clean names settlementDatealways empty in SIT — blank even after successful confirm (Q-IPPS-9, pending IPPS clarification)- IPPS SIT base URL:
https://promptpay-api-sit.ipps.cloud
Related Specs¶
- 2026-04-26-payment-manager-v2-master.md — master PM design, channel model, fee PRE/POST, critical rules
docs/superpowers/specs/2026-04-28-pm-b7-psp-adapter-pattern.md— B7 PSP adapter pattern, full psp_tx_map schema, state machine, recovery matrixprojects/payment-manager/docs/WORKERS.md— PspWorker and OutboxWorker implementation detailsprojects/payment-manager/docs/IPPS-OPEN-QUESTIONS.md— resolved and open IPPS questions (Q-IPPS-*)