Payment Gateway
A checkout flow that charges a card, a wallet, or UPI through the same call, and never double-charges a customer just because the network hiccupped on the first attempt.
Requirements
Functional
- A caller charges a customer a given amount using a chosen payment method (card, wallet, UPI).
- Each payment method has its own validation and its own way of talking to a processor.
- A charge either succeeds, fails, or is left pending if the processor doesn't answer in time.
- A caller can retry a charge without risking the customer being billed twice for the same logical request.
Non-functional
- Adding a new payment method must not require changing
PaymentProcessoror any existing method's code. - Retrying a failed or unknown-status charge must be safe to call any number of times.
Design
PaymentProcessor takes a PaymentMethod and a Transaction and does exactly one thing:
ask the method to charge, then record what happened. Every method-specific validation and
network call lives inside that method's own class - the processor never asks "is this a
card?" anywhere in its code.
- 1The caller passes an amount, a method, and an idempotency key - nothing method-specific.
- 2Before doing anything else, the processor checks whether this exact request already ran.
- 3A fresh request gets a transaction record before the network call, so a crash mid-charge is still visible.
- 4The processor hands off to the chosen strategy without knowing how it validates or connects.
- 5The transaction is updated to SUCCESS or FAILED once the method returns.
Idempotency is what makes retry safe: every Transaction carries an idempotency key, and
the processor checks for a prior transaction with that key before charging again, so a
retried request either replays the original result or proceeds exactly once.
SUCCESS and FAILED are both terminal - once markStatus() sets one, nothing in this
code ever changes it again, which is exactly why a retry has to create idempotency
protection around a new charge attempt rather than expecting the original Transaction
to somehow un-fail. A PENDING transaction that never receives a markStatus() call (the
processor call timed out with no answer) is the one case the diagram cannot show: it just
never leaves PENDING, which is the reconciliation gap the design decisions below call
out explicitly.
Class diagram
Code
Design decisions
PaymentMethodis a Strategy, chosen by the caller, not inspected by the processor. A card, a wallet, and UPI validate completely differently (card number checks vs. wallet balance vs. a UPI handle format) - forcing that into one class would mean one giant method with a branch per method instead of three small ones that each know only their own rules.- Idempotency lives in
PaymentProcessor, not in eachPaymentMethod. Whether a retry should re-charge is a property of the transaction, not of how money moves, so checking it once in the processor means every payment method gets safe retries without writing any retry logic itself. Transactionrecords a status transition, it never gets deleted or reused. A failed transaction stays failed; a retry creates a lookup against the same idempotency key rather than mutating the old record, so the transaction log stays an honest audit trail of what was actually attempted.- What's missing for a real system: this models a synchronous charge; a production
gateway would need a webhook-driven reconciliation path for
PENDINGtransactions the processor never got a final answer for, and would persist the idempotency key table durably rather than in an in-memory map, since a retry after a crash still has to see it.
Common follow-ups
- Two requests with the same idempotency key arrive at nearly the same instant - what
breaks?
charge()'s "check existing, then create and store" sequence isn't atomic, so two threads could both miss the existing-transaction check and both callmethod.charge(), double-charging the customer. A real implementation needs the lookup-and-insert to be one atomic operation - a unique constraint on the idempotency key, or a compare-and-swap. - A
PENDINGtransaction's processor call times out with no answer - what should a retry do? It hits the same idempotency key, finds the existingPENDINGtransaction, and returns it rather than charging again - but per the page's own gap analysis, resolving thatPENDINGtoSUCCESS/FAILEDneeds webhook-driven reconciliation, since nothing here revisits a stuck transaction on its own. - How would you add a refund? A new operation on
PaymentProcessorthat looks up the originalTransactionand asks itsPaymentMethodto reverse the charge -PaymentMethodwould need arefund()alongsidecharge(), since only the concrete method (card network, wallet, UPI) knows how to undo its own charge. - Why can't
CardPaymentMethod.validate()alone guarantee a charge will succeed?validate()only checks the card number's shape; it says nothing about funds or issuer declines, which only the actual network call incharge()can determine. Validation and authorization are different questions answered at different times.
Check yourself
Why does PaymentProcessor never inspect the concrete type of a PaymentMethod?