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

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

Flow Diagram

Prerequisites

  • Sender is authenticated (valid JWT/session in Auth Center)
  • Sender's TigerBeetle account user.{senderId}.THB exists in pm.tb_account_map
  • Recipient's TigerBeetle account user.{recipientId}.THB exists in pm.tb_account_map
  • Transit account system.transit.INTERNAL_P2P.THB exists in pm.tb_account_map
  • Payment route P2P_TRANSFER → INTERNAL_P2P is active in pm.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
  • /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