
@tosinolugbenga
Part 3 of 3 — Building a Wallet That Does Not Double-Spend
Parts 1 and 2 describe a single wallet breathing in and out. Real products move money between wallets in one business action. This closing piece covers multi-leg flows and what changes once you are live.
A user-to-user transfer should create:
Pending debit on sender wallet
Pending credit on recipient wallet
One atomic finalize that completes both
If only the debit completes, you have lost money from the system’s perspective. Package both legs in a single database function with four row locks (two transactions, two wallets) and two ledger inserts.
Expose failures as a single user-visible outcome: “transfer failed” — not “debited, credit stuck pending.”
Checkout flows often split one card charge into:
Platform fee → platform wallet
Seller payout → seller wallet
Optional tax / partner wallets
Each leg is its own transaction row, often sharing the same payment reference but different wallet_id. That is why uniqueness is (reference, wallet_id) for in-flight rows, not reference alone.
Finalize either:
All legs in one routine after one gateway verification, or
An orchestrated sequence where failure on any leg marks the group failed and compensates (harder; prefer atomic multi-leg finalize when amounts are known upfront).
Escrow (“hold funds until delivery”) is usually modelled as:
PENDING → HELD when capture is authorized
HELD → COMPLETED on release, or CANCELLED / FAILED on void
Do not add held_balance beside balance unless you have a clear reconciliation story. HELD debits should still count against available balance when creating new debits.
Finance will compare gateway reports to your database. Standard joins:
Gateway Field | Your Field |
|---|---|
Provider reference | transactions.reference |
Settled amount | transactions.amount (completed credits) |
Currency | transactions.currency_code or wallet currency |
Settlement date | transactions.updated_at or ledger created_at |
Flag rows where:
Gateway says success, no completed credit exists
Completed credit exists, gateway has no record
Currency or amount mismatch (store explicit mismatch flags in meta for admin dashboards)
The ledger answers “what did we think the wallet was at the instant we applied this?” — useful for end-of-day balance proofs.
Teams that discover duplicate COMPLETED rows for the same (reference, wallet_id) cannot always add a strict unique index retroactively. Common approach:
Add partial unique index for in-flight statuses only (prevents new damage).
Backfill scripts mark duplicates for manual review.
Support uses ledger + gateway files to decide refunds or adjustments via new adjusting transactions (never silent balance edits).
Stay on three tables + Postgres routines until:
You need cross-region active-active with conflict-free semantics
You require regulatory sub-ledger segregation per jurisdiction
Volume demands async settlement pipelines (outbox + consumers)
Signals to graduate:
Finalize routines exceed hundreds of lines of branching
Product wants time-travel balance queries at scale (event sourcing)
You buy a ledger SaaS for licensing reasons
Until then, disciplined Postgres + idempotent finalize + constraints punch above their weight.
Part 1 — Separate intent (transactions), balance (wallets), audit (ledgers).
Part 2 — Lock rows, idempotent finalize, partial uniques, verified credits.
Part 3 — Multi-leg transfers, splits, escrow statuses, ops reconciliation.
You do not need a blockchain to explain balances. You need clear state, atomic settlement, and constraints that survive your next deploy.
Other articles you might enjoy