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 walletsamount,currency— amount in smallest unit (cents for USD), currency codetype— one oftransfer,topup,withdrawalstatus—PENDING,COMPLETED,FAILED,REVERSEDidempotency_key— client-supplied unique key to prevent duplicate operationscreated_at— timestamp with timezone
Atomic Fund Transfer
Fund transfers must be atomic. The sequence inside a database transaction:
- Read source wallet balance and acquire a row-level lock (
SELECT FOR UPDATE) - Check
balance >= amount— reject withINSUFFICIENT_FUNDSif not - Insert debit ledger entry for source wallet
- Insert credit ledger entry for destination wallet
- Update denormalized balance columns on both wallet rows
- 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:
- Debit the user wallet and credit a platform holding wallet — status
PENDING - A batch job at end of day submits pending withdrawals to the bank ACH batch file
- 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.
{
“@context”: “https://schema.org”,
“@type”: “FAQPage”,
“mainEntity”: [
{
“@type”: “Question”,
“name”: “How do you model a balance ledger to prevent double-spending in a digital wallet?”,
“acceptedAnswer”: {
“@type”: “Answer”,
“text”: “Use an append-only ledger table where each row is an immutable credit or debit entry with a transaction ID, account ID, amount, and timestamp. Never store a mutable ‘balance’ column directly — derive the balance via SUM of entries. To prevent double-spending, wrap fund transfers in a serializable database transaction with a SELECT FOR UPDATE on the account row, or use optimistic concurrency with a version counter. For high-throughput systems, shard the ledger by account ID and use idempotency keys on the API layer so retried requests don't produce duplicate entries.”
}
},
{
“@type”: “Question”,
“name”: “What is the safest way to implement fund transfers between two wallet accounts atomically?”,
“acceptedAnswer”: {
“@type”: “Answer”,
“text”: “A fund transfer must debit one account and credit another as a single atomic unit. The canonical approach is a two-row ledger insert inside one database transaction: insert a debit entry for the source and a credit entry for the destination, then commit. To avoid deadlocks when two concurrent transfers touch the same pair of accounts in opposite order, always acquire row locks in a canonical account-ID order (e.g., lower ID first). For distributed wallets spanning multiple shards, use a saga pattern with compensating transactions, or a two-phase commit coordinated by a trusted transfer service that records a PENDING state before applying debits and credits.”
}
},
{
“@type”: “Question”,
“name”: “How would you design the reconciliation process for a wallet service that processes millions of transactions per day?”,
“acceptedAnswer”: {
“@type”: “Answer”,
“text”: “Reconciliation compares the internal ledger against external settlement records (bank, card network, or blockchain). Run a nightly batch job that reads all settled external events for a time window, joins them against internal ledger entries by external transaction ID, and flags mismatches — amounts differ, an entry is in one system but not the other, or settlement is delayed. Store reconciliation results in a separate audit table with status (MATCHED, UNMATCHED, PENDING). For real-time alerting, compute a running checksum or Merkle tree over ledger entries so any tampering is immediately detectable. Expose a dashboard showing unmatched item count, total unmatched value, and aging buckets.”
}
},
{
“@type”: “Question”,
“name”: “How do you handle currency conversion and floating-point precision in a multi-currency wallet?”,
“acceptedAnswer”: {
“@type”: “Answer”,
“text”: “Never use IEEE 754 floating-point types (float, double) for monetary amounts. Store all amounts as integers in the smallest currency unit (cents for USD, paise for INR) alongside a currency code. For conversion, fetch exchange rates from a rate service and store the rate used at transaction time in the ledger entry for auditability — rates must never be reconstructed retroactively. Apply rounding according to each currency's ISO 4217 exponent using banker's rounding (round-half-to-even) to minimize systematic bias. Conversion gains and losses should be booked as separate ledger entries to a dedicated FX P&L account so the books always balance.”
}
}
]
}
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