What Is a Digital Wallet?
A digital wallet stores funds and enables transfers between users and merchants. Examples: PayPal, Venmo, Apple Pay, Google Pay, Cash App. Core challenges: exactly-once transactions (no double charges or double credits), consistent balance across concurrent operations, and preventing fraud while maintaining low latency.
System Requirements
Functional
- Add funds via bank transfer or credit card
- Send money to another user
- Pay merchants
- View transaction history
- Link and manage bank accounts
Non-Functional
- 100M users, 10K transactions/second
- No double charges, no lost transfers
- Transaction finality within 5 seconds
- Compliance: PCI-DSS for card data, AML for large transfers
Core Data Model — Double-Entry Accounting
Never update a balance directly. Use a ledger (journal) of immutable transactions:
accounts: id, user_id, currency, balance (cached sum)
transactions: id, idempotency_key, status, created_at
ledger_entries: id, transaction_id, account_id, amount, entry_type(debit/credit)
Every transaction creates exactly two ledger entries: one debit (balance decreases) and one credit (balance increases). The sum of all entries for an account equals its balance. This double-entry model means the ledger always balances — total debits = total credits across all accounts. Auditors can verify correctness by recomputing balances from the ledger.
Transfer Implementation
def transfer(sender_id, receiver_id, amount, idempotency_key):
# Idempotency: check if this transfer already happened
if Transaction.exists(idempotency_key):
return Transaction.get(idempotency_key)
with db.transaction():
# Lock both accounts (order by ID to prevent deadlock)
sender = Account.lock(min(sender_id, receiver_id))
receiver = Account.lock(max(sender_id, receiver_id))
if sender.balance < amount:
raise InsufficientFunds()
txn = Transaction.create(idempotency_key=idempotency_key)
LedgerEntry.create(txn, sender, -amount, 'debit')
LedgerEntry.create(txn, receiver, +amount, 'credit')
sender.balance -= amount # update cached balance
receiver.balance += amount
txn.status = 'completed'
return txn
Lock ordering (min account ID first) prevents deadlocks when two concurrent transfers involve the same two accounts in opposite directions.
Idempotency
The caller provides an idempotency_key (UUID) with each transfer. If the network times out and the caller retries, the second call sees the existing transaction and returns it — no double transfer. The idempotency check must be inside the same transaction as the transfer to prevent TOCTOU (time-of-check-time-of-use) race conditions.
External Transfers (Bank Deposits/Withdrawals)
Bank transfers via ACH take 1-3 business days and can be reversed (insufficient funds, fraudulent). Wallet credits are “pending” until ACH settles. Two-phase approach: credit the wallet with “pending” status immediately (good UX), set a hold on spending (cannot withdraw), lift the hold when ACH confirms settlement. On ACH reversal: debit the wallet and notify the user.
Fraud Detection
Rules evaluated in real-time before each transaction:
- Velocity: more than 10 transfers in 1 hour → flag for review
- Amount: single transfer over $10,000 → AML report (CTR)
- Geo anomaly: transfer from country never seen for this user
- ML model: predict fraud probability from 50+ features
Flagged transactions are held for manual review. High-confidence fraud: auto-decline and freeze account.
Scaling Balances
The cached balance column (accounts.balance) is a hotspot for high-volume wallets (a merchant receiving thousands of payments per second). Solutions: (1) Optimistic locking: use a version column; retry on conflict — works for moderate contention. (2) Balance sharding: split the balance across N virtual sub-accounts; aggregate for read, distribute writes. (3) Asynchronous balance update: update ledger entries synchronously, update cached balance asynchronously from Kafka — accept slight staleness for display balance.
Interview Tips
- Double-entry ledger is the correct financial data model — mention it explicitly.
- Lock ordering by account ID prevents deadlocks.
- Idempotency key is the primary exactly-once mechanism.
- Pending state for external transfers handles ACH reversal risk.