A local-only Java backend that simulates how a real payment processor moves money safely: payment-correctness mechanics (idempotency keys, refunds, an auditable ledger, an explainable fraud screen) combined with site-reliability engineering (circuit breaker, retry with exponential backoff, dead-letter queue, health/metrics endpoints, structured failure handling).
Money must be correct under failure — that is what this project is built to demonstrate, not to deploy. Everything runs on your machine via Docker.
# 1. Postgres + the API (this is all you need for the API + dashboard)
docker compose up --build
# 2. Open the dashboard
open http://localhost:8080 # live feed + simulator toggles
# or drive the API directly
curl http://localhost:8080/actuator/healthOptional SRE observability stack (Prometheus scraping + Grafana):
docker compose --profile observability up --build
# Grafana http://localhost:3000 (admin / admin) — Prometheus datasource pre-provisioned
# Prometheus http://localhost:9090curl -s http://localhost:8080/payments \
-H 'Content-Type: application/json' \
-H 'Idempotency-Key: demo-order-123' \
-d '{"accountId":"acc_demo","amountMinor":5000,"currency":"GBP","ipAddress":"203.0.113.10"}'Send the same request again and observe the identical response — no second charge.
- Open the dashboard, set Bank simulator → Hard fail.
- Fire a handful of payments from the form — they start failing after retries and land in the Dead-letter queue.
- The header badge flips to circuit: OPEN and new requests are queued instantly instead of hanging.
- Hit Healthy, then press replay on the dead-lettered rows — they settle.
| Component | Real-world concern |
|---|---|
| DB-backed idempotency keys, refunds, immutable ledger | Money movement must be exactly-once and auditable |
| Explainable fraud rules (velocity, amount z-score, geo-jump) | Fraud controls anyone can understand and override |
| Resilience4j circuit breaker + retry/backoff around the bank simulator | Failing fast to protect the system |
/actuator/health, /actuator/prometheus, custom Micrometer metrics |
Monitoring, metrics and alerting |
| Dead-letter table + manual replay | Incident handling — keep business-critical systems moving |
| Failure-injection tests | Verifying correct behaviour under dependency faults |
| Method & path | Purpose |
|---|---|
POST /payments |
Charge (requires Idempotency-Key header) |
GET /payments/{id} |
Check status |
POST /payments/{id}/refund |
Refund a SUCCEEDED payment (once) |
GET /admin/transactions |
Recent-transaction feed (dashboard) |
GET /admin/dead-letter |
Failed transactions awaiting a decision |
POST /admin/dead-letter/{id}/replay |
Re-submit a dead-lettered charge |
POST /admin/payments/{id}/approve |
Reviewer approves a fraud-flagged payment (charges it) |
POST /admin/payments/{id}/decline |
Reviewer voids a fraud-flagged payment (bank never called) |
POST /admin/simulator/mode?mode=... |
NORMAL / HARD_FAIL / TIMEOUT / DROPPED_RESPONSE |
POST /admin/simulator/chaos |
Inject random failures at a rate |
POST /admin/reconciliations/run |
Run the reconciliation job on demand |
POST /admin/demo/seed-account |
Seed a history so the fraud rules can fire |
/actuator/health, /actuator/prometheus, /metrics |
Liveness + Prometheus metrics |
SUCCEEDED · PENDING_REVIEW (fraud, waiting for a human decision) ·
VOIDED (declined in review, never charged) · UNKNOWN (settled, response
lost) · DEAD_LETTERED (needs replay) · FAILED (definitively not charged).
When the fraud engine flags a transaction it routes it to PENDING_REVIEW
before the bank is called, so no money moves until a human decides:
- Approve (
POST /admin/payments/{id}/approve) — the reviewer overrides the flag and the payment is charged now; if the bank is unhealthy the usual dead-letter and reconciliation paths still apply. - Decline (
POST /admin/payments/{id}/decline) — the payment is voided and the bank is never contacted.
A payment request that times out is ambiguous: the acquirer may have settled it just before the reply vanished, in which case retrying double-charges the customer, while not retrying risks losing revenue if it never settled. This is one of the genuinely hard distributed-systems problems in payments, and PayGuard reproduces it on purpose.
The simulated bank settles every charge to its own ledger before producing a
response. In DROPPED_RESPONSE mode the charge is written and the response is
then "lost". The gateway therefore never blindly retries a lost response.
Instead it marks the transaction UNKNOWN and a reconciliation job
(ReconciliationRunner) periodically asks the bank's ledger — the authority —
whether the charge exists for that bankRequestId:
UNKNOWN ──► look up bankRequestId on bank_ledger ──► found ? SUCCEEDED (money moved, charge reference recorded)
└─► not found? FAILED (customer was not charged)
Because the bank is idempotent on bankRequestId, a replayed or reconciled
request can never settle twice. Reconciliation also sweeps orphaned PENDING
rows (a crash between allocating the idempotency key and finishing the request)
into the dead-letter queue so nothing stalls silently.
-
Postgres over NoSQL. Money needs ACID: idempotency is a unique constraint, refunds take a pessimistic lock, and the ledger is append-only. A document store would make "charge exactly once under concurrency" an application-level nightmare.
-
Rules-based fraud engine over ML. Every decision must be explainable to an investigator (and to an interviewer). Each rule contributes a score and a human-readable reason; no black box. Layered signals, not a single verdict.
-
DB-backed idempotency, not an in-memory map. The unique
idempotency_keyon the PENDING row is the lock: exactly one concurrent request ever wins it; losers poll for the stored result. Correct across instances, not just threads. -
Circuit breaker outside the retry loop, counting per attempt. The breaker measures the bank's per-attempt health and opens quickly under a real outage, while retries with exponential backoff absorb transient blips.
CallNotPermittedExceptionis excluded from retries so an open circuit is never hammered. -
Lost responses are not circuit failures. The bank actually succeeded, so
ResponseLostis ignored by both the breaker and the retry policy and handled by reconciliation instead. -
Small local stack on purpose. No Kafka, no Kubernetes — the dead-letter queue is an in-memory pattern backed by a real table, which keeps
docker compose upthe entire story while preserving the engineering idea. -
Testcontainers for integration tests. The happy path is tested, but the tests that matter force each failure mode (timeout, hard failure, lost response, open circuit) and assert the exact final state. If your Docker socket is unavailable, point the suite at a running Postgres instead:
docker compose up -d db PAYGUARD_TEST_DB_URL=jdbc:postgresql://localhost:5433/payguard mvn test
src/main/java/com/payguard/
├── payment/ controller, service, entity, repo (idempotency logic lives here)
├── fraud/ explainable rules engine + mocked IP→region lookup
├── resilience/ Resilience4j circuit breaker + retry config and executor
├── bank/ simulated downstream bank + its own ledger
├── deadletter/ DLQ entity/repo/service with manual replay
├── reconciliation/ scheduled job that resolves UNKNOWN transactions
├── admin/ dashboard feed, simulator controls, seeding
└── metrics/ custom Micrometer counters/timers
src/test/java/ unit (fraud rules) + integration + failure-injection suites
src/main/resources/db/migration/ schema (Flyway)
src/main/resources/static/ the dashboard (single page, no build step)
Java 17 · Spring Boot 3 · Spring Data JPA · PostgreSQL · Flyway · Resilience4j · Micrometer/Prometheus · JUnit 5 + Testcontainers · plain HTML/JS dashboard · Docker Compose.
mvn test runs 19 tests: pure unit tests for the fraud rules (no infra),
plus integration/failure-injection tests that prove — against a real Postgres —
a duplicate idempotency key never double-charges (including two concurrent
requests for the same key), a lost response resolves to exactly one settlement,
and after the circuit opens every queued transaction replays to SUCCEEDED once
the bank recovers.

