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.
45-Minute LLD Playbook
Each phase is what the interviewer expects you to do and say. Concrete steps, not topic hints. Diagrams are what you would sketch on the board.
- 15 min
Clarify scope and lock the process
GoalPin down which payment methods, which fraud checks, and what idempotency guarantee. Then ask whether to lead with a class diagram or jump into code.
Do & Say- ASK·1ASK: Which methods for v1? Then propose: credit card, UPI, net banking for v1. Debit card and crypto as v2. New methods are one Strategy class. Wait for confirmation.
- SAY·2Lock gateways: Stripe and Razorpay as two PaymentGateways behind charge/refund. Treat charge as synchronous. Park async webhooks as a follow-up.
- SAY·3Pin idempotency: Every PaymentRequest carries an idempotency_key. Duplicate within retention returns the original Transaction. Only safe behavior under retries.
- SAY·4Fraud checks in scope: Three checkers in v1: Blacklist, Velocity, AmountLimit. Chain of Responsibility before any gateway call. New rules are new links, not new branches in the processor.
- ASK·5ASK: Sketch the class diagram with PaymentProcessor, Strategy hierarchy, fraud chain, and state machine first, or jump into code? Just budgeting my 40 minutes.
Interviewer is grading: You park crypto, webhooks, partial refunds, multi-currency explicitly to v2 rather than letting scope creep eat your code time. You name idempotency-key as a request field at the start, not as an afterthought when the question comes.
- 25-10 min
Sketch the API and (optionally) the class diagram
GoalLock signatures for process_payment and refund, and name where each pattern lives. Draw the diagram only if the interviewer asked for it.
Do & Say- SAY·1Name abstractions: PaymentProcessor. PaymentMethod with CreditCard, UPI, NetBanking. PaymentGateway with Stripe and Razorpay. FraudChecker with three concretes. Transaction with PaymentStatus state machine. PaymentObserver and PaymentRequest.
- WRITE·2WRITE processor signature: process_payment(request, currency) -> Transaction. Throws FraudRejectError, PaymentError. Returns Transaction with status COMPLETED or FAILED.
- WRITE·3WRITE PaymentStatus map: INITIATED -> PROCESSING -> COMPLETED or FAILED. COMPLETED -> REFUNDED. FAILED and REFUNDED terminal. transition() raises InvalidTransitionError on anything else. Never bypass it.
- WRITE·4WRITE FraudChecker contract: check(request) -> FraudResult. set_next(other) -> other. Returning other lets me chain blacklist.set_next(velocity).set_next(amount_limit) on one line.
- SAY·5Data flow: Request in, idempotency check first. Then method.validate, fraud chain, create Transaction. INITIATED -> PROCESSING, method.charge, COMPLETED or FAILED. Observers fire on every status change.
Interviewer is grading: You write the state transition map cleanly before writing any class. You name the fraud chain order out loud and defend cheapest-check-first (blacklist is a set lookup, velocity is map lookup plus filter, amount-limit is one compare). The diagram, if drawn, separates the four pattern clusters rather than mashing them.
- 325 min
Code in this sequence (bottom-up)
GoalType the code in an order that builds value objects first, then strategies, then chain, then orchestrator. Talk through pattern decisions as you type.
Do & Say- SAY·1Start with enums and exceptions: PaymentStatus enum with allowed_transitions() returning a set per state. PaymentError, FraudRejectError, InvalidTransitionError, DuplicateKeyError. Transition map lives on the enum. (~3 min)
- SAY·2Value objects: StatusChange with from, to, timestamp, reason. GatewayResponse with success, txn_id, message. PaymentRequest and FraudResult, all frozen. Transaction holds StatusChange list for a free audit log. (~2 min)
- SAY·3PaymentGateway interface plus StripeGateway and RazorpayGateway with charge/refund: Stripe rejects amounts over 10000 to simulate failure. Adapter seam: different SDKs, same charge/refund. (~3 min)
- SAY·4PaymentMethod abstract plus CreditCard, DebitCard, UPI, NetBanking: Each holds its own gateway. Validates credentials: 16-digit + 3-4 CVV, @ in VPA, bank code length >=3. Delegates to the gateway. Method owns the gateway choice. (~5 min)
- SAY·5FraudChecker abstract with set_next-returns-other: BlacklistChecker is a set lookup. VelocityChecker uses defaultdict of timestamps, drops entries older than 1min, rejects if over limit. AmountLimitChecker is one compare. Cheapest reject first. (~5 min)
- SAY·6Transaction with transition(): Holds id, amount, currency, method, status starting INITIATED, gateway_txn_id, idempotency_key, user_id, created_at, history. transition() consults allowed_transitions, raises on miss, appends StatusChange. No setter on status. (~3 min)
- SAY·7PaymentObserver interface plus PaymentLogger concrete observer: on_status_change prints. Production seam for webhook firing, analytics, ledger writes. (~1 min)
- SAY·8PaymentProcessor with payments dict, idem store, fraud_chain, observers. process_payment flow: Idempotency first, return existing on hit. Then validate, fraud chain, create Transaction. INITIATED->PROCESSING then notify, charge, COMPLETED or FAILED then notify. refund requires COMPLETED. Idempotency first saves chain cost on retries. (~3 min)
- SAY·9Sanity walk: Alice retries pay-001. First call: fraud runs, charge succeeds, stored. Second call same key: idem hit returns same Transaction without fraud or Stripe. (~1 min)
Interviewer is grading: Idempotency check sits BEFORE fraud and validation so retries are cheap. You name set_next-returns-other as the trick that enables the one-line chain wiring. status mutation only happens through transition(), never direct assignment. Velocity checker prunes old entries on each call rather than letting the map grow unbounded.
- 45 min
Trade-offs, extensions, and wrap-up
GoalName two specific trade-offs, volunteer one extension, close with a one-sentence summary.
Do & Say- SAY·1Trade-off one, sync versus async charge: Today process_payment blocks on charge and transitions to COMPLETED inline. Real Stripe is async: initial call goes to PROCESSING, webhook flips to COMPLETED or FAILED. State machine already supports it.
- SAY·2Trade-off two, fraud chain order: Blacklist first as set lookup, cheapest. Velocity second, amount-limit third. Order minimizes work on rejected payments. Could be config-driven Strategy; for three checkers hardcoded is fine.
- SAY·3Volunteer partial refunds: Add refunded_amount to Transaction. refund(id, amount=None) defaults full, accepts partial as long as total stays under original. COMPLETED -> REFUNDED only when fully refunded. State machine still legal.
- SAY·4Webhook question: WebhookHandler receives gateway POST. Looks up Transaction by gateway_txn_id, transitions PROCESSING -> COMPLETED/FAILED. Dedup on webhook id. Never re-trigger charge.
- SAY·5Close: Strategy for methods. Chain for fraud. State machine for lifecycle. Observer for notifications. Adapter for gateways. Five patterns plus one idempotency-keyed Transaction.
Interviewer is grading: You distinguish sync versus async charge as a deployment choice the state machine already supports rather than as a redesign. You name partial-refund as the extension and explain why it does not break the state machine. You can summarize in one breath.
Code Implementation
from __future__ import annotations
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from decimal import Decimal
from datetime import datetime, timedelta
from enum import Enum, auto
from collections import defaultdict
import uuid
class PaymentError(Exception):
pass
class FraudRejectError(PaymentError):
pass
class InvalidTransitionError(PaymentError):
pass
class DuplicateKeyError(PaymentError):
pass
class PaymentStatus(Enum):
INITIATED = auto()
PROCESSING = auto()
COMPLETED = auto()
FAILED = auto()
REFUNDED = auto()
def allowed_transitions(self) -> set["PaymentStatus"]:
return {
PaymentStatus.INITIATED: {PaymentStatus.PROCESSING},
PaymentStatus.PROCESSING: {PaymentStatus.COMPLETED, PaymentStatus.FAILED},
PaymentStatus.COMPLETED: {PaymentStatus.REFUNDED},
PaymentStatus.FAILED: set(),
PaymentStatus.REFUNDED: set(),
}[self]
@dataclass(frozen=True)
class StatusChange:
from_status: PaymentStatus
to_status: PaymentStatus
timestamp: datetime
reason: str
@dataclass(frozen=True)
class GatewayResponse:
success: bool
txn_id: str
message: str
# ---- Payment Gateway (Adapter) ----
class PaymentGateway(ABC):
@abstractmethod
def charge(self, amount: Decimal, details: dict) -> GatewayResponse: ...
@abstractmethod
def refund(self, txn_id: str, amount: Decimal) -> GatewayResponse: ...
class StripeGateway(PaymentGateway):
def charge(self, amount: Decimal, details: dict) -> GatewayResponse:
# Simulate: amounts over 10000 get declined
if amount > Decimal("10000"):
return GatewayResponse(False, "", "Amount exceeds gateway limit")
txn_id = f"stripe_{uuid.uuid4().hex[:10]}"
return GatewayResponse(True, txn_id, "Charge successful")
def refund(self, txn_id: str, amount: Decimal) -> GatewayResponse:
return GatewayResponse(True, f"ref_{txn_id}", "Refund processed")
class RazorpayGateway(PaymentGateway):
def charge(self, amount: Decimal, details: dict) -> GatewayResponse:
txn_id = f"rzp_{uuid.uuid4().hex[:10]}"
return GatewayResponse(True, txn_id, "UPI charge successful")
def refund(self, txn_id: str, amount: Decimal) -> GatewayResponse:
return GatewayResponse(True, f"ref_{txn_id}", "UPI refund processed")
# ---- Payment Method (Strategy) ----
class PaymentMethod(ABC):
@abstractmethod
def method_type(self) -> str: ...
@abstractmethod
def validate(self) -> bool: ...
@abstractmethod
def charge(self, amount: Decimal) -> GatewayResponse: ...
@abstractmethod
def refund(self, txn_id: str, amount: Decimal) -> GatewayResponse: ...
class CreditCardMethod(PaymentMethod):
def __init__(self, card_number: str, expiry: str,
cvv: str, gateway: PaymentGateway):
self._card = card_number
self._expiry = expiry
self._cvv = cvv
self._gateway = gateway
def method_type(self) -> str:
return "CREDIT_CARD"
def validate(self) -> bool:
digits = self._card.replace("-", "").replace(" ", "")
return len(digits) == 16 and len(self._cvv) in (3, 4)
def charge(self, amount: Decimal) -> GatewayResponse:
return self._gateway.charge(amount, {"card_last4": self._card[-4:]})
def refund(self, txn_id: str, amount: Decimal) -> GatewayResponse:
return self._gateway.refund(txn_id, amount)
class DebitCardMethod(PaymentMethod):
def __init__(self, card_number: str, pin: str, gateway: PaymentGateway):
self._card = card_number
self._pin = pin
self._gateway = gateway
def method_type(self) -> str:
return "DEBIT_CARD"
def validate(self) -> bool:
digits = self._card.replace("-", "").replace(" ", "")
return len(digits) == 16 and len(self._pin) == 4
def charge(self, amount: Decimal) -> GatewayResponse:
return self._gateway.charge(amount, {"card_last4": self._card[-4:]})
def refund(self, txn_id: str, amount: Decimal) -> GatewayResponse:
return self._gateway.refund(txn_id, amount)
class UPIMethod(PaymentMethod):
def __init__(self, vpa: str, gateway: PaymentGateway):
self._vpa = vpa
self._gateway = gateway
def method_type(self) -> str:
return "UPI"
def validate(self) -> bool:
return "@" in self._vpa and len(self._vpa) > 3
def charge(self, amount: Decimal) -> GatewayResponse:
return self._gateway.charge(amount, {"vpa": self._vpa})
def refund(self, txn_id: str, amount: Decimal) -> GatewayResponse:
return self._gateway.refund(txn_id, amount)
class NetBankingMethod(PaymentMethod):
def __init__(self, bank_code: str, gateway: PaymentGateway):
self._bank_code = bank_code
self._gateway = gateway
def method_type(self) -> str:
return "NET_BANKING"
def validate(self) -> bool:
return len(self._bank_code) >= 3
def charge(self, amount: Decimal) -> GatewayResponse:
return self._gateway.charge(amount, {"bank": self._bank_code})
def refund(self, txn_id: str, amount: Decimal) -> GatewayResponse:
return self._gateway.refund(txn_id, amount)
# ---- Fraud Checker (Chain of Responsibility) ----
@dataclass
class PaymentRequest:
user_id: str
amount: Decimal
method: PaymentMethod
idempotency_key: str
@dataclass(frozen=True)
class FraudResult:
approved: bool
reason: str
class FraudChecker(ABC):
def __init__(self):
self._next: FraudChecker | None = None
def set_next(self, checker: "FraudChecker") -> "FraudChecker":
self._next = checker
return checker
def check(self, request: PaymentRequest) -> FraudResult:
result = self._do_check(request)
if not result.approved:
return result
if self._next:
return self._next.check(request)
return FraudResult(True, "All checks passed")
@abstractmethod
def _do_check(self, request: PaymentRequest) -> FraudResult: ...
class AmountLimitChecker(FraudChecker):
def __init__(self, max_amount: Decimal):
super().__init__()
self._max = max_amount
def _do_check(self, request: PaymentRequest) -> FraudResult:
if request.amount > self._max:
return FraudResult(False,
f"Amount ${request.amount} exceeds limit ${self._max}")
return FraudResult(True, "Amount within limit")
class VelocityChecker(FraudChecker):
"""Rejects if a user makes too many payments in a short window."""
def __init__(self, max_per_minute: int):
super().__init__()
self._max = max_per_minute
self._recent: dict[str, list[datetime]] = defaultdict(list)
def _do_check(self, request: PaymentRequest) -> FraudResult:
now = datetime.now()
cutoff = now - timedelta(minutes=1)
recent = [t for t in self._recent[request.user_id] if t > cutoff]
self._recent[request.user_id] = recent
if len(recent) >= self._max:
return FraudResult(False,
f"Velocity limit: {len(recent)} payments in last minute")
self._recent[request.user_id].append(now)
return FraudResult(True, "Velocity OK")
class BlacklistChecker(FraudChecker):
def __init__(self, blocked_users: set[str]):
super().__init__()
self._blocked = blocked_users
def _do_check(self, request: PaymentRequest) -> FraudResult:
if request.user_id in self._blocked:
return FraudResult(False,
f"User {request.user_id} is blacklisted")
return FraudResult(True, "User not blacklisted")
# ---- Transaction (with state machine) ----
class Transaction:
def __init__(self, txn_id: str, amount: Decimal, currency: str,
method: PaymentMethod, idempotency_key: str, user_id: str):
self.id = txn_id
self.amount = amount
self.currency = currency
self.method = method
self.status = PaymentStatus.INITIATED
self.gateway_txn_id = ""
self.idempotency_key = idempotency_key
self.user_id = user_id
self.created_at = datetime.now()
self.history: list[StatusChange] = []
def transition(self, new_status: PaymentStatus, reason: str = "") -> None:
if new_status not in self.status.allowed_transitions():
raise InvalidTransitionError(
f"{self.status.name} -> {new_status.name} is not allowed"
)
self.history.append(StatusChange(
self.status, new_status, datetime.now(), reason))
self.status = new_status
# ---- Observer ----
class PaymentObserver(ABC):
@abstractmethod
def on_status_change(self, txn: Transaction,
old: PaymentStatus, new: PaymentStatus) -> None: ...
class PaymentLogger(PaymentObserver):
def on_status_change(self, txn: Transaction,
old: PaymentStatus, new: PaymentStatus) -> None:
print(f" [LOG] {txn.id}: {old.name} -> {new.name}")
# ---- Payment Processor (Orchestrator) ----
class PaymentProcessor:
def __init__(self, fraud_chain: FraudChecker):
self._payments: dict[str, Transaction] = {}
self._idem_store: dict[str, Transaction] = {}
self._fraud_chain = fraud_chain
self._observers: list[PaymentObserver] = []
def add_observer(self, obs: PaymentObserver) -> None:
self._observers.append(obs)
def _notify(self, txn: Transaction,
old: PaymentStatus, new: PaymentStatus) -> None:
for obs in self._observers:
obs.on_status_change(txn, old, new)
def process_payment(self, request: PaymentRequest,
currency: str = "USD") -> Transaction:
# Idempotency
if request.idempotency_key in self._idem_store:
print(f" Idempotent hit for key={request.idempotency_key}")
return self._idem_store[request.idempotency_key]
# Validate method
if not request.method.validate():
raise PaymentError(
f"Invalid {request.method.method_type()} details")
# Fraud check chain
fraud_result = self._fraud_chain.check(request)
if not fraud_result.approved:
raise FraudRejectError(fraud_result.reason)
# Create transaction
txn = Transaction(
txn_id=f"TXN-{uuid.uuid4().hex[:8]}",
amount=request.amount,
currency=currency,
method=request.method,
idempotency_key=request.idempotency_key,
user_id=request.user_id,
)
self._payments[txn.id] = txn
self._idem_store[request.idempotency_key] = txn
# INITIATED -> PROCESSING
old = txn.status
txn.transition(PaymentStatus.PROCESSING, "Starting charge")
self._notify(txn, old, txn.status)
# Charge via the method's gateway
response = request.method.charge(request.amount)
if response.success:
old = txn.status
txn.gateway_txn_id = response.txn_id
txn.transition(PaymentStatus.COMPLETED, response.message)
self._notify(txn, old, txn.status)
else:
old = txn.status
txn.transition(PaymentStatus.FAILED, response.message)
self._notify(txn, old, txn.status)
return txn
def refund(self, payment_id: str) -> Transaction:
txn = self._payments.get(payment_id)
if not txn:
raise PaymentError(f"Payment {payment_id} not found")
if txn.status != PaymentStatus.COMPLETED:
raise PaymentError(
f"Cannot refund {txn.status.name} payment")
response = txn.method.refund(txn.gateway_txn_id, txn.amount)
if response.success:
old = txn.status
txn.transition(PaymentStatus.REFUNDED, response.message)
self._notify(txn, old, txn.status)
else:
raise PaymentError(f"Refund failed: {response.message}")
return txn
def get_payment(self, payment_id: str) -> Transaction | None:
return self._payments.get(payment_id)
if __name__ == "__main__":
# Build fraud check chain
blacklist = BlacklistChecker({"blocked_user"})
velocity = VelocityChecker(max_per_minute=5)
amount_limit = AmountLimitChecker(Decimal("5000"))
blacklist.set_next(velocity).set_next(amount_limit)
# Create processor
processor = PaymentProcessor(fraud_chain=blacklist)
processor.add_observer(PaymentLogger())
stripe = StripeGateway()
razorpay = RazorpayGateway()
print("=== Payment System ===\n")
# 1. Credit card payment
print("1. Credit card payment of $250...")
req1 = PaymentRequest(
user_id="alice", amount=Decimal("250"),
method=CreditCardMethod("4111-1111-1111-1111", "12/27", "123", stripe),
idempotency_key="pay-001",
)
txn1 = processor.process_payment(req1)
print(f" Status: {txn1.status.name}")
print(f" Gateway ID: {txn1.gateway_txn_id}\n")
# 2. UPI payment
print("2. UPI payment of Rs 1500...")
req2 = PaymentRequest(
user_id="bob", amount=Decimal("1500"),
method=UPIMethod("bob@oksbi", razorpay),
idempotency_key="pay-002",
)
txn2 = processor.process_payment(req2, currency="INR")
print(f" Status: {txn2.status.name}\n")
# 3. Fraud rejection (amount too high)
print("3. Attempting $8000 payment (limit is $5000)...")
req3 = PaymentRequest(
user_id="alice", amount=Decimal("8000"),
method=CreditCardMethod("4111-1111-1111-1111", "12/27", "123", stripe),
idempotency_key="pay-003",
)
try:
processor.process_payment(req3)
except FraudRejectError as e:
print(f" Fraud rejected: {e}\n")
# 4. Blacklisted user
print("4. Blacklisted user tries to pay...")
req4 = PaymentRequest(
user_id="blocked_user", amount=Decimal("50"),
method=UPIMethod("bad@upi", razorpay),
idempotency_key="pay-004",
)
try:
processor.process_payment(req4)
except FraudRejectError as e:
print(f" Fraud rejected: {e}\n")
# 5. Idempotent retry
print("5. Retrying payment with key=pay-001...")
retry = processor.process_payment(req1)
print(f" Same transaction? {retry.id == txn1.id}\n")
# 6. Refund
print("6. Refunding payment 1...")
refunded = processor.refund(txn1.id)
print(f" Status: {refunded.status.name}\n")
# 7. Invalid transition
print("7. Trying illegal transition on refunded payment...")
try:
txn1.transition(PaymentStatus.COMPLETED, "hack")
except InvalidTransitionError as e:
print(f" Blocked: {e}\n")
# 8. Full lifecycle history
print("8. Payment 1 lifecycle:")
for sc in txn1.history:
print(f" {sc.from_status.name} -> {sc.to_status.name}: {sc.reason}")Interview Grading by Level
What an interviewer at each level expects to see in your answer. Use this to calibrate, not to perform.
Junior Engineer (L3)
Names Strategy, Chain, and State correctly and writes a working PaymentMethod hierarchy, but state machine and idempotency stay informal.
- Names Strategy for payment methods and Chain of Responsibility for fraud.
- Writes the PaymentMethod abstract with charge() and refund() methods.
- Implements at least two methods (CreditCard, UPI) wiring to different gateways.
- Adds an idempotency check when asked, even if implemented as a flat dict lookup.
- Recognizes that fraud checks must run before any charge call.
- Models PaymentStatus as a string instead of an enum with allowed_transitions.
- Mutates Transaction.status by direct assignment without going through transition().
- Runs fraud chain before the idempotency check, so retries pay the chain cost.
- VelocityChecker grows its per-user list forever without pruning entries past the window.
- Hardcodes the fraud chain order with no explanation of cheapest-check-first.
Mid-Level Engineer (L4)
Drives the design end-to-end with a real state machine, cheapest-check-first fraud chain, and idempotency as the first line of process_payment.
- Codes PaymentStatus as an enum with allowed_transitions() and a transition() method that raises on illegal moves.
- Idempotency check sits before fraud and method validation, with a clear explanation.
- FraudChecker uses set_next-returns-other so the chain wires up in a single line.
- Adapter pattern is named explicitly for the gateway layer with the two-different-SDK reasoning.
- Observer fires on every status change, not just on COMPLETED.
- Walks through one scenario end-to-end (retry, fraud reject, or successful charge) as a sanity check.
- Does not volunteer partial-refund or async webhook as the natural next extensions.
- Misses the sync-charge-versus-async-webhook distinction unless prompted.
- Treats currency as a single field rather than naming multi-currency as a follow-up.
Senior Engineer (L5+)
Frames each pattern as a defense against a specific failure mode, volunteers extensions before being asked, and names operational concerns the interviewer did not ask about.
- Volunteers partial refunds with the refunded_amount field plus the state machine staying intact, before being asked.
- Volunteers the async-webhook variant and notes the state machine already supports the PROCESSING-to-terminal split.
- Names the idempotency-key retention window (24h or 7d) as an operational choice, not a code choice.
- Defends Chain over a flat list by naming a fourth hypothetical check (geo-velocity) that would force an if/else cascade in the processor.
- Calls out that VelocityChecker should be backed by Redis with TTL in production, not an in-process dict.
- Names the double-entry ledger Observer as the audit trail and acknowledges that observer ordering matters (ledger fires before webhook so reconciliation is safe).
- Closes with a one-sentence summary that names all five patterns and the idempotency key in under 20 seconds.
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.