A non-custodial failed-payment recovery (dunning) engine for subscription businesses.
Money is charged through the merchant's own payment gateway — Recoup never touches funds and never sees a card number. It exists to recover the recurring revenue that would otherwise be lost to failed payments, without you re-platforming your billing.
Every subscription business quietly loses revenue to failed recurring payments — expired cards, insufficient funds, temporary bank declines. Left alone, those failures become involuntary churn.
Most recovery tools ask you to move your subscriptions onto their platform and let their system move the money. Recoup takes the opposite stance: it is a thin, money-safe layer that plugs into the gateway and billing you already run, retries failed charges intelligently, and reconciles every attempt so a customer is never double-charged.
| Recoup | |
|---|---|
| Custody | Non-custodial — the charge runs on the merchant's own gateway; funds never flow through us |
| Card data | PAN is never stored, logged, or serialized — only tokenized card_token_ref |
| Correctness | Double-charging is prevented by design, not by hope (see invariants below) |
| Portability | Gateway-agnostic core — a new provider is a new adapter, nothing else changes |
| Infra | PostgreSQL is the database and the job queue and the scheduler — no Redis/Kafka/RabbitMQ |
Payment recovery is unforgiving: a bug doesn't produce a wrong pixel, it charges a real customer twice. These invariants are enforced in code, schema, and tests — not left to discipline:
- Write-ahead. The attempt row (
IN_FLIGHT+ a uniqueconversation_id) is committed before the gateway is called. The network call happens outside any transaction; the result lands in a separate one. - Unknown result ⇒ never re-charge. On a timeout or ambiguous response the engine asks the
gateway for the truth (
retrieve) instead of retrying blindly. - At most one success per invoice — a partial unique index (
… WHERE state = 'SUCCEEDED') makes a second successful charge impossible at the database level. - Append-only ledger. Every state change appends a row; there is no update/delete surface on the event log at all.
- PAN never; secrets sealed. Gateway credentials are protected with envelope encryption (AES-GCM, per-key versioning); card numbers never enter a table, log, or DTO.
- Kill switch. A merchant flagged
kill_switch = truecannot emit a single charge — checked on every charge path.
A modular monolith (Spring Modulith) where each module is internally layered as clean/hexagonal
ports & adapters. The dependency arrow always points inward — adapter → application → domain — and
the domain is pure Java (no framework imports). All of this is enforced by ArchUnit tests, so the
architecture can't quietly rot.
flowchart LR
subgraph Ingress
API[Failure API] --> ING
WH[Gateway webhook] --> ING
CSV[CSV import] --> ING
end
ING[ingestion] --> CLS[classification]
CLS --> RTR[retry engine]
RTR -->|charge| GW{{PaymentGatewayPort}}
GW -->|iyzico adapter| IYZ[(iyzico)]
RTR --> REC[reconciliation]
REC -->|retrieve / truth| GW
RTR --> RCV[recovery comms]
RCV --> MAIL[(email / SMS)]
DASH[dashboard API] --> DB[(PostgreSQL)]
RTR --> DB
ING --> DB
Modules: tenant · ingestion · classification · retry · reconciliation · recovery ·
dashboard · shared. They talk to each other only through public API interfaces and Spring
application events — never by reaching into each other's internals.
Gateway-agnostic core. No provider name or SDK appears in the core domain. Everything
iyzico-specific lives behind PaymentGatewayPort in its own adapter package; provider-specific
decline codes are normalized to a shared vocabulary the rest of the system understands. Adding
PayTR, Craftgate, or Stripe means writing one adapter that passes the same GatewayContractTest.
See ARCHITECTURE.md for the full design and the numbered invariants the code
refers to.
- Ingest a failed payment — via the REST API, a gateway webhook, or a CSV upload.
- Classify the decline (soft / hard / unknown) and open a recovery case.
- Retry on a per-merchant schedule; each charge is write-ahead logged before the gateway call.
- Reconcile anything uncertain — the sweeper asks the gateway for the real outcome rather than re-charging, and a daily job cross-checks the books.
- Recover with templated email/SMS nudges carrying signed card-update and opt-out links.
- Observe everything from the dashboard: cases, attempts, the ledger, and kill-switch controls.
- Language / runtime: Java 21 with virtual threads
- Framework: Spring Boot 3, Spring Modulith
- Data: PostgreSQL (database +
FOR UPDATE SKIP LOCKEDqueue + scheduler), Flyway migrations - Build & test: Maven, JUnit 5, Testcontainers (incl. fault-injection tests), ArchUnit
- Frontend: React + TypeScript dashboard
- Delivery: single Docker image; GitHub Actions CI
Prerequisites: JDK 21, Docker, Maven.
# Run the full test suite (spins up PostgreSQL via Testcontainers)
mvn verify# Run locally — the `local` profile uses in-memory fake gateway & comms,
# so no external iyzico/Resend calls are made.
docker compose upConfiguration is environment-driven; copy infra/.env.example and fill in the
blanks. Nothing secret is committed.
Every module ships unit and Testcontainers integration tests. The retry and reconciliation paths — the heart of the product — carry fault-injection tests that crash, time out, and duplicate on purpose to prove the double-charge count stays at zero.
The MVP (ingestion → classification → retry → reconciliation → recovery → dashboard) is complete.
The forward plan — thin SDKs, webhook relay, live-gateway hardening, a working card-update flow,
recovery-ROI analytics, a second gateway with failover-based recovery, and smart retry timing — is
in ROADMAP.md.
MIT © 2026 Mert Topal.