← All projects

Case 01 — Payments

Payment Gateway & Ledger Service

A Razorpay-inspired gateway. The feature list is unremarkable — charge a card, refund it, settle it. The engineering is entirely about two questions: can this charge happen twice, and can this event get lost?

Role
Solo — designed and built it myself
Stack
Java, Spring Boot, PostgreSQL, Kafka, Redis
Focus
Idempotency, exactly-once delivery, encryption at rest
Status
Personal project, built 2025 — not a production system

The lifecycle

Every payment is a state machine, not a row you update

The naive version of a payments table has a status column that gets overwritten. That design loses history, permits illegal jumps, and makes disputes unanswerable. Instead a payment moves through explicit states, and each transition is appended to an audit trail: who triggered it, when, and what the previous state was.

The practical benefit is that illegal transitions become impossible rather than unlikely. A settled payment cannot be captured again, because the state machine has no such edge.

capture settle refund CREATED CAPTURED SETTLED REFUNDED

Four states, four legal transitions. Refund is terminal from captured; nothing returns to created.


Hard problem 01

A customer clicks pay twice

This is the defining problem of a payments API. The client has no idea whether its first request succeeded — the response may have been lost after the charge committed — so it retries. Without protection, the retry is a second charge, and the customer finds out on their statement.

What I built

A servlet filter in front of the payment endpoints, backed by Redis, that treats the client's idempotency key as the unit of truth:

  • On arrival, the filter attempts to acquire a lock on the key. If it can't, a request with that key is already in flight and the duplicate is rejected rather than queued.
  • If the key has a stored response, that response is replayed verbatim — same body, same status. The second click gets the first charge's answer.
  • If the key is new, the request proceeds, and the response is written back against the key before the lock is released.

The important detail is that the lock is taken before any work happens, not after. An idempotency check that runs after the charge is a race with a friendlier name.

POST /payments Idempotency-Key: ord_88f21c ├─ lock(ord_88f21c) ── held? ──▶ 409 duplicate in flight ├─ get(ord_88f21c) ── hit? ──▶ replay stored 201 response └─ miss ─▶ charge ─▶ store(ord_88f21c, response) ─▶ unlock

Hard problem 02

The event that never left

Once a payment is captured, other things must happen: a ledger entry, a receipt, a webhook to the merchant. The obvious implementation publishes to Kafka right after the database commit. The obvious implementation is a dual-write bug.

If the service dies between the commit and the publish, the payment exists and the event does not. Nothing retries it, because nothing knows it was owed. Move the publish inside the transaction instead and you get the mirror-image failure: the event is sent for a transaction that then rolls back.

The transactional outbox

The fix is to make the event part of the same commit. The payment row and an outbox row are written in one transaction, so they succeed or fail together. A separate relay polls the outbox, publishes to Kafka, and marks rows dispatched — retrying until the broker acknowledges.

This buys at-least-once delivery, which means consumers must tolerate duplicates. That's a fair trade: duplicate delivery is a solvable problem on the consumer side, whereas a lost event is unrecoverable.

ONE DATABASE TRANSACTION payments outbox poll relay publish kafka ack dispatched

The event is committed as data, then delivered as a message. The relay is free to crash and retry — the outbox row is still there.


Handling card data

A vault, because raw card numbers shouldn't be anywhere

I wanted the card-handling path to reflect how a real gateway is constrained, so I built a PCI-inspired vault rather than storing anything usable. Card details are encrypted with AES-GCM under envelope encryption — a per-record data key, itself encrypted by a master key — and the rest of the system only ever sees a token.

Because AES-GCM is authenticated, tampering with ciphertext fails verification instead of decrypting into garbage. Rate limiting on the sensitive endpoints runs as a Redis Lua script, so the check-and-increment happens atomically inside Redis rather than as two round trips a burst of traffic can slip between.

What I'd do next

  • Replace outbox polling with logical decoding off the WAL, so delivery latency stops depending on poll interval.
  • Add a reconciliation job that walks the ledger against the payment states and reports drift, rather than trusting that it can't happen.
  • Move key management to a real KMS, since a master key in application config is the weakest link in the current design.
Back to

All projects

Next case

Distributed AI App Builder →