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.

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.
0%0 of 122 pages studied