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. Onlyactivated/chargedpromote apendingrow toactive, 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¶
- Seed Razorpay Plans; set
razorpay_plan_*in GCP Secret Manager (elsecreate_subscriptionreturns 503). - Wire
auto_resume_expired_pausesto Cloud Scheduler → internal endpoint (TODO(KWILO-600)). - Browser-test the full flow in Razorpay test mode.
recurringSubscriptionsflag 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).