B2C payments: Razorpay upgrade flow¶
Razorpay-backed one-time orders let a free B2C user upgrade to Pro (₹399/mo, ₹3,990/yr) from any paywall surface. The core contract: activation happens on the webhook, not on the client /verify response. The client-side verify is UI-only. B2B users are unaffected; pricing is two-tier (see b2c-two-tier-pricing.md).
How it works¶
Client asks the backend for an order, opens Razorpay Checkout, the user pays via UPI/card/netbanking, Razorpay fires the payment.captured webhook, the backend verifies the signature, dedupes on event id, writes a user_subscriptions row, and flips users.b2c_plan + b2c_plan_expires_at.
Client Backend Razorpay
│── POST /orders ─────▶│── create_order(notes{uid})──▶│
│ │◀──── order_id, key_id ──────│
│◀── { order_id, key } ─│ │
├── Checkout modal ───────────────────────────────────▶│
│◀── signed { payment_id, order_id, signature } ──────│
│── POST /verify ──────▶│ UI confirmation only │
│◀── { ok: true } ──────│ │
│ │◀── POST /webhooks/razorpay ──│ payment.captured
│ │ 1. verify signature │ (+ x-razorpay-event-id)
│ │ 2. INSERT processed_events │
│ │ (IntegrityError = dup → 200)
│ │ 3. fetch_order, match notes.user_id
│ │ 4. INSERT user_subscriptions
│ │ 5. UPDATE users.b2c_plan, b2c_plan_expires_at
│ │── 200 OK ───────────────────▶│
Key behaviors:
- Activation on webhook, not
/verify. Webhooks are the only signal that survives a network drop between browser and backend; Razorpay retries with exponential backoff for 24 hours./verifyonly confirms the signature so the success screen is trustworthy. - One-time orders, not the Subscriptions API. The user re-initiates renewal at expiry. This avoids UPI AutoPay mandate friction and gives a sharper month-2 retention signal than auto-renew inertia.
- Idempotency at the DB edge.
processed_razorpay_events.event_idis UNIQUE; a duplicate webhook raisesIntegrityError, caught and returned as 200. Belt-and-braces:user_subscriptions.razorpay_order_idis also UNIQUE, so an order can activate at most one subscription row. - Order ownership via
notes.user_id. Orders carrynotes.user_id = <authenticated user id>;/verifyand the webhook re-fetch the order and assert it matches the caller. Thenotespayload is signed by Razorpay, so it can't be tampered with client-side.
Data model¶
Two new tables, two new columns on users. Full DDL in migrations 00047 and 00048.
processed_razorpay_events users user_subscriptions
id UUID pk id user_id FK→users.id
event_id UNIQUE ← webhook dedup b2c_plan NEW plan_id (pro_monthly)
event_type b2c_plan_expires_at NEW plan (pro; legacy 'student' grandfathered)
payload_receipt billing_cycle (monthly | annual)
received_at fast-read cache for period_start / period_end
/me/quota: hot-path status (active | expired | cancelled)
reads the users row, razorpay_order_id UNIQUE
not user_subscriptions. razorpay_payment_id
amount_paise, currency
activated_by_event_id, activated_at
user_subscriptions is the audit trail, one row per paid period. The UNIQUE razorpay_order_id blocks double-activation even if event-id dedup misses.
Known gaps (deferred)¶
Refund flow, proration on mid-cycle change, dunning, GST invoices, in-app receipts, and the auto-recurring Subscriptions API are not built. A reconciliation cron for orphan "paid but not activated" orders, a state-machine guard on user_subscriptions.status, Sentry alerts on webhook failures, and a dev-mode test shortcut are tracked in #570. Today a double-charge writes two rows (two months of plan), refunded manually via the Razorpay dashboard.
Reference¶
API endpoints:
| Method | Endpoint | Purpose |
|---|---|---|
| POST | /api/v1/payments/orders |
Create a Razorpay order for a plan. Rate-limited 5/min/user. |
| POST | /api/v1/payments/verify |
UI-only signature confirmation. Does not activate. |
| POST | /api/v1/webhooks/razorpay |
Webhook intake. Idempotent. Activates plan on payment.captured. |
Plan catalog (hard-coded in core/b2c_plans.py):
| plan_id | monthly (paise) | annual (paise) |
|---|---|---|
pro_monthly |
39,900 (₹399) | 3,99,000 (₹3,990) |
The retired student_monthly plan (₹199) was removed from PLAN_CATALOG; validate_webhook_amount returns False for it. Existing b2c_plan='student' users are grandfathered by effective_plan() until b2c_plan_expires_at.
Where it lives¶
- Backend:
apps/backend/src/api/v1/payments.py(the three endpoints),services/razorpay_client.py(SDK wrapper, signature verify),services/b2c_subscription.py(activate_from_webhook),models/razorpay_event.py,models/user_subscription.py,schemas/payments.py, migrations00047+00048. - Frontend:
apps/web/src/pages/b2c/components/PaywallModal.tsx(Checkout entry);apps/mobile/src/screens/PaywallScreen.tsx(scaffold, #570 follow-up).