Digital Wallet
Double-entry bookkeeping for every transaction, idempotency keys to prevent duplicate transfers, and a strategy pattern for payment methods so adding UPI or crypto never touches the transfer logic.
Key Abstractions
Account with a balance, supports credit and debit with concurrency safety
Immutable record of a credit or debit with amount, type, and timestamp
Orchestrates P2P transfers using double-entry bookkeeping
Strategy interface for bank transfer, card, UPI, and other funding sources
Append-only audit trail of every wallet operation
Class Diagram
The Key Insight
A digital wallet is an accounting system with a nice UI on top. The fundamental rule is double-entry bookkeeping: every transfer creates exactly two records. One debit in the sender's wallet, one credit in the receiver's. The total money in the system never changes. If it does, you have a bug and you can detect it immediately by summing all entries.
The second non-negotiable piece is idempotency. Networks are unreliable. Users double-tap buttons. Payment gateways timeout and retry automatically. Without idempotency keys, every retry becomes a duplicate charge. Every mutation endpoint needs a unique client-generated key, and the system must remember which keys it has already processed.
Requirements
Functional
- Create wallets for users with unique identifiers
- Add funds to a wallet via multiple payment methods (bank, card, UPI)
- Transfer money between wallets (peer-to-peer)
- View transaction history for any wallet
- Reject duplicate requests using idempotency keys
Non-Functional
- Every transfer must be atomic: both the debit and credit happen, or neither does
- Idempotency keys must be checked before any state mutation
- Money amounts must use fixed-point decimal arithmetic, never floating point
- Transaction log must be append-only for audit compliance
- Per-wallet locks with consistent ordering to prevent deadlocks
Design Decisions
Why double-entry bookkeeping?
Single-entry is tempting. You just subtract from Alice and add to Bob. But what happens if the process crashes between those two operations? Alice lost money and Bob never received it. Double-entry treats the pair as one atomic unit. You can also verify system integrity at any time by summing all entries. If debits and credits don't balance, something went wrong and the transaction log tells you exactly when.
Why idempotency keys instead of deduplicating by amount and timestamp?
Two legitimate $50 transfers from Alice to Bob within the same second are perfectly valid. Amount plus timestamp does not uniquely identify a transfer intent. Idempotency keys are client-generated, so the client decides what counts as a retry versus a new request. That puts deduplication logic exactly where it belongs.
Why Strategy pattern for payment methods?
The wallet does not care how money enters it. Bank transfer, credit card, UPI all end the same way: credit this wallet X amount. Strategy pattern means adding cryptocurrency funding tomorrow requires one new class implementing PaymentMethod. TransferService never changes. Open/closed principle, doing real work.
Why lock ordering by wallet ID?
Without consistent ordering, two concurrent transfers between the same pair of wallets can deadlock. Thread 1 locks Alice, tries to lock Bob. Thread 2 locks Bob, tries to lock Alice. By always acquiring the lock on the lower ID first, every thread locks in the same global order. Deadlock becomes structurally impossible.
Why an append-only transaction log?
Financial systems live and die by auditability. You cannot delete or modify a transaction record. Even reversals are new entries, not deletions. This gives you full history for dispute resolution, and since every operation is a recorded command, you can rebuild wallet state from scratch by replaying the log. That is also your disaster recovery plan.
Interview Follow-ups
- "How would you handle multi-currency wallets?" Each wallet holds a balance map keyed by currency code. Transfers between different currencies go through an exchange rate service. Cross-currency transfers create four entries: debit in source currency, credit in target currency, plus the rate metadata.
- "What about scheduled or recurring transfers?" A scheduler holds RecurringTransfer templates with cron expressions. On each tick, it calls the regular transfer method with a deterministic idempotency key derived from the schedule ID and execution time.
- "How do you handle refunds?" A refund is a new reverse transfer. Credit the original sender, debit the original receiver. The original transaction stays in the log untouched. The refund entry references the original transaction ID.
- "What if the external payment gateway times out during add-funds?" Mark the transaction as PENDING and store the idempotency key. Run a reconciliation job that polls the gateway, then either completes the credit or rolls it back.
Code Implementation
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from decimal import Decimal
from datetime import datetime
from enum import Enum, auto
import threading
import uuid
class WalletError(Exception):
pass
class InsufficientBalanceError(WalletError):
pass
class DuplicateRequestError(WalletError):
pass
class TransactionType(Enum):
CREDIT = auto()
DEBIT = auto()
@dataclass(frozen=True)
class Transaction:
id: str
wallet_id: str
amount: Decimal
txn_type: TransactionType
idempotency_key: str
description: str
timestamp: datetime
class Wallet:
def __init__(self, wallet_id: str, user_id: str):
self.id = wallet_id
self.user_id = user_id
self._balance = Decimal("0.00")
self.lock = threading.Lock()
@property
def balance(self) -> Decimal:
return self._balance
def credit(self, amount: Decimal, key: str, description: str = "") -> Transaction:
if amount <= 0:
raise ValueError("Credit amount must be positive")
self._balance += amount
return Transaction(
id=str(uuid.uuid4())[:8],
wallet_id=self.id,
amount=amount,
txn_type=TransactionType.CREDIT,
idempotency_key=key,
description=description,
timestamp=datetime.now(),
)
def debit(self, amount: Decimal, key: str, description: str = "") -> Transaction:
if amount <= 0:
raise ValueError("Debit amount must be positive")
if amount > self._balance:
raise InsufficientBalanceError(
f"Balance {self._balance}, needed {amount}"
)
self._balance -= amount
return Transaction(
id=str(uuid.uuid4())[:8],
wallet_id=self.id,
amount=amount,
txn_type=TransactionType.DEBIT,
idempotency_key=key,
description=description,
timestamp=datetime.now(),
)
class PaymentMethod(ABC):
@abstractmethod
def fund(self, wallet_id: str, amount: Decimal) -> bool: ...
@abstractmethod
def method_name(self) -> str: ...
class BankTransfer(PaymentMethod):
def fund(self, wallet_id: str, amount: Decimal) -> bool:
print(f" [Bank] Transferring ${amount} to wallet {wallet_id}")
return True
def method_name(self) -> str:
return "Bank Transfer"
class CardPayment(PaymentMethod):
def fund(self, wallet_id: str, amount: Decimal) -> bool:
print(f" [Card] Charging ${amount} for wallet {wallet_id}")
return True
def method_name(self) -> str:
return "Credit Card"
class UPIPayment(PaymentMethod):
def fund(self, wallet_id: str, amount: Decimal) -> bool:
print(f" [UPI] Processing ${amount} for wallet {wallet_id}")
return True
def method_name(self) -> str:
return "UPI"
class TransactionLog:
"""Append-only ledger. Entries are never modified or deleted."""
def __init__(self):
self._entries: list[Transaction] = []
self._lock = threading.Lock()
def append(self, txn: Transaction) -> None:
with self._lock:
self._entries.append(txn)
def get_history(self, wallet_id: str) -> list[Transaction]:
return [t for t in self._entries if t.wallet_id == wallet_id]
class TransferService:
def __init__(self):
self._wallets: dict[str, Wallet] = {}
self._log = TransactionLog()
self._processed_keys: set[str] = set()
self._keys_lock = threading.Lock()
def create_wallet(self, user_id: str) -> Wallet:
wallet = Wallet(wallet_id=f"W-{str(uuid.uuid4())[:6]}", user_id=user_id)
self._wallets[wallet.id] = wallet
return wallet
def _claim_key(self, key: str) -> None:
"""Atomically check and claim an idempotency key. Raises on duplicate."""
with self._keys_lock:
if key in self._processed_keys:
raise DuplicateRequestError(f"Key already processed: {key}")
self._processed_keys.add(key)
def add_funds(
self, wallet_id: str, amount: Decimal,
method: PaymentMethod, idempotency_key: str
) -> Transaction:
self._claim_key(idempotency_key)
wallet = self._wallets[wallet_id]
if not method.fund(wallet_id, amount):
raise RuntimeError(f"{method.method_name()} failed")
with wallet.lock:
txn = wallet.credit(
amount, idempotency_key,
f"Fund via {method.method_name()}"
)
self._log.append(txn)
return txn
def transfer(
self, from_id: str, to_id: str,
amount: Decimal, idempotency_key: str
) -> tuple[Transaction, Transaction]:
self._claim_key(idempotency_key)
sender = self._wallets[from_id]
receiver = self._wallets[to_id]
# Lock in consistent order by wallet ID to prevent deadlocks
first, second = sorted([sender, receiver], key=lambda w: w.id)
with first.lock:
with second.lock:
debit_txn = sender.debit(
amount, idempotency_key,
f"Transfer to {receiver.user_id}"
)
credit_txn = receiver.credit(
amount, idempotency_key,
f"Transfer from {sender.user_id}"
)
self._log.append(debit_txn)
self._log.append(credit_txn)
return debit_txn, credit_txn
def get_balance(self, wallet_id: str) -> Decimal:
return self._wallets[wallet_id].balance
def get_history(self, wallet_id: str) -> list[Transaction]:
return self._log.get_history(wallet_id)
if __name__ == "__main__":
service = TransferService()
# Create wallets
alice_w = service.create_wallet("Alice")
bob_w = service.create_wallet("Bob")
print(f"Created wallet for Alice: {alice_w.id}")
print(f"Created wallet for Bob: {bob_w.id}")
# Add funds via different payment methods
service.add_funds(alice_w.id, Decimal("1000"), BankTransfer(), "fund-001")
service.add_funds(bob_w.id, Decimal("500"), CardPayment(), "fund-002")
print(f"\nAlice balance: ${service.get_balance(alice_w.id)}")
print(f"Bob balance: ${service.get_balance(bob_w.id)}")
# P2P transfer with double-entry bookkeeping
service.transfer(alice_w.id, bob_w.id, Decimal("250"), "txn-001")
print(f"\nAfter Alice sends $250 to Bob:")
print(f" Alice balance: ${service.get_balance(alice_w.id)}")
print(f" Bob balance: ${service.get_balance(bob_w.id)}")
# Idempotency: retry the same transfer
print("\nRetrying same transfer (key=txn-001)...")
try:
service.transfer(alice_w.id, bob_w.id, Decimal("250"), "txn-001")
except DuplicateRequestError as e:
print(f" Blocked: {e}")
print(f" Alice balance: ${service.get_balance(alice_w.id)}")
print(f" Bob balance: ${service.get_balance(bob_w.id)}")
# Insufficient balance
print("\nAlice tries to send $900 (only has $750)...")
try:
service.transfer(alice_w.id, bob_w.id, Decimal("900"), "txn-002")
except InsufficientBalanceError as e:
print(f" Rejected: {e}")
# Transaction history
print(f"\nAlice's transaction history:")
for txn in service.get_history(alice_w.id):
print(f" {txn.txn_type.name}: ${txn.amount} - {txn.description}")Common Mistakes
- ✗Single-entry bookkeeping. If you only debit the sender without crediting the receiver atomically, a crash leaves money in limbo.
- ✗Missing idempotency keys. Network retries will double-charge users. Every external-facing endpoint needs one.
- ✗Using floating point for money. Use Decimal or BigDecimal. Floating point rounding errors compound over thousands of transactions.
- ✗Not locking wallets in a consistent order during transfers. Two concurrent transfers between Alice and Bob can deadlock.
Key Points
- ✓Double-entry bookkeeping: every transfer creates two entries, one debit and one credit. Balances always sum to zero.
- ✓Idempotency keys on every transfer request. Retries are safe because duplicate keys are rejected.
- ✓Strategy pattern for payment methods. Adding a new funding source means one new class, zero changes to TransferService.
- ✓Command pattern stores each operation. Undo, audit trail, and replay all come from the same log.