Core
Billing
Razorpay Integration

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_signature

Constant-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

7. Env vars & config

VarRequiredPurposeWhat breaks
RAZORPAY_KEY_IDyesPlatform account key id, sent to FE to open checkout modal/subscribe returns 500; FE cannot open Razorpay modal
RAZORPAY_KEY_SECRETyesPlatform account secret, used for Order create + HMAC verifyEvery verify fails with mismatched signature
NEXT_PUBLIC_RAZORPAY_KEY_IDyes (FE)Mirror of KEY_ID exposed to browserCheckout 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 currentPeriodEnd passes, the cron flips the row to expired; the user must manually hit /subscribe again, 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/subscription route, and none is needed for Orders API. Don't add one looking for subscription.charged; it will never fire.
  • Signature must be verified server-side — the FE receives razorpay_signature from the Razorpay modal. Treating the modal's success callback as proof-of-payment without /verify would let an attacker POST a fake payment_id and get a free subscription. Always HMAC-check before mutating UserSubscription.status.
  • Idempotency is verify-only-once, not create-only-once/subscribe can 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 leaves pending SubscriptionPayment rows. Debounce the FE button and dedupe by recent pending row if this becomes noisy.
  • Double-submit of /verify — if the FE retries the verify call, the second call re-updates UserSubscription to the same state and inserts no duplicate (or duplicates a succeeded SubscriptionPayment if not guarded). Check for an existing succeeded row with the same razorpay_payment_id before inserting.
  • Amount is in paiseplan.monthlyPrice is rupees (Decimal). orders.create expects paise (integer). Multiply by 100 and Math.round — floats + Decimal have bitten this before.
  • Receipt must be unique per Order — Razorpay rejects duplicate receipts within 24h. Use sub_${userId}_${Date.now()}, not sub_${userId}_${planSlug}.
  • Shared RazorpayService, separate keys — both billing and buyer-checkout import the same class. Billing passes process.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_due is dead — the enum has it, no code path sets it. Failed payments go straight to failed; the user is left on the previous status until cron expiry.

9. Extension points

  • Migrate to Razorpay Subscriptions API — true recurring. Requires: creating plan + subscription resources on Razorpay, adding a /webhooks/razorpay handler for subscription.charged / subscription.halted, persisting razorpay_subscription_id on UserSubscription, and retiring the manual re-subscribe flow. Biggest migration risk: existing in-flight subs need a cutover date.
  • Refunds — not implemented. Add a /refund admin endpoint that calls razorpay.payments.refund(paymentId, amount) and flips SubscriptionPayment.status=refunded (new enum value).
  • Orphaned pending cleanup — nightly job to mark pending rows older than 30min as failed (Razorpay Orders expire after ~15min anyway).
  • Proration on upgrade — today /upgrade swaps the plan without charging the delta. Compute (newPrice - oldPrice) * daysRemaining/periodDays and create a one-off Order.

10. Related docs