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.
git clone https://github.com/pandeyvishwas51-oss/recoup.git
cd recoup
python3 -m venv .venv && source .venv/bin/activate
pip install -e .
recoup demoThat 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 |
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 demopython examples/demo.py is the same sample through the Python API.
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. |
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.
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.
Everything below is what the code does, not a wish list.
- Input. A
FailedPaymentis aPaymentEvent, aCustomer, and optionalRecoveryAttempthistory. Amounts are minor units (paise, cents). - Classify.
classify_failure(code, reason)maps the gateway string tosoft,hard,fraud, orunknown. Soft includesinsufficient_funds,do_not_honor,processing_error. Hard includesexpired_card,incorrect_cvc,authentication_required. Fraud includesstolen_card,lost_card,fraudulent. Unknown is treated as retryable. - 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.PolicyConfigcan replace both tuples andmax_attempts(default 4). - Decide.
- Fraud:
escalatenow, channelinternal, no customer notify. attempt >= max_attempts:close, plus a final notice.- Hard:
notifyat the current stage. Same-method retry is skipped. - Soft or unknown:
retryatnow + 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.
- Fraud:
- VIP.
risk_flagscontainsvip, orltv >= 10 * amount. Thenmax_attemptsbecomes 5 so a fourth failure still retries. Turn this off withPolicyConfig(extra_vip_attempt=False). - Copy.
draft_messagefills a template for the stage and channel (emailorsms). Escalations become an internal note addressed toops. - Optional model.
RecoveryAgentalways runs the policy first. If a provider key is set (orLLM_MODELis a local Ollama / vLLM model), it starts a tool-use loop (get_event,get_customer,run_policy,submit_recovery) through LiteLLM onLLM_MODEL(defaultgpt-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. - Output. CLI prints the plan and the draft.
SimulatedAdapter.record_attemptappends 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)
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 -qRuntime dependency: litellm. The policy engine, templates, simulated adapter, and CLI --no-agent path do not call the network.
recoup demo
recoup simulate examples/events.json
recoup plan examples/event.json
recoup --json --no-agent plan examples/event.jsonexamples/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.
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 emailfrom 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.
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.
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)- Run Recoup on each failed-payment event.
- If
next_action.kind == "retry", create the retry on the gateway yourself atscheduled_at. - If there is a customer
Messagewithchannelemail or sms, send it yourself. - If
kind == "escalate", open a ticket. Do not email the cardholder. - If
kind == "close", pause or cancel in your billing system, using your own rules.
Python 3.11+. Tests run on 3.11, 3.12, and 3.13 in CI.
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.
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.
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.
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.
Subclass GatewayAdapter, implement list_failed and record_attempt, and map the provider payload onto PaymentEvent / Customer. Keep charges and sends out of that mapping.
Not in v0. The plan status becomes exhausted and the draft says access will pause. You cancel or pause in your billing system.
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.
pip install -e ".[dev]"
pytest -qMIT. Free for commercial and personal use.