Skip to content

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. /verify only 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_id is UNIQUE; a duplicate webhook raises IntegrityError, caught and returned as 200. Belt-and-braces: user_subscriptions.razorpay_order_id is also UNIQUE, so an order can activate at most one subscription row.
  • Order ownership via notes.user_id. Orders carry notes.user_id = <authenticated user id>; /verify and the webhook re-fetch the order and assert it matches the caller. The notes payload 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, migrations 00047 + 00048.
  • Frontend: apps/web/src/pages/b2c/components/PaywallModal.tsx (Checkout entry); apps/mobile/src/screens/PaywallScreen.tsx (scaffold, #570 follow-up).