Building a digital wallet
Implement the atomic transfer (debit + credit + ledger in one transaction), idempotency, and async top-up over a state machine.
The atomic transfer
A transfer must debit the sender and credit the receiver atomically, never overdraft, and be retry-safe:
def transfer(idempotency_key, sender, receiver, amount):
prior = idempotency.get(idempotency_key)
if prior: return prior.result # retry → same result, no double move
with db.txn(): # all-or-nothing
idempotency.reserve(idempotency_key) # unique key → blocks concurrent dup
ok = db.execute( # conditional debit (no overdraft)
"UPDATE wallets SET balance = balance - :a "
"WHERE user_id = :s AND balance >= :a", a=amount, s=sender)
if not ok: raise InsufficientFunds()
db.execute("UPDATE wallets SET balance = balance + :a "
"WHERE user_id = :r", a=amount, r=receiver)
txn_id = new_id()
post_entry(txn_id, debit=sender, credit=receiver, amount=amount) # double-entry
result = {"txn_id": txn_id, "status": "completed"}
idempotency.complete(idempotency_key, result)
return result
Everything — the overdraft check, both balance updates, the ledger entries, the idempotency record — commits in one transaction. A crash rolls it all back; a retry returns the stored result. No double-spend, no half-transfer.
Why the conditional update matters
WHERE balance >= :amount makes the check-and-decrement a single atomic step. Two
concurrent transfers from the same wallet can’t both pass if only one is affordable — the DB
serializes them. (Reading balance then updating in app code would race.)
Reading balance and history
def balance(user): return wallets.get(user).balance # maintained column (fast)
def history(user): return ledger.entries_for_account(user) # derived from ledger (truth)
The materialized balance is updated in the same transaction as the ledger, so it can’t
drift; the ledger remains the auditable source if you ever need to recompute.
Async top-up over a state machine
External money (bank/card) is async — credit the wallet only when the provider confirms:
def top_up(idempotency_key, user, amount, source):
tx = transfers.create(user=user, amount=amount, state="initiated")
psp.charge(source, amount, idempotency_key=idempotency_key) # async; webhook later
return tx.id
def on_psp_webhook(event):
with db.txn():
tx = transfers.get(event.tx_id)
if tx.applied(event.id): return # idempotent
if event.type == "succeeded":
credit_wallet(tx.user, tx.amount) # NOW credit (balance + ledger)
tx.state = "completed"
else:
tx.state = "failed"
tx.record_applied(event.id); transfers.put(tx)
Crediting on initiated would let users spend money that never arrived — credit on
confirmation only. Withdrawal is the mirror (debit on request, finalize on bank
confirmation, reverse on failure).
Scale and failure handling
- Concurrent payments from one wallet → conditional decrement serializes; no overdraft.
- Retries / double-tap → idempotency key returns the original result.
- Crash mid-transfer → transaction rolls back; nothing partial.
- Top-up provider fails → wallet never credited (credited only on webhook success); reconcile.
- Hot wallet (big merchant) → row contention; shard sub-accounts or batch settlements.
- CP → on doubt, leave pending and reconcile rather than risk a wrong balance.
The takeaway
Concrete signals: an atomic transfer (conditional debit + credit + double-entry ledger + idempotency record, all in one transaction), a maintained balance kept in sync with the ledger, and async external top-up/withdraw credited only on confirmation. This atomic- ledger-transfer is the exact pattern UPI extends across different banks next.