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

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

Flow Diagram

Prerequisites

  • Sender is authenticated (valid JWT/session in Auth Center)
  • Sender has completed KYC — external payments require kyc_verified tag in metadata.tags
  • Sender's IPPS wallet is registered: pm.tb_account_map.ipps_wallet_id IS NOT NULL
  • Sender's TigerBeetle account user.{senderId}.THB exists in pm.tb_account_map
  • System account system.nostro.ipps.THB exists in pm.tb_account_map
  • Payment route for IPPS_WITHDRAWAL / THAI_QR_PAY + amount range is active in pm.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_routechannel = '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_eventpm.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-smoke sparingly
  • 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 to intent.to_name and tx_history.to_name; production will return clean names
  • settlementDate always empty in SIT — blank even after successful confirm (Q-IPPS-9, pending IPPS clarification)
  • IPPS SIT base URL: https://promptpay-api-sit.ipps.cloud
  • 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 matrix
  • projects/payment-manager/docs/WORKERS.md — PspWorker and OutboxWorker implementation details
  • projects/payment-manager/docs/IPPS-OPEN-QUESTIONS.md — resolved and open IPPS questions (Q-IPPS-*)