A payment gateway
you can watch fail on purpose.
PaySim Town is a real HTTP-idempotent payment gateway and a digital bank with holds, capture and refund — double-entry ledgers per store, a hand-rolled circuit breaker, and a crash injector that can kill the process mid-payment — wrapped in a pixel-art town where customers walk to shops, envelopes cross a bridge to the bank, and a storm cloud forms when the breaker opens.
The point isn’t the game. It’s that nothing here ever double-charges, loses money, or lies about what happened — even when the bank is offline or the JVM dies between two writes. Every animation on screen is driven by a server-sent event; the frontend holds no business state of its own.
An engineering study of payment failure handling
Underneath the pixel art is a Java 21 + Spring Boot 4 service with a real gateway/bank split over loopback HTTP — not an in-process shortcut — so a timeout is a genuine socket timeout, and the gateway’s outbound call and the bank’s inbound handling get separate trace spans, the way a production gateway-to-acquirer call actually looks.
Every mutating call is replay-safe
Every mutating endpoint requires an Idempotency-Key. The same key and
body replay the stored response byte-for-byte; a different body with the same key is
rejected; a key still in flight gets a retry hint instead of a second charge.
Deterministic keys to the bank
Every gateway→bank call carries a key derived from the payment itself, never a fresh UUID per attempt — so retries, lease takeovers and reconciler re-drives all hit the bank as the exact same operation.
Double-entry, append-only, per store
Every business event is a balanced journal entry — postings must sum to Σ = 0 — and that invariant is unrepresentable to break in memory, not just checked at write time.
A hand-rolled circuit breaker
Closed, open and half-open states live in a sealed interface with CAS transitions and no lock, each guarded by a generation so a stale probe can never record an outcome against a breaker that has already moved on.
A crash injector that kills the JVM
A simulated crash can be thrown at named points inside the payment and ledger flow — after the bank approves but before the gateway records it, for example — and every one of those windows is provably recoverable.
A timeout is an unknown outcome
If the bank call times out, the payment is never guessed into failed. It stays in-flight until a reconciler asks the bank what actually happened and resolves it from the answer, not from an assumption.
Watch the bank go down
A bank-outage scenario drives every mutating call toward a sliding-window failure rate. Once it crosses the threshold, the breaker opens.
Retry sits outside the breaker: each attempt counts toward its failure window, and once it’s open, new payments fail fast in milliseconds instead of waiting out a retry budget. A half-open probe tests the water with a capped number of requests before the breaker decides whether to close again.
Backoff between attempts uses full jitter across the whole window, so retrying customers desynchronize instead of hammering the bank in lockstep the moment it recovers.
Send it twice on purpose
The Inspector narrates one payment end to end, from the customer’s counter to the bank and back, with every step annotated by the event that produced it.
A duplicate submission with the same idempotency key never reaches the bank a second time — the gateway claims the key once, and the second request gets the first request’s stored response back, byte for byte.
Three independent risk checks — fraud score, customer velocity, store ticket limit — run concurrently with one shared deadline, so a slow check can’t silently extend how long a customer waits at the counter.
A back office for the same data
Press C in the running app to leave the town for the back office —
a keyset-paged CRM over customers, banks, stores and payments, reading the same ledgers
live.
Built to lean on the JDK, not route around it
Every customer, every SSE writer and the ledger poster run on their own virtual
thread. Mutual exclusion goes through ReentrantLock, never
synchronized, because a pinned virtual thread on JDK 21 defeats the point of
having thousands of cheap ones.
make demo builds the frontend and
starts the Java service on localhost:8080. It isn’t deployed anywhere
public; this page exists to show what it does.