Payment System
Strategy pattern for payment methods so adding UPI or crypto is one new class, chain of responsibility for layered fraud checks, a state machine enforcing payment lifecycle transitions, and idempotency keys to prevent double charges.
Key Abstractions
Orchestrator that validates, fraud-checks, and routes payments through the correct method
Strategy interface with subtypes CreditCard, DebitCard, UPI, and NetBanking
Adapter wrapping external payment providers behind a uniform charge/refund interface
Core entity with a state machine governing its lifecycle from INITIATED through COMPLETED or FAILED
Chain of responsibility for layered validation: velocity checks, amount limits, blacklist matching
Class Diagram
The Key Insight
A payment system routes money through external providers, each with different APIs, different failure modes, and different validation requirements. The user picks a method and clicks "Pay." Behind the scenes, you are managing three things at once: a strategy that knows how to talk to the right gateway, a chain of fraud checks that must all pass before money moves, and a state machine that prevents the payment from ever reaching an illegal state.
Strategy handles method diversity. Credit cards, debit cards, UPI, net banking all end with "charge this amount," but the details and gateways are completely different. Chain of responsibility handles fraud. Each checker looks at one thing: is the amount too large? Is this user blacklisted? Is there suspicious velocity? If any checker says no, the payment stops. If all say yes, it proceeds. The state machine keeps everything honest. INITIATED goes to PROCESSING. PROCESSING goes to COMPLETED or FAILED. COMPLETED can become REFUNDED. Nothing else is allowed.
Requirements
Functional
- Process payments via multiple methods: credit card, debit card, UPI, net banking
- Track each payment through a strict lifecycle: INITIATED, PROCESSING, COMPLETED, FAILED, REFUNDED
- Run layered fraud checks before charging: amount limits, velocity, blacklist
- Support idempotency so retries do not create duplicate charges
- Process refunds for completed payments
- Notify observers on every status transition
Non-Functional
- State machine must enforce legal transitions. No code path should bypass it.
- Fraud checks must run before any charge attempt, not after
- Full audit trail via status history with timestamps and reasons
- Gateway integration via adapters so swapping providers requires zero changes to core logic
Design Decisions
Why Strategy for payment methods?
Credit cards need a card number, expiry, and CVV. UPI needs a VPA string. Net banking needs a bank code. Each method validates differently and talks to a different gateway. If you put all of this into a single class with conditional branches, every new payment method means modifying existing code. With Strategy, adding cryptocurrency tomorrow is one new class implementing the interface. PaymentProcessor never changes.
Why Chain of Responsibility for fraud checks?
Each fraud check is independent. Amount limits, velocity checks, blacklist lookups, these are separate concerns with separate data. Chain of responsibility lets you compose them in any order, add new checks without touching existing ones, and short-circuit on the first rejection. Compare this to a monolithic validatePayment() method that grows a new if-block every sprint. The chain scales better organizationally too, since different teams can own different checkers.
Why a state machine instead of a mutable status field?
Without enforced transitions, any bug can set any status. A FAILED payment could become COMPLETED through a race condition or a copy-paste error. The state machine defines exactly which transitions are legal and throws immediately on violations. You catch bugs at the point of the illegal transition, not downstream where the damage is harder to trace. It also makes the system self-documenting. Read the transition map and you know the entire lifecycle.
Why idempotency keys?
Payment APIs are called over networks that fail. Timeouts trigger retries. Mobile apps fire duplicate requests when users tap twice. Without idempotency, every retry is a new charge. With it, the system checks whether it has already processed that key and returns the original result. This is standard practice at Stripe, Razorpay, and every serious payment provider.
Why Adapter for payment gateways?
Stripe's API looks nothing like Razorpay's, which looks nothing like a bank's net banking endpoint. Without adapters, your payment logic is littered with provider-specific code and each new integration means modifying existing methods. The Adapter pattern wraps each provider behind charge() and refund(). Your core logic is provider-agnostic. Swapping Stripe for Adyen means writing one new adapter class.
Interview Follow-ups
- "How would you handle partial refunds?" Add a
refundedAmountfield to Transaction. Allow multiple refund calls as long as the total refunded does not exceed the original charge. Each partial refund gets its own StatusChange entry. - "What about async payment confirmation via webhooks?" The initial
processPaymentcall transitions to PROCESSING and returns. A WebhookHandler receives callbacks from the gateway, looks up the transaction by gateway ID, and transitions to COMPLETED or FAILED. The client polls or subscribes for status updates. - "How would you add retry logic for failed payments?" FAILED transitions back to INITIATED on explicit retry (add it to allowed transitions). A RetryScheduler picks up failed payments, applies exponential backoff, and resubmits with the same idempotency key. Max retry count prevents infinite loops.
- "How do you handle multi-currency payments?" Add a CurrencyConverter service. When the payment currency differs from the gateway's settlement currency, convert before calling charge. Store both the original amount/currency and the converted amount on the Transaction.
Code Implementation
1 from __future__ import annotations
2 from abc import ABC, abstractmethod
3 from dataclasses import dataclass, field
4 from decimal import Decimal
5 from datetime import datetime, timedelta
6 from enum import Enum, auto
7 from collections import defaultdict
8 import uuid
9
10
11 class PaymentError(Exception):
12 pass
13
14 class FraudRejectError(PaymentError):
15 pass
16
17 class InvalidTransitionError(PaymentError):
18 pass
19
20 class DuplicateKeyError(PaymentError):
21 pass
22
23
24 class PaymentStatus(Enum):
25 INITIATED = auto()
26 PROCESSING = auto()
27 COMPLETED = auto()
28 FAILED = auto()
29 REFUNDED = auto()
30
31 def allowed_transitions(self) -> set["PaymentStatus"]:
32 return {
33 PaymentStatus.INITIATED: {PaymentStatus.PROCESSING},
34 PaymentStatus.PROCESSING: {PaymentStatus.COMPLETED, PaymentStatus.FAILED},
35 PaymentStatus.COMPLETED: {PaymentStatus.REFUNDED},
36 PaymentStatus.FAILED: set(),
37 PaymentStatus.REFUNDED: set(),
38 }[self]
39
40
41 @dataclass(frozen=True)
42 class StatusChange:
43 from_status: PaymentStatus
44 to_status: PaymentStatus
45 timestamp: datetime
46 reason: str
47
48
49 @dataclass(frozen=True)
50 class GatewayResponse:
51 success: bool
52 txn_id: str
53 message: str
54
55
56 # ---- Payment Gateway (Adapter) ----
57
58 class PaymentGateway(ABC):
59 @abstractmethod
60 def charge(self, amount: Decimal, details: dict) -> GatewayResponse: ...
61
62 @abstractmethod
63 def refund(self, txn_id: str, amount: Decimal) -> GatewayResponse: ...
64
65
66 class StripeGateway(PaymentGateway):
67 def charge(self, amount: Decimal, details: dict) -> GatewayResponse:
68 # Simulate: amounts over 10000 get declined
69 if amount > Decimal("10000"):
70 return GatewayResponse(False, "", "Amount exceeds gateway limit")
71 txn_id = f"stripe_{uuid.uuid4().hex[:10]}"
72 return GatewayResponse(True, txn_id, "Charge successful")
73
74 def refund(self, txn_id: str, amount: Decimal) -> GatewayResponse:
75 return GatewayResponse(True, f"ref_{txn_id}", "Refund processed")
76
77
78 class RazorpayGateway(PaymentGateway):
79 def charge(self, amount: Decimal, details: dict) -> GatewayResponse:
80 txn_id = f"rzp_{uuid.uuid4().hex[:10]}"
81 return GatewayResponse(True, txn_id, "UPI charge successful")
82
83 def refund(self, txn_id: str, amount: Decimal) -> GatewayResponse:
84 return GatewayResponse(True, f"ref_{txn_id}", "UPI refund processed")
85
86
87 # ---- Payment Method (Strategy) ----
88
89 class PaymentMethod(ABC):
90 @abstractmethod
91 def method_type(self) -> str: ...
92
93 @abstractmethod
94 def validate(self) -> bool: ...
95
96 @abstractmethod
97 def charge(self, amount: Decimal) -> GatewayResponse: ...
98
99 @abstractmethod
100 def refund(self, txn_id: str, amount: Decimal) -> GatewayResponse: ...
101
102
103 class CreditCardMethod(PaymentMethod):
104 def __init__(self, card_number: str, expiry: str,
105 cvv: str, gateway: PaymentGateway):
106 self._card = card_number
107 self._expiry = expiry
108 self._cvv = cvv
109 self._gateway = gateway
110
111 def method_type(self) -> str:
112 return "CREDIT_CARD"
113
114 def validate(self) -> bool:
115 digits = self._card.replace("-", "").replace(" ", "")
116 return len(digits) == 16 and len(self._cvv) in (3, 4)
117
118 def charge(self, amount: Decimal) -> GatewayResponse:
119 return self._gateway.charge(amount, {"card_last4": self._card[-4:]})
120
121 def refund(self, txn_id: str, amount: Decimal) -> GatewayResponse:
122 return self._gateway.refund(txn_id, amount)
123
124
125 class DebitCardMethod(PaymentMethod):
126 def __init__(self, card_number: str, pin: str, gateway: PaymentGateway):
127 self._card = card_number
128 self._pin = pin
129 self._gateway = gateway
130
131 def method_type(self) -> str:
132 return "DEBIT_CARD"
133
134 def validate(self) -> bool:
135 digits = self._card.replace("-", "").replace(" ", "")
136 return len(digits) == 16 and len(self._pin) == 4
137
138 def charge(self, amount: Decimal) -> GatewayResponse:
139 return self._gateway.charge(amount, {"card_last4": self._card[-4:]})
140
141 def refund(self, txn_id: str, amount: Decimal) -> GatewayResponse:
142 return self._gateway.refund(txn_id, amount)
143
144
145 class UPIMethod(PaymentMethod):
146 def __init__(self, vpa: str, gateway: PaymentGateway):
147 self._vpa = vpa
148 self._gateway = gateway
149
150 def method_type(self) -> str:
151 return "UPI"
152
153 def validate(self) -> bool:
154 return "@" in self._vpa and len(self._vpa) > 3
155
156 def charge(self, amount: Decimal) -> GatewayResponse:
157 return self._gateway.charge(amount, {"vpa": self._vpa})
158
159 def refund(self, txn_id: str, amount: Decimal) -> GatewayResponse:
160 return self._gateway.refund(txn_id, amount)
161
162
163 class NetBankingMethod(PaymentMethod):
164 def __init__(self, bank_code: str, gateway: PaymentGateway):
165 self._bank_code = bank_code
166 self._gateway = gateway
167
168 def method_type(self) -> str:
169 return "NET_BANKING"
170
171 def validate(self) -> bool:
172 return len(self._bank_code) >= 3
173
174 def charge(self, amount: Decimal) -> GatewayResponse:
175 return self._gateway.charge(amount, {"bank": self._bank_code})
176
177 def refund(self, txn_id: str, amount: Decimal) -> GatewayResponse:
178 return self._gateway.refund(txn_id, amount)
179
180
181 # ---- Fraud Checker (Chain of Responsibility) ----
182
183 @dataclass
184 class PaymentRequest:
185 user_id: str
186 amount: Decimal
187 method: PaymentMethod
188 idempotency_key: str
189
190
191 @dataclass(frozen=True)
192 class FraudResult:
193 approved: bool
194 reason: str
195
196
197 class FraudChecker(ABC):
198 def __init__(self):
199 self._next: FraudChecker | None = None
200
201 def set_next(self, checker: "FraudChecker") -> "FraudChecker":
202 self._next = checker
203 return checker
204
205 def check(self, request: PaymentRequest) -> FraudResult:
206 result = self._do_check(request)
207 if not result.approved:
208 return result
209 if self._next:
210 return self._next.check(request)
211 return FraudResult(True, "All checks passed")
212
213 @abstractmethod
214 def _do_check(self, request: PaymentRequest) -> FraudResult: ...
215
216
217 class AmountLimitChecker(FraudChecker):
218 def __init__(self, max_amount: Decimal):
219 super().__init__()
220 self._max = max_amount
221
222 def _do_check(self, request: PaymentRequest) -> FraudResult:
223 if request.amount > self._max:
224 return FraudResult(False,
225 f"Amount ${request.amount} exceeds limit ${self._max}")
226 return FraudResult(True, "Amount within limit")
227
228
229 class VelocityChecker(FraudChecker):
230 """Rejects if a user makes too many payments in a short window."""
231
232 def __init__(self, max_per_minute: int):
233 super().__init__()
234 self._max = max_per_minute
235 self._recent: dict[str, list[datetime]] = defaultdict(list)
236
237 def _do_check(self, request: PaymentRequest) -> FraudResult:
238 now = datetime.now()
239 cutoff = now - timedelta(minutes=1)
240 recent = [t for t in self._recent[request.user_id] if t > cutoff]
241 self._recent[request.user_id] = recent
242
243 if len(recent) >= self._max:
244 return FraudResult(False,
245 f"Velocity limit: {len(recent)} payments in last minute")
246 self._recent[request.user_id].append(now)
247 return FraudResult(True, "Velocity OK")
248
249
250 class BlacklistChecker(FraudChecker):
251 def __init__(self, blocked_users: set[str]):
252 super().__init__()
253 self._blocked = blocked_users
254
255 def _do_check(self, request: PaymentRequest) -> FraudResult:
256 if request.user_id in self._blocked:
257 return FraudResult(False,
258 f"User {request.user_id} is blacklisted")
259 return FraudResult(True, "User not blacklisted")
260
261
262 # ---- Transaction (with state machine) ----
263
264 class Transaction:
265 def __init__(self, txn_id: str, amount: Decimal, currency: str,
266 method: PaymentMethod, idempotency_key: str, user_id: str):
267 self.id = txn_id
268 self.amount = amount
269 self.currency = currency
270 self.method = method
271 self.status = PaymentStatus.INITIATED
272 self.gateway_txn_id = ""
273 self.idempotency_key = idempotency_key
274 self.user_id = user_id
275 self.created_at = datetime.now()
276 self.history: list[StatusChange] = []
277
278 def transition(self, new_status: PaymentStatus, reason: str = "") -> None:
279 if new_status not in self.status.allowed_transitions():
280 raise InvalidTransitionError(
281 f"{self.status.name} -> {new_status.name} is not allowed"
282 )
283 self.history.append(StatusChange(
284 self.status, new_status, datetime.now(), reason))
285 self.status = new_status
286
287
288 # ---- Observer ----
289
290 class PaymentObserver(ABC):
291 @abstractmethod
292 def on_status_change(self, txn: Transaction,
293 old: PaymentStatus, new: PaymentStatus) -> None: ...
294
295
296 class PaymentLogger(PaymentObserver):
297 def on_status_change(self, txn: Transaction,
298 old: PaymentStatus, new: PaymentStatus) -> None:
299 print(f" [LOG] {txn.id}: {old.name} -> {new.name}")
300
301
302 # ---- Payment Processor (Orchestrator) ----
303
304 class PaymentProcessor:
305 def __init__(self, fraud_chain: FraudChecker):
306 self._payments: dict[str, Transaction] = {}
307 self._idem_store: dict[str, Transaction] = {}
308 self._fraud_chain = fraud_chain
309 self._observers: list[PaymentObserver] = []
310
311 def add_observer(self, obs: PaymentObserver) -> None:
312 self._observers.append(obs)
313
314 def _notify(self, txn: Transaction,
315 old: PaymentStatus, new: PaymentStatus) -> None:
316 for obs in self._observers:
317 obs.on_status_change(txn, old, new)
318
319 def process_payment(self, request: PaymentRequest,
320 currency: str = "USD") -> Transaction:
321 # Idempotency
322 if request.idempotency_key in self._idem_store:
323 print(f" Idempotent hit for key={request.idempotency_key}")
324 return self._idem_store[request.idempotency_key]
325
326 # Validate method
327 if not request.method.validate():
328 raise PaymentError(
329 f"Invalid {request.method.method_type()} details")
330
331 # Fraud check chain
332 fraud_result = self._fraud_chain.check(request)
333 if not fraud_result.approved:
334 raise FraudRejectError(fraud_result.reason)
335
336 # Create transaction
337 txn = Transaction(
338 txn_id=f"TXN-{uuid.uuid4().hex[:8]}",
339 amount=request.amount,
340 currency=currency,
341 method=request.method,
342 idempotency_key=request.idempotency_key,
343 user_id=request.user_id,
344 )
345 self._payments[txn.id] = txn
346 self._idem_store[request.idempotency_key] = txn
347
348 # INITIATED -> PROCESSING
349 old = txn.status
350 txn.transition(PaymentStatus.PROCESSING, "Starting charge")
351 self._notify(txn, old, txn.status)
352
353 # Charge via the method's gateway
354 response = request.method.charge(request.amount)
355
356 if response.success:
357 old = txn.status
358 txn.gateway_txn_id = response.txn_id
359 txn.transition(PaymentStatus.COMPLETED, response.message)
360 self._notify(txn, old, txn.status)
361 else:
362 old = txn.status
363 txn.transition(PaymentStatus.FAILED, response.message)
364 self._notify(txn, old, txn.status)
365
366 return txn
367
368 def refund(self, payment_id: str) -> Transaction:
369 txn = self._payments.get(payment_id)
370 if not txn:
371 raise PaymentError(f"Payment {payment_id} not found")
372 if txn.status != PaymentStatus.COMPLETED:
373 raise PaymentError(
374 f"Cannot refund {txn.status.name} payment")
375
376 response = txn.method.refund(txn.gateway_txn_id, txn.amount)
377 if response.success:
378 old = txn.status
379 txn.transition(PaymentStatus.REFUNDED, response.message)
380 self._notify(txn, old, txn.status)
381 else:
382 raise PaymentError(f"Refund failed: {response.message}")
383 return txn
384
385 def get_payment(self, payment_id: str) -> Transaction | None:
386 return self._payments.get(payment_id)
387
388
389 if __name__ == "__main__":
390 # Build fraud check chain
391 blacklist = BlacklistChecker({"blocked_user"})
392 velocity = VelocityChecker(max_per_minute=5)
393 amount_limit = AmountLimitChecker(Decimal("5000"))
394
395 blacklist.set_next(velocity).set_next(amount_limit)
396
397 # Create processor
398 processor = PaymentProcessor(fraud_chain=blacklist)
399 processor.add_observer(PaymentLogger())
400
401 stripe = StripeGateway()
402 razorpay = RazorpayGateway()
403
404 print("=== Payment System ===\n")
405
406 # 1. Credit card payment
407 print("1. Credit card payment of $250...")
408 req1 = PaymentRequest(
409 user_id="alice", amount=Decimal("250"),
410 method=CreditCardMethod("4111-1111-1111-1111", "12/27", "123", stripe),
411 idempotency_key="pay-001",
412 )
413 txn1 = processor.process_payment(req1)
414 print(f" Status: {txn1.status.name}")
415 print(f" Gateway ID: {txn1.gateway_txn_id}\n")
416
417 # 2. UPI payment
418 print("2. UPI payment of Rs 1500...")
419 req2 = PaymentRequest(
420 user_id="bob", amount=Decimal("1500"),
421 method=UPIMethod("bob@oksbi", razorpay),
422 idempotency_key="pay-002",
423 )
424 txn2 = processor.process_payment(req2, currency="INR")
425 print(f" Status: {txn2.status.name}\n")
426
427 # 3. Fraud rejection (amount too high)
428 print("3. Attempting $8000 payment (limit is $5000)...")
429 req3 = PaymentRequest(
430 user_id="alice", amount=Decimal("8000"),
431 method=CreditCardMethod("4111-1111-1111-1111", "12/27", "123", stripe),
432 idempotency_key="pay-003",
433 )
434 try:
435 processor.process_payment(req3)
436 except FraudRejectError as e:
437 print(f" Fraud rejected: {e}\n")
438
439 # 4. Blacklisted user
440 print("4. Blacklisted user tries to pay...")
441 req4 = PaymentRequest(
442 user_id="blocked_user", amount=Decimal("50"),
443 method=UPIMethod("bad@upi", razorpay),
444 idempotency_key="pay-004",
445 )
446 try:
447 processor.process_payment(req4)
448 except FraudRejectError as e:
449 print(f" Fraud rejected: {e}\n")
450
451 # 5. Idempotent retry
452 print("5. Retrying payment with key=pay-001...")
453 retry = processor.process_payment(req1)
454 print(f" Same transaction? {retry.id == txn1.id}\n")
455
456 # 6. Refund
457 print("6. Refunding payment 1...")
458 refunded = processor.refund(txn1.id)
459 print(f" Status: {refunded.status.name}\n")
460
461 # 7. Invalid transition
462 print("7. Trying illegal transition on refunded payment...")
463 try:
464 txn1.transition(PaymentStatus.COMPLETED, "hack")
465 except InvalidTransitionError as e:
466 print(f" Blocked: {e}\n")
467
468 # 8. Full lifecycle history
469 print("8. Payment 1 lifecycle:")
470 for sc in txn1.history:
471 print(f" {sc.from_status.name} -> {sc.to_status.name}: {sc.reason}")Common Mistakes
- ✗Hardcoding payment methods with if/else chains. Every new method means modifying the processor, which means risk and merge conflicts.
- ✗Running fraud checks after charging the customer. Validate before you touch their money, not after.
- ✗Using a free-form status string instead of an enum with enforced transitions. You will eventually see FAILED payments magically become COMPLETED.
- ✗Missing idempotency on the charge endpoint. Network retries are a fact of life, and without key-based deduplication, users get double-charged.
Key Points
- ✓Strategy for payment methods: each method validates itself and talks to its own gateway. Adding a new method never touches PaymentProcessor.
- ✓Chain of responsibility for fraud checks. Each checker either approves the payment and passes it along, or rejects it. New rules are new links in the chain.
- ✓State machine on Transaction enforces legal transitions. PROCESSING cannot jump to REFUNDED. FAILED cannot become COMPLETED.
- ✓Idempotency keys prevent double charges. If a key was already processed, return the existing result without re-executing.