Internal P2P Payment¶
Overview¶
The Internal P2P flow transfers funds synchronously between two OneWallet users in the same TigerBeetle ledger. It is triggered when the sender initiates a peer-to-peer transfer from the Flutter app. Because both accounts are within the same ledger, no external PSP adapter is involved — the entire flow completes within a single HTTP request to Payment Manager and returns a final SETTLED status to the caller.
Participants¶
| Service | Role in This Flow |
|---|---|
| Flutter App | User-facing initiator; displays confirmation and result |
| Auth Center (Serverpod) | Authenticates user, issues request to PM with HMAC signature |
| Payment Manager (Node.js/Fastify) | Validates request, resolves accounts, executes two-phase TigerBeetle transfers, returns final status |
| TigerBeetle | Ledger that holds user balances; executes atomic pending + post-pending transfer batch |
| Redis (Pub/Sub) | PM publishes intent.{id} on settlement; Serverpod streams the event to Flutter |
Diagram¶

Prerequisites¶
- Sender is authenticated (valid JWT/session in Auth Center)
- Sender's TigerBeetle account
user.{senderId}.THBexists inpm.tb_account_map - Recipient's TigerBeetle account
user.{recipientId}.THBexists inpm.tb_account_map - Transit account
system.transit.INTERNAL_P2P.THBexists inpm.tb_account_map - Payment route
P2P_TRANSFER → INTERNAL_P2Pis active inpm.payment_route - Sender's available balance (
credits_posted - debits_posted - debits_pending) >= transfer amount + fee
Steps¶
Step 1: User Initiates Transfer¶
Service: Flutter App
Action: User enters recipient and amount, taps "Send". App generates a idempotencyKey (UUID) and calls Auth Center via Serverpod RPC.
Data: { recipientUserId, amount, currency: "THB", idempotencyKey }
Step 2: Auth Center Forwards Intent to PM¶
Service: Auth Center (Serverpod)
Action: Calls POST /intents on Payment Manager. Request is signed with HMAC (X-Service-Id, X-Timestamp, X-Signature). The caller's userId is passed via X-User-Id header.
Data:
POST /intents
X-Service-Id: auth-center
X-Timestamp: <unix epoch>
X-Signature: <hmac-sha256>
X-User-Id: <senderId>
{
"idempotencyKey": "<uuid>",
"operationType": "P2P_TRANSFER",
"amount": <satangs>,
"currency": "THB",
"recipientUserId": <recipientId>
}
Step 3: PM Idempotency Check¶
Service: Payment Manager
Action: Queries pm.intent by idempotency_key. If a record exists, returns the saved response immediately (HTTP 200) without re-executing any TigerBeetle operations.
Data: SELECT id, status, channel FROM pm.intent WHERE idempotency_key = $1
Step 4: Route Resolution¶
Service: Payment Manager
Action: Queries pm.payment_route to resolve channel by operationType = 'P2P_TRANSFER' and amount range. Returns channel = 'INTERNAL_P2P'.
Data: pm.payment_route table lookup
Step 5: Account Resolution¶
Service: Payment Manager
Action: Resolves TigerBeetle account IDs for sender, recipient, and transit from pm.tb_account_map.
Data:
- from: user.{senderId}.THB
- to: user.{recipientId}.THB
- transit: system.transit.INTERNAL_P2P.THB
If any account is not found → HTTP 422 AccountNotFoundError.
Step 6: Intent Record Created (CREATED)¶
Service: Payment Manager
Action: Inserts a new row into pm.intent with status = 'CREATED' before touching TigerBeetle. This guards against duplicate execution if PM crashes mid-flight.
Data: INSERT INTO pm.intent (..., status='CREATED', tb_transfer_ids='{}')
Step 7: TigerBeetle Authorize — Linked Pending Transfers¶
Service: Payment Manager → TigerBeetle
Action: Creates two linked PENDING transfers in a single createTransfers batch. Being linked means TigerBeetle either accepts both atomically or rejects both. If sender has insufficient balance, the batch is rejected here.
Transfer IDs are deterministic (uuidv5 of intentId + index), ensuring idempotency on retry.
Transfers:
Transfer 0 (PENDING | LINKED):
debit: user.{senderId}.THB
credit: system.transit.INTERNAL_P2P.THB
amount: <total>
timeout: 300s
Transfer 1 (PENDING):
debit: system.transit.INTERNAL_P2P.THB
credit: user.{recipientId}.THB
amount: <total>
timeout: 300s
Step 8: Intent Updated to AUTHORIZED¶
Service: Payment Manager
Action: Updates pm.intent to status = 'AUTHORIZED', stores tb_transfer_ids = [id0, id1]. This checkpoint survives a crash — the startup reconciler can void these pending transfers on recovery.
Data: UPDATE pm.intent SET status='AUTHORIZED', tb_transfer_ids=$1 WHERE id=$2
Step 9: TigerBeetle Settle — Linked Post-Pending Transfers¶
Service: Payment Manager → TigerBeetle
Action: Creates two linked POST_PENDING transfers that finalize the previously pending transfers. This is the settlement step — funds move from sender to recipient through the transit account. The transit account balance returns to zero after this step.
Transfers:
Transfer 2 (POST_PENDING | LINKED):
pending_id: id0
amount: AMOUNT_MAX (post full pending)
Transfer 3 (POST_PENDING):
pending_id: id1
amount: AMOUNT_MAX
Step 10: Intent Updated to SETTLED¶
Service: Payment Manager
Action: Updates pm.intent to status = 'SETTLED', stores all four tb_transfer_ids. Returns HTTP 200 to Auth Center with { intentId, status: "SETTLED" }.
Data: UPDATE pm.intent SET status='SETTLED', tb_transfer_ids=$1 WHERE id=$2
Step 11: Auth Center Publishes to Redis¶
Service: Auth Center (Serverpod)
Action: On receiving SETTLED from PM, publishes the result on Redis channel intent.{intentId}.
Data: PUBLISH intent.<intentId> { status: "SETTLED", amount, ... }
Step 12: Flutter Receives Result¶
Service: Flutter App (via Serverpod streaming endpoint)
Action: Auth Center's intentStatusStream endpoint pushes the settlement event to the Flutter client. App displays "Transfer successful" with updated balance.
State Transitions¶
CREATED
→ AUTHORIZED (after TB pending transfers created)
→ SETTLED (after TB post-pending transfers created, synchronous)
→ FAILED (TB error at any step, or startup reconciler voids stale intent)
The INTERNAL_P2P channel is fully synchronous — CREATED → AUTHORIZED → SETTLED happens within a single POST /intents request. There is no IN_PROGRESS state for this channel.
Transit account invariant: system.transit.INTERNAL_P2P.THB balance = 0 after every successfully settled P2P transfer.
Error Cases¶
| Scenario | Where Detected | Outcome | Recovery |
|---|---|---|---|
| Insufficient sender balance | TigerBeetle createTransfers (Step 7) |
HTTP 422 INSUFFICIENT_FUNDS; intent status → FAILED |
User must top up and retry |
| Recipient account not found | pm.tb_account_map lookup (Step 5) |
HTTP 422 AccountNotFoundError; no TB operations attempted |
Verify recipient user ID |
| HMAC invalid or timestamp out of ±60s | PM HMAC middleware | HTTP 401; no intent created | Re-sign request |
| No route for P2P_TRANSFER | pm.payment_route lookup (Step 4) |
HTTP 400 NoRouteError |
Admin must ensure payment_route seed is present |
| PM crashes after AUTHORIZED, before SETTLED | Startup reconciler on next boot | Stale AUTHORIZED intents older than 5 min → TB void pending → status FAILED |
Client retries with same idempotency key; new intent created |
TB pending_transfer_expired during post-pending |
TigerBeetle (Step 9) | Treated as non-fatal; intent → FAILED; funds returned to sender by TB automatically | User retries |
| Duplicate idempotency key from client | PM idempotency check (Step 3) | HTTP 200 with original result; no new TB operations | No action needed |
| Active hold already exists for sender | B5 Saga Orchestrator balance check (post-B3) | HTTP 409 ACTIVE_HOLD_EXISTS |
Wait for existing intent to complete or expire |
Related Specs¶
/docs/superpowers/specs/2026-04-27-pm-b3-intent-router.md— INTERNAL_P2P channel implementation, DB schema (pm.intent,pm.payment_route), saga runner, startup reconciler/docs/superpowers/plans/2026-04-27-b5-saga-orchestrator.md— KYC and balance validation steps added between CREATED and AUTHORIZED