ADR-011: Hand off dunning to Hyperswitch Revenue Recovery
ADR-011: Hand off dunning to Hyperswitch Revenue Recovery
Status
Accepted (2026-05-28)
Context
A merchant on mvs-pay with recurring billing experiences a steady stream of declined renewals. Recovering those declines (dunning) requires choosing when to retry, which connector to retry on, and what retry strategy to use per BIN / MCC / time-of-day pattern. mvs-pay had two paths:
- MVS-side dunning — build retry orchestration in mvs-pay-extensions, reading Hyperswitch’s GSM table and BillingSubscription state, scheduling retries via Temporal.
- Hyperswitch Revenue Recovery — Juspay ships dunning as a first-class feature in v1.123.1. It models the retry plan, BIN-aware retry curves, and connector selection natively, and emits webhook events back to the subscription platform when an attempt resolves.
This is a per-feature instance of the question already answered for the broader stack by ADR-007 (native Hyperswitch bias), but the Revenue Recovery decision deserves its own record because it touches BillingSubscription state — a model MVS owns end-to-end.
Decision
Hand off retry orchestration to Hyperswitch Revenue Recovery. MVS keeps subscription state, billing line items, invoice generation, and the merchant-facing UI. Hyperswitch owns retry timing, connector selection, and the BIN-aware curve.
The handoff seam is two-way:
- MVS → Hyperswitch:
client.revenueRecovery.configure(...)posts the merchant’s retry plan (retry budget, start-after window, max retries, cadence preset).client.revenueRecovery.setSubscriptionPlatform(...)registers the webhook URL Hyperswitch should call when an attempt resolves. - Hyperswitch → MVS: outcome webhooks land at
apps/mvs-pay-extensions/src/routes/webhooks-revenue-recovery.ts. The handler verifies the signature withpayment_response_hash_key(mirroring the main webhook ingest atroutes/webhooks-hyperswitch.ts) and updatesBillingSubscription.{recoveryStatus,lastRecoveryAttempt,recoveryAttemptCount}.
Rationale
Revenue Recovery has access to >20 retry signals MVS would otherwise have to rebuild:
- BIN issuer profile (e.g., capital-one-prepaid declines retry differently than chase-debit).
- MCC-specific retry curves (recurring vs one-shot, healthcare vs SaaS).
- Time-of-day retry windows (Mondays at 09:00 vs Fridays at 17:00).
- Cross-connector decline correlation (if Stripe and Adyen both decline on the same BIN, don’t retry).
- Velocity throttles (don’t hammer the same card 5 times in 1 hour).
Building these MVS-side competes with Juspay’s product roadmap and adds a meaningful ML / heuristic surface to maintain. The native option is also live behind a feature flag, so the cost of trying it is bounded — if it underperforms MVS-side dunning, we can roll the flag back, keep the SDK contract, and swap the implementation underneath.
Consequences
- MVS owns: subscription state machine (
BillingSubscription.statustransitions: ACTIVE → PAST_DUE → UNPAID), billing line items (BillingSubscriptionItem), invoice generation (BillingInvoice), the operator-facing UI for the recovery plan (/revenue-recovery). - Hyperswitch owns: retry timing (curves), connector selection per attempt (the Smart Retries / Dynamic Routing engines apply here too), the recovery-rate analytics endpoint.
- New schema:
BillingSubscriptiongainsrecoveryStatus,lastRecoveryAttempt,recoveryAttemptCount(migration20261001000000_phase4_revenue_recovery). The webhook handler is the single writer. - New webhook:
/webhooks/revenue-recoveryjoins/webhooks/hyperswitchas a HMAC-verified outbound ingest. Both use the samepayment_response_hash_keysecret. - Operational coupling: if Hyperswitch Revenue Recovery has a regression that mis-classifies a
finaloutcome, MVS subscriptions could prematurely flip to UNPAID. Mitigated by alerting onmvs_pay.smart_retry.score_window(Phase 3 SLO carries forward) and an audit trail inlastRecoveryAttempt.
Alternatives considered
- MVS-side dunning — rejected for the native-bias reasons above (ADR-007) and the >20-signal cost. Acceptable to revisit if Hyperswitch Revenue Recovery shows persistent under-performance against an MVS baseline; revisit threshold is “recovery rate < industry baseline by 5+ percentage points sustained for 30 days.”
- Hybrid (MVS schedules, Hyperswitch executes) — rejected because the scheduling decision is exactly where the >20 signals live. Splitting the decision from the execution loses the value of the native engine.
- Third-party dunning vendor (Stripe Smart Retries, Recurly Recover, Churn Buster) — rejected because mvs-pay’s connector strategy is connector-agnostic; binding to a vendor’s dunning engine recreates the lock-in we’re explicitly avoiding.