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.
Class diagram
implementsuses
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.