Skip to content
merrttopalPublic

About

Non-custodial failed-payment recovery (dunning) engine - Java 21 + Spring Modulith over PostgreSQL.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

Recoup

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.

Java Spring Boot Spring Modulith PostgreSQL License


The problem

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.

Why it's different

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

Money-accuracy, by design

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:

  1. Write-ahead. The attempt row (IN_FLIGHT + a unique conversation_id) is committed before the gateway is called. The network call happens outside any transaction; the result lands in a separate one.
  2. Unknown result ⇒ never re-charge. On a timeout or ambiguous response the engine asks the gateway for the truth (retrieve) instead of retrying blindly.
  3. At most one success per invoice — a partial unique index (… WHERE state = 'SUCCEEDED') makes a second successful charge impossible at the database level.
  4. Append-only ledger. Every state change appends a row; there is no update/delete surface on the event log at all.
  5. 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.
  6. Kill switch. A merchant flagged kill_switch = true cannot emit a single charge — checked on every charge path.

Architecture

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
Loading

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.

How a recovery flows

  1. Ingest a failed payment — via the REST API, a gateway webhook, or a CSV upload.
  2. Classify the decline (soft / hard / unknown) and open a recovery case.
  3. Retry on a per-merchant schedule; each charge is write-ahead logged before the gateway call.
  4. Reconcile anything uncertain — the sweeper asks the gateway for the real outcome rather than re-charging, and a daily job cross-checks the books.
  5. Recover with templated email/SMS nudges carrying signed card-update and opt-out links.
  6. Observe everything from the dashboard: cases, attempts, the ledger, and kill-switch controls.

Tech stack

  • Language / runtime: Java 21 with virtual threads
  • Framework: Spring Boot 3, Spring Modulith
  • Data: PostgreSQL (database + FOR UPDATE SKIP LOCKED queue + 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

Getting started

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 up

Configuration is environment-driven; copy infra/.env.example and fill in the blanks. Nothing secret is committed.

Testing philosophy

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.

Roadmap

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.

License

MIT © 2026 Mert Topal.

About

Non-custodial failed-payment recovery (dunning) engine - Java 21 + Spring Modulith over PostgreSQL.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages