Skip to content

B2C Subscription Lifecycle (Razorpay recurring)

Recurring B2C billing: auto-debit monthly until the user cancels, with self-serve cancel, pause, and resume. This moves B2C checkout from one-time Razorpay Orders (a "monthly" purchase that bought a 30-day window and silently lapsed) to Razorpay Subscriptions (Plans + mandate + auto-charge), adds the lifecycle webhooks, and ships a Manage Subscription settings surface.

How it works

  • Recurring engine: Razorpay Plans + create_subscription/cancel/pause/resume/fetch client; POST /payments/subscriptions; GET /me/subscription.
  • Lifecycle webhooks are the source of truth: subscription.activated/charged/cancelled/paused/resumed/halted/completed/pending + refund.processed, const-map dispatched and idempotent. Activation is webhook-driven with no client callback verify.
  • Cancel takes effect at cycle end (keep Pro until period end, then free). Pause is capped at 30 days, then auto-resumes via cron. Re-subscribe creates a new Razorpay subscription, since cancel is terminal in Razorpay.
  • Statuses: none (free/never subscribed), pending, active, pending_cancellation, paused, halted, cancelled, expired, refunded. Only activated/charged promote a pending row to active, so abandoned checkouts never look subscribed.
  • No in-app refund button: refunds are ops-only via the Razorpay dashboard, applied reactively through refund.processed. No 14-day money-back.
  • Migration: new buyers go recurring; existing one-time-window holders keep their window (is_legacy_one_time), then re-subscribe.
  • UI: a B2C-only Manage Subscription tab in Settings (state-aware panel, cancel/pause dialogs with reason capture, recurring-checkout launcher) plus a Billing FAQ on the public Pricing page. The old Profile-tab plan card was removed.

Out-of-order webhook delivery is handled (handlers apply state from the event, not assumed transitions). Ownership/authz is enforced on every mutation (B2C role + the subscription belongs to the caller).

Security hardening

Reviewed against Razorpay's Best Practices, Subscriptions, and Webhook-validation docs. Each behavior below is enforced on this branch:

Area Behavior
Plan-cache integrity Cache writes the plan value (pro), not a status; _cache_paid_plan/_downgrade_plan_cache applied symmetrically across all status transitions
Webhook signature HMAC-SHA256 of the raw body with the webhook secret, before JSON parse; reject (400) on mismatch
Webhook replay Reject events older than _WEBHOOK_MAX_AGE_SECONDS (300s) via created_at, on top of x-razorpay-event-id dedup
Amount tampering validate_webhook_amount runs first; a mismatch returns early without activating or extending
Signature confusion Order signature (order_id\|payment_id) differs from subscription signature (payment_id\|subscription_id); verify_payment_signature is documented order-only
Rate limiting @limiter.limit("3/minute") on create + cancel/pause/resume
Idempotency Event-id dedup; per-charge idempotency on razorpay_payment_id; create_subscription_for_user reuses an existing pending row
Info leak Missing config raises SubscriptionConfigError → 503 generic; only genuine user-input errors return 400 with a message
Input strictness ConfigDict(extra="forbid") + Literal/length bounds on subscription + payment schemas
Secrets in logs payment_id removed from logs; logs carry only subscription_id; key_secret never logged or returned
Frontend type safety status is z.enum(SUBSCRIPTION_STATUSES) + nullish guards; typed Razorpay constructor union; useRef in-flight guard against double-submit

Before production

  1. Seed Razorpay Plans; set razorpay_plan_* in GCP Secret Manager (else create_subscription returns 503).
  2. Wire auto_resume_expired_pauses to Cloud Scheduler → internal endpoint (TODO(KWILO-600)).
  3. Browser-test the full flow in Razorpay test mode.
  4. recurringSubscriptions flag is on (tab visible to B2C); flip off to gate it again before plans are seeded.

Where it lives

Surfaces: app.kwilo.ai Settings → Manage Subscription; kwilo.ai Pricing FAQ. Endpoints: POST /payments/subscriptions, GET /me/subscription, POST /webhooks/razorpay (lifecycle events).