Razorpay Integration (Billing)
How Eziseller charges sellers for their own SaaS subscription via Razorpay. One-shot payments using the Orders API — not Razorpay Subscriptions. Distinct from the buyer-checkout Razorpay flow in
public-catalog.ts.
1. Overview
Eziseller sells monthly/yearly plans (Starter, Growth, Professional) to sellers. Payment is collected as a single Razorpay Order per billing period using the Orders API, confirmed by HMAC signature verification on the server. There is no Razorpay Subscription resource, no auto-renewal, and no subscription webhook — each renewal is a fresh user-initiated /subscribe call that creates a new Order (see subscription-lifecycle.md). The platform's single RazorpayService (backend/lib/razorpay-service.ts) is shared with the buyer-side checkout flow (see ../../02-integrations/shipping/shipping-overview.md siblings and razorpay-payments buyer doc) — the two callers differ only in which keys they use and what they persist.
2. Architecture
The FE never trusts the payment success callback from Razorpay — the server re-derives the signature from order_id|payment_id with the secret and must match before activating the subscription.
3. Data model
See schema.prisma:L1437-L1456. SubscriptionPayment.transactionId holds the razorpay_payment_id; metadata stores the razorpay_order_id and receipt. Currently only read by the admin revenue dashboard.
4. Key flows
4.1 Subscribe → pay → verify
4.2 Signature verification
Identical to buyer-side code. From razorpay-service.ts:L39-L51:
HMAC_SHA256( `${razorpay_order_id}|${razorpay_payment_id}`, KEY_SECRET ) === razorpay_signatureConstant-string order matters — do not swap the pipe order. Compared with === (not timingSafeEqual); acceptable because the compared values are both server-derived hex strings of equal length, but prefer crypto.timingSafeEqual for defence-in-depth.
5. Lifecycle (per payment)
orphaned is not a DB state — it's a pending row whose Order was never paid. No cleanup job exists; these accumulate in SubscriptionPayment.
6. Key files
- backend/lib/razorpay-service.ts:L22-L34 —
createOrder(amount, currency, receipt); amount is in paise/cents - backend/lib/razorpay-service.ts:L39-L51 —
verifySignature, HMAC SHA256 overorder_id|payment_id - backend/routes/subscriptions.ts:L87-L256 —
/subscribe(trial eligibility, eligibility guards, Order creation) - backend/routes/subscriptions.ts:L107-L138 — eligibility guards (active or cancelled-in-period block new orders)
- backend/prisma/schema.prisma:L1437-L1456 —
SubscriptionPaymentmodel - backend/routes/public-catalog.ts:L382-L460 — different caller: buyer checkout, uses seller's per-store keys, not platform keys
7. Env vars & config
| Var | Required | Purpose | What breaks |
|---|---|---|---|
RAZORPAY_KEY_ID | yes | Platform account key id, sent to FE to open checkout modal | /subscribe returns 500; FE cannot open Razorpay modal |
RAZORPAY_KEY_SECRET | yes | Platform account secret, used for Order create + HMAC verify | Every verify fails with mismatched signature |
NEXT_PUBLIC_RAZORPAY_KEY_ID | yes (FE) | Mirror of KEY_ID exposed to browser | Checkout modal fails to mount |
Platform-billing keys are distinct from the per-seller keys stored on Store (used by buyer checkout). Never cross-wire them.
8. Gotchas & troubleshooting
- No auto-renewal — Razorpay Subscriptions API is not used. When
currentPeriodEndpasses, the cron flips the row toexpired; the user must manually hit/subscribeagain, which creates a fresh Order. Do not assume a returning user's second payment is a renewal of the same DB row. - No subscription webhook — there is no
/webhooks/razorpay/subscriptionroute, and none is needed for Orders API. Don't add one looking forsubscription.charged; it will never fire. - Signature must be verified server-side — the FE receives
razorpay_signaturefrom the Razorpay modal. Treating the modal's success callback as proof-of-payment without/verifywould let an attacker POST a fakepayment_idand get a free subscription. Always HMAC-check before mutatingUserSubscription.status. - Idempotency is verify-only-once, not create-only-once —
/subscribecan be clicked twice and will create two Razorpay Orders. The user only pays one (the modal they complete); the other Order stays unpaid. Safe, but leavespendingSubscriptionPaymentrows. Debounce the FE button and dedupe by recentpendingrow if this becomes noisy. - Double-submit of
/verify— if the FE retries the verify call, the second call re-updatesUserSubscriptionto the same state and inserts no duplicate (or duplicates a succeededSubscriptionPaymentif not guarded). Check for an existingsucceededrow with the samerazorpay_payment_idbefore inserting. - Amount is in paise —
plan.monthlyPriceis rupees (Decimal).orders.createexpects paise (integer). Multiply by 100 andMath.round— floats + Decimal have bitten this before. - Receipt must be unique per Order — Razorpay rejects duplicate receipts within 24h. Use
sub_${userId}_${Date.now()}, notsub_${userId}_${planSlug}. - Shared
RazorpayService, separate keys — both billing and buyer-checkout import the same class. Billing passesprocess.env.RAZORPAY_KEY_*; buyer-checkout passes the seller's own store-level keys. Log which caller created a given Order when debugging a mystery charge. past_dueis dead — the enum has it, no code path sets it. Failed payments go straight tofailed; the user is left on the previous status until cron expiry.
9. Extension points
- Migrate to Razorpay Subscriptions API — true recurring. Requires: creating
plan+subscriptionresources on Razorpay, adding a/webhooks/razorpayhandler forsubscription.charged/subscription.halted, persistingrazorpay_subscription_idonUserSubscription, and retiring the manual re-subscribe flow. Biggest migration risk: existing in-flight subs need a cutover date. - Refunds — not implemented. Add a
/refundadmin endpoint that callsrazorpay.payments.refund(paymentId, amount)and flipsSubscriptionPayment.status=refunded(new enum value). - Orphaned
pendingcleanup — nightly job to markpendingrows older than 30min asfailed(Razorpay Orders expire after ~15min anyway). - Proration on upgrade — today
/upgradeswaps the plan without charging the delta. Compute(newPrice - oldPrice) * daysRemaining/periodDaysand create a one-off Order.
10. Related docs
- subscription-lifecycle.md — state machine, cron, why there's no webhook
- usage-limits.md — what an active subscription unlocks
- ../auth/subscription-gating.md —
requireSubscriptionmiddleware - ../../02-integrations — see
razorpay-payments.mdfor the buyer-checkout flow (different caller, same SDK) - ../../06-ops/cron-and-jobs.md — expiry cron