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
1 from abc import ABC, abstractmethod
2 from dataclasses import dataclass, field
3 from decimal import Decimal
4 from datetime import datetime
5 from enum import Enum, auto
6 import threading
7 import uuid
8
9
10 class WalletError(Exception):
11 pass
12
13
14 class InsufficientBalanceError(WalletError):
15 pass
16
17
18 class DuplicateRequestError(WalletError):
19 pass
20
21
22 class TransactionType(Enum):
23 CREDIT = auto()
24 DEBIT = auto()
25
26
27 @dataclass(frozen=True)
28 class Transaction:
29 id: str
30 wallet_id: str
31 amount: Decimal
32 txn_type: TransactionType
33 idempotency_key: str
34 description: str
35 timestamp: datetime
36
37
38 class Wallet:
39 def __init__(self, wallet_id: str, user_id: str):
40 self.id = wallet_id
41 self.user_id = user_id
42 self._balance = Decimal("0.00")
43 self.lock = threading.Lock()
44
45 @property
46 def balance(self) -> Decimal:
47 return self._balance
48
49 def credit(self, amount: Decimal, key: str, description: str = "") -> Transaction:
50 if amount <= 0:
51 raise ValueError("Credit amount must be positive")
52 self._balance += amount
53 return Transaction(
54 id=str(uuid.uuid4())[:8],
55 wallet_id=self.id,
56 amount=amount,
57 txn_type=TransactionType.CREDIT,
58 idempotency_key=key,
59 description=description,
60 timestamp=datetime.now(),
61 )
62
63 def debit(self, amount: Decimal, key: str, description: str = "") -> Transaction:
64 if amount <= 0:
65 raise ValueError("Debit amount must be positive")
66 if amount > self._balance:
67 raise InsufficientBalanceError(
68 f"Balance {self._balance}, needed {amount}"
69 )
70 self._balance -= amount
71 return Transaction(
72 id=str(uuid.uuid4())[:8],
73 wallet_id=self.id,
74 amount=amount,
75 txn_type=TransactionType.DEBIT,
76 idempotency_key=key,
77 description=description,
78 timestamp=datetime.now(),
79 )
80
81
82 class PaymentMethod(ABC):
83 @abstractmethod
84 def fund(self, wallet_id: str, amount: Decimal) -> bool: ...
85
86 @abstractmethod
87 def method_name(self) -> str: ...
88
89
90 class BankTransfer(PaymentMethod):
91 def fund(self, wallet_id: str, amount: Decimal) -> bool:
92 print(f" [Bank] Transferring ${amount} to wallet {wallet_id}")
93 return True
94
95 def method_name(self) -> str:
96 return "Bank Transfer"
97
98
99 class CardPayment(PaymentMethod):
100 def fund(self, wallet_id: str, amount: Decimal) -> bool:
101 print(f" [Card] Charging ${amount} for wallet {wallet_id}")
102 return True
103
104 def method_name(self) -> str:
105 return "Credit Card"
106
107
108 class UPIPayment(PaymentMethod):
109 def fund(self, wallet_id: str, amount: Decimal) -> bool:
110 print(f" [UPI] Processing ${amount} for wallet {wallet_id}")
111 return True
112
113 def method_name(self) -> str:
114 return "UPI"
115
116
117 class TransactionLog:
118 """Append-only ledger. Entries are never modified or deleted."""
119
120 def __init__(self):
121 self._entries: list[Transaction] = []
122 self._lock = threading.Lock()
123
124 def append(self, txn: Transaction) -> None:
125 with self._lock:
126 self._entries.append(txn)
127
128 def get_history(self, wallet_id: str) -> list[Transaction]:
129 return [t for t in self._entries if t.wallet_id == wallet_id]
130
131
132 class TransferService:
133 def __init__(self):
134 self._wallets: dict[str, Wallet] = {}
135 self._log = TransactionLog()
136 self._processed_keys: set[str] = set()
137 self._keys_lock = threading.Lock()
138
139 def create_wallet(self, user_id: str) -> Wallet:
140 wallet = Wallet(wallet_id=f"W-{str(uuid.uuid4())[:6]}", user_id=user_id)
141 self._wallets[wallet.id] = wallet
142 return wallet
143
144 def _claim_key(self, key: str) -> None:
145 """Atomically check and claim an idempotency key. Raises on duplicate."""
146 with self._keys_lock:
147 if key in self._processed_keys:
148 raise DuplicateRequestError(f"Key already processed: {key}")
149 self._processed_keys.add(key)
150
151 def add_funds(
152 self, wallet_id: str, amount: Decimal,
153 method: PaymentMethod, idempotency_key: str
154 ) -> Transaction:
155 self._claim_key(idempotency_key)
156 wallet = self._wallets[wallet_id]
157 if not method.fund(wallet_id, amount):
158 raise RuntimeError(f"{method.method_name()} failed")
159 with wallet.lock:
160 txn = wallet.credit(
161 amount, idempotency_key,
162 f"Fund via {method.method_name()}"
163 )
164 self._log.append(txn)
165 return txn
166
167 def transfer(
168 self, from_id: str, to_id: str,
169 amount: Decimal, idempotency_key: str
170 ) -> tuple[Transaction, Transaction]:
171 self._claim_key(idempotency_key)
172 sender = self._wallets[from_id]
173 receiver = self._wallets[to_id]
174
175 # Lock in consistent order by wallet ID to prevent deadlocks
176 first, second = sorted([sender, receiver], key=lambda w: w.id)
177
178 with first.lock:
179 with second.lock:
180 debit_txn = sender.debit(
181 amount, idempotency_key,
182 f"Transfer to {receiver.user_id}"
183 )
184 credit_txn = receiver.credit(
185 amount, idempotency_key,
186 f"Transfer from {sender.user_id}"
187 )
188
189 self._log.append(debit_txn)
190 self._log.append(credit_txn)
191 return debit_txn, credit_txn
192
193 def get_balance(self, wallet_id: str) -> Decimal:
194 return self._wallets[wallet_id].balance
195
196 def get_history(self, wallet_id: str) -> list[Transaction]:
197 return self._log.get_history(wallet_id)
198
199
200 if __name__ == "__main__":
201 service = TransferService()
202
203 # Create wallets
204 alice_w = service.create_wallet("Alice")
205 bob_w = service.create_wallet("Bob")
206 print(f"Created wallet for Alice: {alice_w.id}")
207 print(f"Created wallet for Bob: {bob_w.id}")
208
209 # Add funds via different payment methods
210 service.add_funds(alice_w.id, Decimal("1000"), BankTransfer(), "fund-001")
211 service.add_funds(bob_w.id, Decimal("500"), CardPayment(), "fund-002")
212 print(f"\nAlice balance: ${service.get_balance(alice_w.id)}")
213 print(f"Bob balance: ${service.get_balance(bob_w.id)}")
214
215 # P2P transfer with double-entry bookkeeping
216 service.transfer(alice_w.id, bob_w.id, Decimal("250"), "txn-001")
217 print(f"\nAfter Alice sends $250 to Bob:")
218 print(f" Alice balance: ${service.get_balance(alice_w.id)}")
219 print(f" Bob balance: ${service.get_balance(bob_w.id)}")
220
221 # Idempotency: retry the same transfer
222 print("\nRetrying same transfer (key=txn-001)...")
223 try:
224 service.transfer(alice_w.id, bob_w.id, Decimal("250"), "txn-001")
225 except DuplicateRequestError as e:
226 print(f" Blocked: {e}")
227 print(f" Alice balance: ${service.get_balance(alice_w.id)}")
228 print(f" Bob balance: ${service.get_balance(bob_w.id)}")
229
230 # Insufficient balance
231 print("\nAlice tries to send $900 (only has $750)...")
232 try:
233 service.transfer(alice_w.id, bob_w.id, Decimal("900"), "txn-002")
234 except InsufficientBalanceError as e:
235 print(f" Rejected: {e}")
236
237 # Transaction history
238 print(f"\nAlice's transaction history:")
239 for txn in service.get_history(alice_w.id):
240 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.