Digital Wallet Service Low-Level Design: Balance Ledger, Fund Transfer, and Reconciliation

Double-Entry Ledger

A digital wallet service is built on a double-entry ledger: every financial event creates exactly two entries — a debit from the source account and a credit to the destination account. The sum of all ledger entries for any wallet equals its current balance. This makes fraud and bugs immediately visible because the books must always balance.

Transaction Schema

Each transaction record captures:

  • txn_id — globally unique transaction identifier (UUID v4)
  • from_wallet_id, to_wallet_id — source and destination wallets
  • amount, currency — amount in smallest unit (cents for USD), currency code
  • type — one of transfer, topup, withdrawal
  • statusPENDING, COMPLETED, FAILED, REVERSED
  • idempotency_key — client-supplied unique key to prevent duplicate operations
  • created_at — timestamp with timezone

Atomic Fund Transfer

Fund transfers must be atomic. The sequence inside a database transaction:

  1. Read source wallet balance and acquire a row-level lock (SELECT FOR UPDATE)
  2. Check balance >= amount — reject with INSUFFICIENT_FUNDS if not
  3. Insert debit ledger entry for source wallet
  4. Insert credit ledger entry for destination wallet
  5. Update denormalized balance columns on both wallet rows
  6. Commit — any failure triggers a full rollback

Using row-level locks prevents double-spend under concurrent requests to the same wallet.

Balance Calculation and Denormalization

The authoritative balance is SUM(credits) - SUM(debits) across all ledger entries for a wallet. Computing this on the fly becomes expensive for wallets with years of history. The solution is a denormalized balance column on the wallet row, updated atomically within every transaction. Reads are O(1). The ledger entries remain the source of truth for reconciliation.

Balance Cache

For high-read workloads, cache balance per wallet_id in Redis:

SET wallet:balance:{wallet_id} {amount} EX 60

Invalidate (delete) the cache key on every write. Never update the cache directly from application logic — always let the next read recompute from the database. This avoids stale-cache bugs under concurrent writes.

Idempotency

Clients supply an idempotency_key with every transfer request. The service enforces a unique constraint on (idempotency_key, wallet_id). If a request arrives with an already-processed key, the service returns the existing transaction record instead of executing a new debit. This makes retries safe after network timeouts.

Transaction Limits and Fraud Checks

Before executing any transfer, enforce limits:

  • Single-transaction limit: maximum amount per transfer (e.g., $10,000)
  • Daily spend limit: sum of outgoing transfers in the last 24 hours must not exceed threshold
  • Velocity limit: no more than N outgoing transactions per hour per wallet
  • Unusual recipient: first-time transfer to a recipient above a threshold → flag for review
  • Large amount flag: transfers above a configurable ceiling routed to a fraud review queue before execution

Fraud checks run synchronously before the database transaction; a rejected transfer never touches the ledger.

Currency Handling

All amounts are stored as integers in the smallest currency unit (cents for USD, pence for GBP). This eliminates floating-point rounding errors. Foreign exchange conversions call an external FX rate API at transfer time. The converted amount and the applied rate are stored on the transaction record for audit purposes.

Wallet Hierarchy

The same ledger system supports multiple wallet types: user wallets, merchant wallets, a platform fee wallet, and escrow wallets. Differentiating by a wallet_type column allows fine-grained reporting without separate tables. Funds moving between types still follow the same double-entry rules.

Withdrawal to Bank

Bank withdrawals (ACH or wire) are two-phase:

  1. Debit the user wallet and credit a platform holding wallet — status PENDING
  2. A batch job at end of day submits pending withdrawals to the bank ACH batch file
  3. On bank confirmation, the holding wallet is debited and the withdrawal record moves to COMPLETED

Daily Reconciliation

A nightly reconciliation job sums all ledger entries per wallet and compares to the denormalized balance column. Any discrepancy triggers an alert and is written to a reconciliation exceptions table for manual review. This catches bugs where the balance column drifted from the ledger — a class of errors that is otherwise invisible at runtime.

See also: Stripe Interview Guide 2026: Process, Bug Bash Round, and Payment Systems

See also: Coinbase Interview Guide

See also: Scale AI Interview Guide 2026: Data Infrastructure, RLHF Pipelines, and ML Engineering

Scroll to Top