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:

  1. MVS-side dunning — build retry orchestration in mvs-pay-extensions, reading Hyperswitch’s GSM table and BillingSubscription state, scheduling retries via Temporal.
  2. 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 with payment_response_hash_key (mirroring the main webhook ingest at routes/webhooks-hyperswitch.ts) and updates BillingSubscription.{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.status transitions: 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: BillingSubscription gains recoveryStatus, lastRecoveryAttempt, recoveryAttemptCount (migration 20261001000000_phase4_revenue_recovery). The webhook handler is the single writer.
  • New webhook: /webhooks/revenue-recovery joins /webhooks/hyperswitch as a HMAC-verified outbound ingest. Both use the same payment_response_hash_key secret.
  • Operational coupling: if Hyperswitch Revenue Recovery has a regression that mis-classifies a final outcome, MVS subscriptions could prematurely flip to UNPAID. Mitigated by alerting on mvs_pay.smart_retry.score_window (Phase 3 SLO carries forward) and an audit trail in lastRecoveryAttempt.

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.