Skip to content

Latest commit

 

History

3 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Recoup: Failed Payment Recovery and Dunning Planner for Subscriptions

Keywords: failed payment recovery, dunning emails, subscription churn, retry failed payment, revenue recovery, razorpay failed payments, stripe invoice payment_failed, payment retry schedule, dunning stages, subscription renewal failure

Give Recoup a failed charge. It picks the next retry, the dunning stage, and drafts the customer message. It does not send the message, and it does not move money.

CI License: MIT Python 3.11+

Use it in two minutes

git clone https://github.com/pandeyvishwas51-oss/recoup.git
cd recoup
python3 -m venv .venv && source .venv/bin/activate
pip install -e .
recoup demo

That command reads the sample failed payment, prints the next retry, and prints a drafted customer email. No API key. With no key, Recoup stays on the rule-based policy and templates.

Set one model name and the provider key to turn on the optional model loop, then run the same command. LiteLLM picks the provider from LLM_MODEL.

LLM LLM_MODEL Key
OpenAI gpt-4o-mini or gpt-4o OPENAI_API_KEY
Anthropic claude-opus-5 or claude-3-5-haiku-latest ANTHROPIC_API_KEY
Google gemini/gemini-2.0-flash GEMINI_API_KEY
OpenRouter openrouter/openai/gpt-4o-mini OPENROUTER_API_KEY
Local Ollama ollama/llama3.2 none (Ollama must be running)
vLLM hosted_vllm/<model-name> none, or OPENAI_API_KEY if the server expects one
# OpenAI
export LLM_MODEL=gpt-4o-mini
export OPENAI_API_KEY=sk-your-key

# Anthropic
export LLM_MODEL=claude-opus-5
export ANTHROPIC_API_KEY=sk-ant-your-key

# Local Ollama (no key)
export LLM_MODEL=ollama/llama3.2

recoup demo

python examples/demo.py is the same sample through the Python API.

What is Recoup?

Recoup is a small Python planner for failed payment recovery and failed subscription renewals. Payment gateways tell you that a charge died. Recoup decides what to do next: retry the same method, ask the customer to update it, escalate a fraud signal, or stop. It also drafts the email or SMS for that step.

v0 is decide-and-draft only. Your own code sends the email and talks to the gateway when you are ready.

Piece Role
PolicyEngine Pure retry clock and dunning stages. No API key. This is the demoable core.
RecoveryAgent Optional model loop (any LLM via LiteLLM and LLM_MODEL) for tone and odd cases. Falls back to templates with no key.
SimulatedAdapter Reads failed events from a JSON file. The demo path. No live gateway.
Stripe / Razorpay stubs Test-mode keys only. Map a webhook payload to PaymentEvent. Do not list or charge.
CLI recoup demo, recoup simulate, and recoup plan print the plan and the draft.

Direct answer

How do I recover a failed subscription payment? Load the failed charge as a PaymentEvent plus a Customer, then call PolicyEngine().plan(...). On a soft decline (for example insufficient_funds) attempt 1, the default plan retries in 1 hour and drafts a soft reminder. Attempt 2 waits 24 hours with a firmer ask. Attempt 3 waits 72 hours with a final notice. Attempt 4 closes recovery. Run recoup simulate examples/events.json to see this on fake data.

Does Recoup charge the card or email the customer? No. v0 never captures a payment, never creates a charge, and never sends email, SMS, or WhatsApp. record_attempt is local bookkeeping. Take the RecoveryPlan and Message and send them from your own system.

Can I use this with Razorpay or Stripe? Yes, as data. Map a payment.failed or invoice.payment_failed payload with from_payment_entity / from_stripe_object, then run the planner. The adapter classes refuse live keys (rzp_live_, sk_live_) and do not call the network in v0. The working demo is the simulated JSON adapter.

Why Recoup?

Gateways already retry some declines. Billing suites already dunn. Recoup sits in the gap: a file you can read, a policy you can unit-test without a key, and a draft you can edit before anything reaches a customer.

Stripe Smart Retries Hosted dunning (Chargebee and similar) Homegrown cron Recoup
Works across gateways Stripe only That suite's gateways If you write it Yes, through adapters
Retry schedule you can test offline No Partial Yes Yes (policy.py, no key)
Dunning copy (soft / firm / final) You write it Templates in the suite You write it Drafted here, not sent
Fraud / stolen card Radar / rules Rules You write it Escalate, no customer email
Moves money in v0 Gateway does Suite does Your cron does Never
Needs an API key to demo Yes Yes No No

Tradeoffs: Recoup will not cancel a subscription, refund, or capture. The Stripe and Razorpay classes are stubs that check test-mode keys and map payloads. They do not pull a live failed-payment list. If you already like your billing suite's dunning, keep it.

How it works

Everything below is what the code does, not a wish list.

  1. Input. A FailedPayment is a PaymentEvent, a Customer, and optional RecoveryAttempt history. Amounts are minor units (paise, cents).
  2. Classify. classify_failure(code, reason) maps the gateway string to soft, hard, fraud, or unknown. Soft includes insufficient_funds, do_not_honor, processing_error. Hard includes expired_card, incorrect_cvc, authentication_required. Fraud includes stolen_card, lost_card, fraudulent. Unknown is treated as retryable.
  3. Attempt and stage. event.attempt_number (or, if it is 0, one plus prior retries in history) picks a stage from (soft, firm, final) and a delay from (1.0, 24.0, 72.0) hours. PolicyConfig can replace both tuples and max_attempts (default 4).
  4. Decide.
    • Fraud: escalate now, channel internal, no customer notify.
    • attempt >= max_attempts: close, plus a final notice.
    • Hard: notify at the current stage. Same-method retry is skipped.
    • Soft or unknown: retry at now + delay, plus a notify at that stage. The plan also lists later retries for display. The engine is meant to be re-run on each new failure.
  5. VIP. risk_flags contains vip, or ltv >= 10 * amount. Then max_attempts becomes 5 so a fourth failure still retries. Turn this off with PolicyConfig(extra_vip_attempt=False).
  6. Copy. draft_message fills a template for the stage and channel (email or sms). Escalations become an internal note addressed to ops.
  7. Optional model. RecoveryAgent always runs the policy first. If a provider key is set (or LLM_MODEL is a local Ollama / vLLM model), it starts a tool-use loop (get_event, get_customer, run_policy, submit_recovery) through LiteLLM on LLM_MODEL (default gpt-4o-mini). A refusal or content-filter stop, a missing SDK, a missing key, --no-agent, or any exception falls back to the templates. The model is for judgment and tone, not for moving money.
  8. Output. CLI prints the plan and the draft. SimulatedAdapter.record_attempt appends to in-memory history. Nothing is sent.
failed event  ->  classify  ->  stage + delay
                      |              |
                      v              v
              fraud / hard / soft   retry clock
                      |              |
                      v              v
              RecoveryPlan  +  drafted Message
                      |
                      v
              your integration sends (not v0)

Install

Python 3.11 or newer.

git clone https://github.com/pandeyvishwas51-oss/recoup.git
cd recoup
python3 -m venv .venv && source .venv/bin/activate
pip install -e ".[dev]"
pytest -q

Runtime dependency: litellm. The policy engine, templates, simulated adapter, and CLI --no-agent path do not call the network.

Quick start

Simulated demo (no keys)

recoup demo
recoup simulate examples/events.json
recoup plan examples/event.json
recoup --json --no-agent plan examples/event.json

examples/events.json covers a first insufficient-funds renewal, a second generic decline, an expired card, a stolen card, an exhausted retry, a VIP fourth attempt, and a 3-D Secure authentication_required failure.

Python, policy only

from datetime import datetime, timezone
from recoup import PolicyEngine, PaymentEvent, Customer

now = datetime(2026, 8, 21, 12, 0, tzinfo=timezone.utc)
event = PaymentEvent(
    id="pay_soft_first",
    amount=49900,
    currency="INR",
    failure_code="insufficient_funds",
    customer_id="cus_ada",
    occurred_at=now,
    subscription_id="sub_pro_ada",
    attempt_number=1,
)
customer = Customer(
    id="cus_ada",
    name="Ada Lovelace",
    email="ada@example.com",
    plan_name="Pro monthly",
)
plan = PolicyEngine().plan(event, customer, now=now)
print(plan.next_action.kind, plan.next_action.delay_hours, plan.rationale)
# retry 1.0  soft decline (insufficient_funds) on attempt 1 of 4; retry in 1.0h and send a soft email

Draft a message (still not sent)

from recoup import RecoveryAgent

result = RecoveryAgent(force_templates=True).recover(event, customer, now=now)
print(result.message.subject)
print(result.message.body)

Set LLM_MODEL and the provider key if you want the model loop to draft instead of the templates. Use --no-agent on the CLI to force templates even when a key is present.

Map a Stripe or Razorpay payload

from recoup.adapters.stripe import from_stripe_object
from recoup.adapters.razorpay import from_payment_entity
from recoup import PolicyEngine

item = from_stripe_object(stripe_payment_intent_dict)
plan = PolicyEngine().plan(item.event, item.customer)

item = from_payment_entity(razorpay_payment_dict)
plan = PolicyEngine().plan(item.event, item.customer)

StripeAdapter and RazorpayAdapter read STRIPE_SECRET_KEY (sk_test_...) and RAZORPAY_KEY_ID / RAZORPAY_KEY_SECRET (rzp_test_...). Live prefixes are rejected. list_failed raises on purpose in v0. Put test values in a git-ignored .env, never in source.

Change the schedule

from recoup import PolicyConfig, PolicyEngine

config = PolicyConfig(
    retry_schedule_hours=(2.0, 48.0),
    max_attempts=3,
    stages=("soft", "final"),
)
plan = PolicyEngine(config).plan(event, customer)

What your integration should do (not shipped)

  1. Run Recoup on each failed-payment event.
  2. If next_action.kind == "retry", create the retry on the gateway yourself at scheduled_at.
  3. If there is a customer Message with channel email or sms, send it yourself.
  4. If kind == "escalate", open a ticket. Do not email the cardholder.
  5. If kind == "close", pause or cancel in your billing system, using your own rules.

FAQ

What Python versions are supported?

Python 3.11+. Tests run on 3.11, 3.12, and 3.13 in CI.

Do I need an LLM key?

No. The policy engine, templates, and recoup demo run without one. A key is only for the optional tool-use loop. Local Ollama needs no key: set LLM_MODEL=ollama/llama3.2.

Which model does the optional loop use?

Whatever you put in LLM_MODEL. The default is gpt-4o-mini (recoup.DEFAULT_MODEL). LiteLLM routes OpenAI, Anthropic, Google, OpenRouter, Ollama, vLLM, and other providers from that string. A refusal or content-filter stop is treated as a fallback to templates, not as a crash.

Why did a stolen card not email the customer?

Fraud-class codes escalate. The draft is an internal note. Sending a "please update your card" email on a stolen-card decline is the wrong default.

Why did an expired card not retry?

Hard declines will not succeed on the same method. Recoup asks the customer to update it. authentication_required is also hard: the customer has to finish 3-D Secure.

How do I add another gateway?

Subclass GatewayAdapter, implement list_failed and record_attempt, and map the provider payload onto PaymentEvent / Customer. Keep charges and sends out of that mapping.

Can Recoup cancel a subscription when attempts run out?

Not in v0. The plan status becomes exhausted and the draft says access will pause. You cancel or pause in your billing system.

Are tests offline?

Yes. CI unsets LLM_MODEL, provider keys, and the gateway keys. The LLM layer is mocked at litellm.completion. The Stripe and Razorpay tests cover key checks and payload mapping only.

How do I run tests?

pip install -e ".[dev]"
pytest -q

License

MIT. Free for commercial and personal use.

About

Open-source agent that recovers failed payments and failed subscription renewals. A deterministic dunning policy engine plus a Claude agent for the judgment calls. Gateway-agnostic (Razorpay, Stripe, or your own).

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages