Skip to main content

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 PaymentProcessor or 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.

CheckoutPaymentProcessorPaymentMethodTransactioncharge(request)1lookup(idempotencyKey)2new Transaction(PENDING)3charge(amount)4markStatus(result)5
  1. 1The caller passes an amount, a method, and an idempotency key - nothing method-specific.
  2. 2Before doing anything else, the processor checks whether this exact request already ran.
  3. 3A fresh request gets a transaction record before the network call, so a crash mid-charge is still visible.
  4. 4The processor hands off to the chosen strategy without knowing how it validates or connects.
  5. 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.

markStatus() [processor succeeded]markStatus() [processor failed]PENDINGSUCCESSFAILED
Click a state to see its legal transitions.

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

«interface»PaymentMethod+ validate(): bool+ charge(amount): ChargeResultPaymentProcessor- transactions: Map<string, Transaction>+ charge(request: ChargeRequest): TransactionChargeRequest- amount: double- method: PaymentMethod- idempotencyKey: stringTransaction- id: string- amount: double- status: TransactionStatus- idempotencyKey: stringCardPaymentMethod+ validate(): bool+ charge(amount): ChargeResultWalletPaymentMethod+ validate(): bool+ charge(amount): ChargeResultUpiPaymentMethod+ validate(): bool+ charge(amount): ChargeResult
implementsuses
PaymentProcessor delegates the actual charge to a PaymentMethod strategy and looks up idempotency keys before ever charging twice.

Code

import java.util.*;
 
enum TransactionStatus { PENDING, SUCCESS, FAILED }
 
class ChargeResult {
final boolean success;
final String reason;
 
ChargeResult(boolean success, String reason) {
this.success = success;
this.reason = reason;
}
}
 
interface PaymentMethod {
boolean validate();
ChargeResult charge(double amount);
}
 
class CardPaymentMethod implements PaymentMethod {
private final String cardNumber;
 
CardPaymentMethod(String cardNumber) {
this.cardNumber = cardNumber;
}
 
public boolean validate() {
return cardNumber != null && cardNumber.replaceAll("\\s", "").length() == 16;
}
 
public ChargeResult charge(double amount) {
if (!validate()) return new ChargeResult(false, "invalid card number");
return new ChargeResult(true, "card network authorized");
}
}
 
class WalletPaymentMethod implements PaymentMethod {
private double balance;
 
WalletPaymentMethod(double balance) {
this.balance = balance;
}
 
public boolean validate() {
return balance >= 0;
}
 
public ChargeResult charge(double amount) {
if (balance < amount) return new ChargeResult(false, "insufficient balance");
balance -= amount;
return new ChargeResult(true, "wallet debited");
}
}
 
class UpiPaymentMethod implements PaymentMethod {
private final String vpa;
 
UpiPaymentMethod(String vpa) {
this.vpa = vpa;
}
 
public boolean validate() {
return vpa != null && vpa.contains("@");
}
 
public ChargeResult charge(double amount) {
if (!validate()) return new ChargeResult(false, "invalid VPA");
return new ChargeResult(true, "UPI collect request approved");
}
}
 
class ChargeRequest {
final double amount;
final PaymentMethod method;
final String idempotencyKey;
 
ChargeRequest(double amount, PaymentMethod method, String idempotencyKey) {
this.amount = amount;
this.method = method;
this.idempotencyKey = idempotencyKey;
}
}
 
class Transaction {
final String id;
final double amount;
final String idempotencyKey;
TransactionStatus status;
 
Transaction(String id, double amount, String idempotencyKey) {
this.id = id;
this.amount = amount;
this.idempotencyKey = idempotencyKey;
this.status = TransactionStatus.PENDING;
}
}
 
class PaymentProcessor {
private final Map<String, Transaction> transactionsByKey = new HashMap<>();
private int nextId = 1;
 
Transaction charge(ChargeRequest request) {
Transaction existing = transactionsByKey.get(request.idempotencyKey);
if (existing != null) return existing;
 
Transaction txn = new Transaction("txn-" + (nextId++), request.amount, request.idempotencyKey);
transactionsByKey.put(request.idempotencyKey, txn);
 
ChargeResult result = request.method.charge(request.amount);
txn.status = result.success ? TransactionStatus.SUCCESS : TransactionStatus.FAILED;
return txn;
}
}

Design decisions

  • PaymentMethod is 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 each PaymentMethod. 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.
  • Transaction records 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 PENDING transactions 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 call method.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 PENDING transaction's processor call times out with no answer - what should a retry do? It hits the same idempotency key, finds the existing PENDING transaction, and returns it rather than charging again - but per the page's own gap analysis, resolving that PENDING to SUCCESS/FAILED needs webhook-driven reconciliation, since nothing here revisits a stuck transaction on its own.
  • How would you add a refund? A new operation on PaymentProcessor that looks up the original Transaction and asks its PaymentMethod to reverse the charge - PaymentMethod would need a refund() alongside charge(), 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 in charge() can determine. Validation and authorization are different questions answered at different times.

Check yourself

Question 1 of 4

Why does PaymentProcessor never inspect the concrete type of a PaymentMethod?