diff --git a/__tests__/payments/razorpay-productionization.test.ts b/__tests__/payments/razorpay-productionization.test.ts new file mode 100644 index 000000000..09e5d897b --- /dev/null +++ b/__tests__/payments/razorpay-productionization.test.ts @@ -0,0 +1,182 @@ +/** + * #1377 — the two behavioural changes in the Razorpay productionization pass. + * + * 1. A RazorpayX payout that reaches the terminal `failed` state must map to + * FAILED. It used to fall through to the `default` arm and read as PENDING, + * which left the earnings BATCHED against a payout the bank had refused. + * 2. Rotating `RAZORPAY_WEBHOOK_SECRET` must not drop the deliveries signed + * with the old secret during the cutover, because Razorpay disables a + * webhook that fails for 24 hours and lost events cannot be replayed. + * 3. The `X-Payout-Idempotency` header must stay inside the length RazorpayX + * accepts, or the duplicate guard becomes a 400 on every live payout. + */ +import crypto from "node:crypto"; + +import { + isPayoutEventName, + matchRazorpayWebhookSecret, + resolveRazorpayPaymentSecrets, + verifyRazorpaySignature, +} from "@/app/api/webhooks/razorpay/signature"; +import { + boundPayoutIdempotencyKey, + RazorpayPayoutsService, +} from "@/lib/payments/payouts/razorpay-payouts"; + +const RAW_BODY = JSON.stringify({ + event: "payment.captured", + payload: { payment: { entity: { id: "pay_test" } } }, +}); + +function sign(body: string, secret: string): string { + return crypto.createHmac("sha256", secret).update(body).digest("hex"); +} + +describe("RazorpayX payout status mapping", () => { + const service = new RazorpayPayoutsService({ + keyId: "rzp_test_key", + keySecret: "secret", + accountNumber: "2323230000000000", + }); + + it("maps every terminal RazorpayX status to a terminal internal status", () => { + expect(service.mapPayoutStatus("failed")).toBe("FAILED"); + expect(service.mapPayoutStatus("rejected")).toBe("FAILED"); + expect(service.mapPayoutStatus("reversed")).toBe("FAILED"); + expect(service.mapPayoutStatus("cancelled")).toBe("CANCELLED"); + expect(service.mapPayoutStatus("processed")).toBe("COMPLETED"); + }); + + it("keeps the intermediate statuses non-terminal so the reconciler keeps polling", () => { + expect(service.mapPayoutStatus("queued")).toBe("PENDING"); + expect(service.mapPayoutStatus("pending")).toBe("PENDING"); + expect(service.mapPayoutStatus("processing")).toBe("PROCESSING"); + }); +}); + +// The resolver takes the environment as an argument precisely so these cases +// need no process.env mutation and cannot leak into a sibling suite. +describe("Razorpay webhook secret rotation grace", () => { + it("offers only the current secret when no rotation is in flight", () => { + const secrets = resolveRazorpayPaymentSecrets({ + RAZORPAY_WEBHOOK_SECRET: "current_secret", + }); + + expect(secrets).toEqual([{ role: "current", value: "current_secret" }]); + }); + + it("offers nothing at all when the current secret is missing", () => { + // The grace window is an aid to a rotation, never a standalone secret: a + // deployment that has lost the current value must fail loudly rather than + // keep accepting deliveries on the retired one. + expect( + resolveRazorpayPaymentSecrets({ + RAZORPAY_WEBHOOK_SECRET_PREVIOUS: "old_secret", + }), + ).toEqual([]); + expect( + resolveRazorpayPaymentSecrets({ + RAZORPAY_WEBHOOK_SECRET: " ", + RAZORPAY_WEBHOOK_SECRET_PREVIOUS: "old_secret", + }), + ).toEqual([]); + }); + + it("offers the previous secret second, and never duplicates the current one", () => { + expect( + resolveRazorpayPaymentSecrets({ + RAZORPAY_WEBHOOK_SECRET: "new_secret", + RAZORPAY_WEBHOOK_SECRET_PREVIOUS: "old_secret", + }), + ).toEqual([ + { role: "current", value: "new_secret" }, + { role: "previous", value: "old_secret" }, + ]); + + expect( + resolveRazorpayPaymentSecrets({ + RAZORPAY_WEBHOOK_SECRET: "same", + RAZORPAY_WEBHOOK_SECRET_PREVIOUS: "same", + }), + ).toEqual([{ role: "current", value: "same" }]); + }); + + it("accepts a delivery signed with either secret and reports which one matched", () => { + const candidates = resolveRazorpayPaymentSecrets({ + RAZORPAY_WEBHOOK_SECRET: "new_secret", + RAZORPAY_WEBHOOK_SECRET_PREVIOUS: "old_secret", + }); + + expect( + matchRazorpayWebhookSecret( + RAW_BODY, + sign(RAW_BODY, "new_secret"), + candidates, + ), + ).toBe("current"); + expect( + matchRazorpayWebhookSecret( + RAW_BODY, + sign(RAW_BODY, "old_secret"), + candidates, + ), + ).toBe("previous"); + expect( + matchRazorpayWebhookSecret( + RAW_BODY, + sign(RAW_BODY, "attacker_secret"), + candidates, + ), + ).toBeNull(); + }); + + it("rejects a malformed signature instead of throwing out of timingSafeEqual", () => { + expect(() => + verifyRazorpaySignature(RAW_BODY, "not-a-hex-digest", "current_secret"), + ).not.toThrow(); + expect( + verifyRazorpaySignature(RAW_BODY, "not-a-hex-digest", "current_secret"), + ).toBe(false); + expect( + verifyRazorpaySignature( + RAW_BODY, + sign(RAW_BODY, "current_secret").slice(0, 63), + "current_secret", + ), + ).toBe(false); + }); + + it("only classifies payout.* bodies as eligible for the RazorpayX secret", () => { + expect( + isPayoutEventName(JSON.stringify({ event: "payout.processed" })), + ).toBe(true); + expect(isPayoutEventName(RAW_BODY)).toBe(false); + expect(isPayoutEventName("{ not json")).toBe(false); + }); +}); + +describe("RazorpayX payout idempotency header", () => { + // Both real key shapes overshoot the gateway's 36-character ceiling, so the + // bound is what stands between a deduplicated retry and a rejected payout. + const orgKey = "payout_11111111-2222-4333-8444-555555555555"; + const consultantKey = + "payout_11111111-2222-4333-8444-555555555555_batch_1756900000000_abcdef12"; + + it("keeps a key the gateway already accepts", () => { + expect(boundPayoutIdempotencyKey("payout_ckv1v0h8n0000abcdefghijkl")).toBe( + "payout_ckv1v0h8n0000abcdefghijkl", + ); + }); + + it("folds an over-long key into the accepted length, deterministically", () => { + for (const key of [orgKey, consultantKey]) { + const bounded = boundPayoutIdempotencyKey(key); + expect(bounded.length).toBeLessThanOrEqual(36); + expect(bounded).toMatch(/^[A-Za-z0-9 _-]+$/); + expect(boundPayoutIdempotencyKey(key)).toBe(bounded); + } + expect(boundPayoutIdempotencyKey(orgKey)).not.toBe( + boundPayoutIdempotencyKey(consultantKey), + ); + }); +}); diff --git a/app/api/webhooks/razorpay/route.ts b/app/api/webhooks/razorpay/route.ts index ca89d7837..c13f473ae 100644 --- a/app/api/webhooks/razorpay/route.ts +++ b/app/api/webhooks/razorpay/route.ts @@ -2,7 +2,7 @@ import * as Sentry from "@sentry/nextjs"; import { NextRequest, NextResponse } from "next/server"; import { after } from "next/server"; import crypto from "node:crypto"; -import { verifyWebhookSignature, logWebhookEvent, isDbHealthy } from "../utils"; +import { logWebhookEvent, isDbHealthy } from "../utils"; import { recordSystemEvent } from "@/lib/enterprise/system-events"; import { razorpayWebhookEnvelopeSchema, @@ -12,11 +12,27 @@ import { // stuck-webhook sweeper (jobs/cleanup/sweep-stuck-webhook-events) can replay // crashed events through the exact same handler routing. import { processRazorpayWebhookEvent } from "../razorpay-dispatch"; +import { + isPayoutEventName, + matchRazorpayWebhookSecret, + resolveRazorpayPaymentSecrets, + verifyRazorpaySignature, +} from "./signature"; + +// #1377 — signature verification needs `node:crypto`, which the edge runtime +// does not provide. Node is already the App Router default for route handlers; +// pinning it here means a future project-wide default flip cannot silently +// break every inbound payment confirmation. +export const runtime = "nodejs"; export async function POST(req: NextRequest) { - const secret = process.env.RAZORPAY_WEBHOOK_SECRET; Sentry.setTag("subsystem", "payments"); - if (!secret) { + + // #1377 — the payment-side secrets, current first and (only during a + // rotation) the previous one. See resolveRazorpayPaymentSecrets for why the + // grace window exists: a hard cutover loses events permanently. + const paymentSecrets = resolveRazorpayPaymentSecrets(); + if (paymentSecrets.length === 0) { console.error("RAZORPAY_WEBHOOK_SECRET not configured"); return NextResponse.json( { error: "Webhook secret not configured" }, @@ -29,74 +45,61 @@ export async function POST(req: NextRequest) { // is configured, re-verify with it (for payout.* events). const razorpayXSecret = process.env.RAZORPAYX_WEBHOOK_SECRET; - const { isValid, body } = await verifyWebhookSignature( - req, - secret, - "razorpay", - ); + const signature = req.headers.get("x-razorpay-signature"); + // The HMAC covers the RAW bytes. Read them once here and hand the same + // string to every verification attempt — parsing and re-serialising would + // reorder keys and break the digest. + const body = signature ? await req.text() : ""; + + const matchedRole = signature + ? matchRazorpayWebhookSecret(body, signature, paymentSecrets) + : null; + + if (matchedRole === "previous") { + // The rotation grace is meant to be short. Every delivery that only the + // OLD secret can verify is reported so a variable left behind after the + // cutover shows up in the operations timeline instead of quietly + // extending the window forever. + await recordSystemEvent({ + category: "WEBHOOK", + severity: "WARN", + message: + "Razorpay webhook verified with RAZORPAY_WEBHOOK_SECRET_PREVIOUS — rotation grace still in use", + context: { provider: "razorpay" }, + }); + } - if (!isValid) { + if (!matchedRole) { // M2 FIX: Only allow RazorpayX secret fallback for payout.* events. - // Parse the body to check event type before re-verifying — this prevents - // non-payout events from being accepted with the RazorpayX secret. - let isPossiblyPayoutEvent = false; - try { - const parsed = JSON.parse(body); - isPossiblyPayoutEvent = - typeof parsed.event === "string" && parsed.event.startsWith("payout."); - } catch { - // Can't parse — not a valid webhook, reject - } + // Read the event name from the (still unverified) body first — this + // prevents non-payout events from being accepted with the RazorpayX + // secret, and can only ever narrow what we accept. + const isPossiblyPayoutEvent = signature ? isPayoutEventName(body) : false; - if ( + const razorpayXAccepted = isPossiblyPayoutEvent && - razorpayXSecret && - razorpayXSecret !== secret - ) { - const signature = req.headers.get("x-razorpay-signature"); - if (signature) { - const crypto = await import("crypto"); - const expectedSig = crypto - .createHmac("sha256", razorpayXSecret) - .update(body) - .digest("hex"); - const sigBuf = Buffer.from(signature, "hex"); - const expectedBuf = Buffer.from(expectedSig, "hex"); - const isRazorpayXValid = - sigBuf.length === expectedBuf.length && - crypto.timingSafeEqual(sigBuf, expectedBuf); - - if (!isRazorpayXValid) { - // #776 §K — repeated HMAC failures are a tamper/misconfig signal. - await recordSystemEvent({ - category: "WEBHOOK", - severity: "WARN", - message: - "Razorpay webhook HMAC verification failed (RazorpayX secret)", - context: { provider: "razorpayx", event: "payout.*" }, - }); - return NextResponse.json( - { error: "Invalid signature" }, - { status: 400 }, - ); - } - // RazorpayX signature valid for payout event — continue processing - } else { - return NextResponse.json( - { error: "Invalid signature" }, - { status: 400 }, - ); - } - } else { + !!signature && + !!razorpayXSecret && + !paymentSecrets.some( + (candidate) => candidate.value === razorpayXSecret, + ) && + verifyRazorpaySignature(body, signature, razorpayXSecret); + + if (!razorpayXAccepted) { // #776 §K — repeated HMAC failures are a tamper/misconfig signal. await recordSystemEvent({ category: "WEBHOOK", severity: "WARN", - message: "Razorpay webhook HMAC verification failed", - context: { provider: "razorpay" }, + message: isPossiblyPayoutEvent + ? "Razorpay webhook HMAC verification failed (RazorpayX secret)" + : "Razorpay webhook HMAC verification failed", + context: isPossiblyPayoutEvent + ? { provider: "razorpayx", event: "payout.*" } + : { provider: "razorpay" }, }); return NextResponse.json({ error: "Invalid signature" }, { status: 400 }); } + // RazorpayX signature valid for payout event — continue processing } // DB health check — return 503 if DB is unreachable so Razorpay retries @@ -171,7 +174,7 @@ export async function POST(req: NextRequest) { eventId, eventType, event.payload, - req.headers.get("x-razorpay-signature") || undefined, + signature || undefined, ); if (!isNew) { diff --git a/app/api/webhooks/razorpay/signature.ts b/app/api/webhooks/razorpay/signature.ts new file mode 100644 index 000000000..f0bfbc011 --- /dev/null +++ b/app/api/webhooks/razorpay/signature.ts @@ -0,0 +1,135 @@ +import crypto from "node:crypto"; + +/** + * Which configured secret verified an inbound Razorpay webhook. + * + * `previous` exists only during a secret rotation. `razorpayx` is the separate + * RazorpayX (payouts) product secret, which is a different value again and is + * only ever consulted for `payout.*` events. + */ +export type RazorpayWebhookSecretRole = "current" | "previous" | "razorpayx"; + +export interface RazorpayWebhookSecretCandidate { + role: RazorpayWebhookSecretRole; + value: string; +} + +// Razorpay signs with HMAC-SHA256 and sends the digest hex-encoded, so a +// well-formed `x-razorpay-signature` is always 64 characters. The length +// pre-check is not decoration: `timingSafeEqual` THROWS on a length mismatch, +// so without it an attacker-controlled header turns a rejected signature into +// an unhandled 500. +const HMAC_SHA256_HEX_LENGTH = 64; + +/** Constant-time HMAC-SHA256 check of the RAW body against one secret. */ +export function verifyRazorpaySignature( + rawBody: string, + signature: string, + secret: string, +): boolean { + if (signature.length !== HMAC_SHA256_HEX_LENGTH) { + return false; + } + const expected = crypto + .createHmac("sha256", secret) + .update(rawBody) + .digest("hex"); + const signatureBuffer = Buffer.from(signature, "hex"); + const expectedBuffer = Buffer.from(expected, "hex"); + if (signatureBuffer.length !== expectedBuffer.length) { + return false; + } + return crypto.timingSafeEqual(signatureBuffer, expectedBuffer); +} + +/** + * The payment-side secrets a delivery may legitimately be signed with, in the + * order they should be tried. + * + * #1377 — rotating `RAZORPAY_WEBHOOK_SECRET` is otherwise a hard cutover, and + * the two sides cannot swap atomically: the operator saves the new secret in + * the Razorpay dashboard, and every event Razorpay signs between that click + * and the platform finishing its redeploy is rejected with a 400. Razorpay + * treats any non-2xx as a delivery failure, retries with exponential backoff + * for 24 hours and then DISABLES the webhook, and a disabled webhook loses + * events permanently because there is no self-serve replay. So a routine + * hygiene action could silently take payment confirmation offline. + * + * `RAZORPAY_WEBHOOK_SECRET_PREVIOUS` closes that gap the same way ADR 09 + * closes it for our OUTBOUND webhooks: both secrets are honoured across the + * cutover, and the old one is retired afterwards. The window here is + * operational rather than timestamped — the variable IS the window — so every + * delivery that actually lands on the previous secret is reported by the + * caller, and a variable left behind after the rotation is loud rather than + * silent. + * + * An unset, blank or duplicated previous secret contributes no candidate, so + * the normal steady state is a single-secret check. A missing CURRENT secret + * contributes none at all: the grace window is an aid to a rotation, not a + * secret in its own right, so a deployment that has lost + * `RAZORPAY_WEBHOOK_SECRET` must fail loudly on the route's 500 rather than + * quietly keep accepting deliveries on a value the operator has retired. + */ +export function resolveRazorpayPaymentSecrets( + env: Readonly> = process.env, +): RazorpayWebhookSecretCandidate[] { + const current = env.RAZORPAY_WEBHOOK_SECRET?.trim(); + const previous = env.RAZORPAY_WEBHOOK_SECRET_PREVIOUS?.trim(); + + const candidates: RazorpayWebhookSecretCandidate[] = []; + if (!current) { + return candidates; + } + candidates.push({ role: "current", value: current }); + if (previous && previous !== current) { + candidates.push({ role: "previous", value: previous }); + } + return candidates; +} + +/** + * Try each candidate in order and report which one matched, or null. + * + * Trying several secrets does not widen the trust boundary: each check is the + * same full HMAC over the same raw body, so a forged signature still has to + * match a secret the platform holds. What it widens is the SET of secrets the + * platform holds, which is exactly why `resolveRazorpayPaymentSecrets` only + * ever returns more than one while a rotation is in flight. + */ +export function matchRazorpayWebhookSecret( + rawBody: string, + signature: string, + candidates: readonly RazorpayWebhookSecretCandidate[], +): RazorpayWebhookSecretRole | null { + for (const candidate of candidates) { + if (verifyRazorpaySignature(rawBody, signature, candidate.value)) { + return candidate.role; + } + } + return null; +} + +/** + * True when the parsed body names a RazorpayX payout event. + * + * The RazorpayX secret is only ever tried for these, and the ordering — main + * secrets first, X secret only on a `payout.*` name — is the whole safety + * property: a non-payout event can never be accepted by the X secret, so the + * fallback cannot be used to smuggle a forged `payment.captured` through. + * The name is read from an as-yet-UNVERIFIED body, which is safe precisely + * because it can only ever narrow what we are willing to accept. + */ +export function isPayoutEventName(rawBody: string): boolean { + try { + const parsed: unknown = JSON.parse(rawBody); + return ( + typeof parsed === "object" && + parsed !== null && + typeof (parsed as { event?: unknown }).event === "string" && + (parsed as { event: string }).event.startsWith("payout.") + ); + } catch { + // Unparseable body — not a webhook we can classify, so no fallback. + return false; + } +} diff --git a/docs/payments/gateways/razorpay/01-setup.md b/docs/payments/gateways/razorpay/01-setup.md index e010ac10f..bf89a24f0 100644 --- a/docs/payments/gateways/razorpay/01-setup.md +++ b/docs/payments/gateways/razorpay/01-setup.md @@ -112,14 +112,16 @@ Dashboard > Settings > Webhooks > Add New Webhook **Events to select**: -| Category | Events | -| ------------------ | --------------------------------------------------------------------------------------------------------------- | -| Payment | `payment.captured`, `order.paid`, `payment.failed` | -| Refund | `refund.created`, `refund.processed`, `refund.failed` | -| Dispute | `payment.dispute.created`, `payment.dispute.won`, `payment.dispute.lost`, `payment.dispute.closed` | -| Payout (RazorpayX) | `payout.processed`, `payout.reversed`, `payout.rejected`, `payout.queued`, `payout.pending`, `payout.cancelled` | +| Category | Events | +| ------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Payment | `payment.captured`, `order.paid`, `payment.failed` | +| Refund | `refund.created`, `refund.processed`, `refund.failed` | +| Dispute | `payment.dispute.created`, `payment.dispute.under_review`, `payment.dispute.action_required`, `payment.dispute.won`, `payment.dispute.lost`, `payment.dispute.closed` | +| Payout (RazorpayX) | `payout.processed`, `payout.failed`, `payout.reversed`, `payout.rejected`, `payout.queued`, `payout.pending`, `payout.cancelled` | -Copy the webhook secret after creation and store it in the appropriate environment variable. +This list is the same one the go-live checklist requires, and it is the exact set the dispatcher in `app/api/webhooks/razorpay-dispatch.ts` handles. Omitting `payout.failed` is the expensive mistake, because it is the terminal event that tells the platform a bank refused the transfer; without it the earnings stay batched against a payout that will never arrive. + +Copy the webhook secret after creation and store it in the appropriate environment variable. Each mode has its own webhook secret, so the value generated in test mode will reject every live delivery and vice versa. Rotating the secret later is a two-sided change and must follow the grace-window procedure in [05-go-live-checklist.md](./05-go-live-checklist.md), because a hard cutover loses the events signed during the gap and Razorpay disables a webhook that has been failing for 24 hours. ### Test Mode vs Live Mode @@ -214,3 +216,4 @@ Set the ngrok URL as your webhook endpoint in the Razorpay dashboard. - [02-architecture-and-flow.md](./02-architecture-and-flow.md) — Payment flow and revenue split - [03-payout-flow.md](./03-payout-flow.md) — RazorpayX payout system - [04-kyc-and-onboarding.md](./04-kyc-and-onboarding.md) — KYC requirements +- [05-go-live-checklist.md](./05-go-live-checklist.md) — What must be true before the first live rupee diff --git a/docs/payments/gateways/razorpay/03-payout-flow.md b/docs/payments/gateways/razorpay/03-payout-flow.md index 1452126c3..017d3c5de 100644 --- a/docs/payments/gateways/razorpay/03-payout-flow.md +++ b/docs/payments/gateways/razorpay/03-payout-flow.md @@ -208,15 +208,20 @@ Webhook confirms status ### RazorpayX Payout Status Mapping -| RazorpayX Status | Internal Status | Description | -| ---------------- | --------------- | ---------------------------- | -| `queued` | PENDING | Queued due to low balance | -| `pending` | PENDING | Awaiting processing | -| `processing` | PROCESSING | Being processed by RazorpayX | -| `processed` | COMPLETED | Funds transferred to bank | -| `reversed` | FAILED | Bank returned the funds | -| `rejected` | FAILED | Payout rejected by RazorpayX | -| `cancelled` | CANCELLED | Payout cancelled | +RazorpayX has three intermediate payout states and five terminal ones, and every terminal state must map to a terminal internal state. If a terminal gateway state is read as an intermediate one, the payout never leaves PROCESSING, its earnings stay BATCHED, and the consultant is neither paid nor re-queued. The mapping below is the full set as documented at [RazorpayX Payout Status](https://razorpay.com/docs/x/payouts/status-details/). + +| RazorpayX Status | Internal Status | Description | +| ---------------- | --------------- | --------------------------------------------------------- | +| `queued` | PENDING | Queued due to low balance | +| `pending` | PENDING | Awaiting approval in the RazorpayX approval workflow | +| `processing` | PROCESSING | Being processed by RazorpayX | +| `processed` | COMPLETED | Funds transferred to bank | +| `reversed` | FAILED | Bank returned the funds; RazorpayX credited us back | +| `rejected` | FAILED | Approval was refused or lapsed | +| `failed` | FAILED | The transfer failed at RazorpayX, the bank, or in transit | +| `cancelled` | CANCELLED | A queued payout was cancelled manually | + +An unrecognised status deliberately maps to PENDING rather than to a terminal state, because "we do not know yet" must keep the reconciler polling instead of settling a payout on a guess. --- @@ -227,6 +232,7 @@ Webhook confirms status | `payout.processed` | Funds transferred successfully | Mark payout COMPLETED, earnings as PAID | | `payout.reversed` | Bank returned funds | Mark payout FAILED, restore available balance | | `payout.rejected` | RazorpayX rejected payout | Mark payout FAILED, alert admin | +| `payout.failed` | Transfer failed at the bank | Mark payout FAILED, return earnings to READY | | `payout.queued` | Insufficient balance, queued | Update payout status to PENDING | | `payout.pending` | Payout pending processing | Update payout status | | `payout.cancelled` | Payout cancelled | Mark payout CANCELLED | @@ -250,9 +256,11 @@ Webhook confirms status Since March 2025, RazorpayX **requires** an idempotency key on every payout request. This prevents duplicate payouts if a request is retried. -The system generates idempotency keys using the payout ID and timestamp: `payout_{payoutId}_{timestamp}` +The key must be deterministic for a given payout, because that is the only property that makes a retry safe. `generateIdempotencyKey` in `lib/payments/payouts/razorpay-payouts.ts` therefore returns `payout_{payoutId}` and nothing else. An earlier version appended a timestamp, which produced a fresh key on every attempt and so defeated the mechanism entirely: a retry after a timeout would have submitted a second payout for the same earnings. Do not reintroduce a clock, a random suffix or an attempt counter into this key. + +The key is sent via the `X-Payout-Idempotency` header. When the payout row already carries an `idempotencyKey`, that value is used ahead of the generated one, so every attempt on a given row lands on the same RazorpayX idempotency slot. -The key is sent via the `X-Payout-Idempotency` header. +RazorpayX bounds that header at 4 to 36 characters drawn from letters, digits, hyphens, underscores and spaces, and answers anything else with a 400. Two of our keys overshoot it: an organization payout derives `payout_`, which is 43 characters, and a consultant payout persists `payout__`, which is 72. `boundPayoutIdempotencyKey` therefore folds any key the gateway would refuse onto a 34-character digest of itself at the point the header is written. The fold is a pure function of the key, so determinism is preserved and a retry still returns the original payout rather than creating a second one. The persisted `idempotencyKey` is left alone, because it is also the row's unique constraint and the Stripe transfer key, and neither of those is bounded the way this header is. --- diff --git a/docs/payments/gateways/razorpay/05-go-live-checklist.md b/docs/payments/gateways/razorpay/05-go-live-checklist.md new file mode 100644 index 000000000..c6289d098 --- /dev/null +++ b/docs/payments/gateways/razorpay/05-go-live-checklist.md @@ -0,0 +1,115 @@ +# Razorpay Go-Live Checklist + +> What has to be true before the platform accepts its first rupee of real money through Razorpay, and what has to be verified in the Razorpay dashboard rather than in this repository. + +**Last Updated**: 2026-09-05 · **Tracking issue**: #1377 + +--- + +## How to read this page + +This checklist covers the payments product only. Consultant and organisation disbursement through RazorpayX is gated separately by `ENABLE_LIVE_PAYOUTS` and has its own runbook at [docs/enterprise/50-operations/06-live-payout-go-live-runbook.md](../../../enterprise/50-operations/06-live-payout-go-live-runbook.md); do not treat the two as one cutover, because accepting money and disbursing money can safely go live weeks apart. + +Several items below cannot be verified from the codebase at all. Auto-capture, the settlement cycle and the uncaptured-payment refund window are account settings that live in the Razorpay dashboard, and no amount of reading `lib/payments/core/razorpay.ts` will tell you how they are configured. Those items are marked as dashboard checks, and they need a screenshot or a dashboard link recorded against the issue rather than a code reference. + +--- + +## 1. Account activation + +The account has to be activated before live keys do anything at all, and activation is not instantaneous. + +- [ ] KYC is submitted and approved, and the dashboard shows the account as activated. Razorpay quotes one to three business days for this, and a rejection restarts the clock. +- [ ] The settlement bank account on the Razorpay account is the platform's current account, and the account holder name matches the registered business name exactly. +- [ ] GSTIN is recorded on the Razorpay account. This is what lets Razorpay issue us a compliant invoice for its own fees, which we need for input tax credit; it is unrelated to the tax invoices this platform issues to consumers, which are minted in-house (see [../../07-b2c-tax-invoice.md](../../07-b2c-tax-invoice.md)). +- [ ] If international cards are ever to be accepted, domestic acceptance is activated first and video KYC is complete. International acceptance is a separate approval, not a toggle. + +--- + +## 2. Keys and the test-key guard + +The platform holds two unrelated Razorpay credential pairs and one webhook secret per mode, and confusing any two of them is the most common cause of a silent outage. + +- [ ] `RAZORPAY_KEY_ID` and `RAZORPAY_SECRET` hold the **live** key pair, with `rzp_live_` as the key id prefix. Note that the secret's variable name is `RAZORPAY_SECRET` in this repository and not `RAZORPAY_KEY_SECRET`; only the legacy readers under `scripts/` accept the second name. +- [ ] `NEXT_PUBLIC_RAZORPAY_KEY_ID` holds the same live key **id**. It is the only Razorpay value that may ever be public, and neither secret may ever be given a `NEXT_PUBLIC_` prefix. +- [ ] `RAZORPAY_ALLOW_TEST_KEYS_IN_PRODUCTION` has been **deleted** from the production environment. While it is set to `true`, a test key under `NODE_ENV=production` only logs a loud error instead of throwing, which is deliberate for the pre-launch period when signup is closed and checkout is exercised with test cards. Once live keys are in place the variable has no legitimate use, and leaving it behind removes the guard that would otherwise catch a future accidental rollback to test keys. +- [ ] A production boot has been observed after the change. The test-key guard in `lib/payments/core/razorpay.ts` runs at module load, so a misconfigured production posture fails at require time rather than at the first customer — which means the deploy either comes up clean or does not come up at all. + +--- + +## 3. Webhooks + +Webhook delivery is how the platform learns that money moved. Everything else is best effort. + +- [ ] A webhook is registered in **live** mode pointing at `https:///api/webhooks/razorpay` over HTTPS on port 443. +- [ ] `RAZORPAY_WEBHOOK_SECRET` in the production environment holds the **live** webhook secret, which is a third value distinct from both the API secret and the test-mode webhook secret. A test-mode secret in production rejects every live delivery with a 400. +- [ ] The selected events are exactly the ones the dispatcher handles: `payment.captured`, `order.paid`, `payment.failed`, `refund.created`, `refund.processed`, `refund.failed`, `refund.speed_changed`, the six `payment.dispute.*` events, and the seven `payout.*` events. Selecting an event the dispatcher does not handle is harmless because unknown events are logged and acknowledged with a 200, but omitting a handled one loses the state transition entirely. +- [ ] The Alert Email Address on the webhook is a monitored inbox. Razorpay emails it when it disables a webhook, and that email is the only notification of the failure mode described below. +- [ ] A signed test delivery has reached production and produced a `WebhookEvent` row. The recipe lives in the Razorpay skill at `.claude/skills/razorpay/references/local-testing.md`; the signature is an HMAC-SHA256 of the exact bytes posted, so it must be generated from the same string that is sent. + +### Why a non-2xx is dangerous here + +Razorpay treats every non-2xx response as a delivery failure, retries on an exponential backoff for 24 hours, and then **disables the webhook** ([webhook FAQs](https://razorpay.com/docs/webhooks/faqs/)). A disabled webhook is not merely paused: events that fire while it is disabled are never delivered, and Razorpay has no self-serve replay. Recovering them means a support ticket, only works for events under 15 days old, and only works if the webhook was enabled when the event fired. This is why the route returns 200 for events it does not handle and reserves 503 for the single case where a retry is genuinely wanted, namely an unreachable database. + +### Rotating the webhook secret + +Rotating `RAZORPAY_WEBHOOK_SECRET` is a two-sided change that cannot be made atomically, because the operator saves the new secret in the Razorpay dashboard and the platform picks it up only on the next deploy. Every event signed in that gap would be rejected, and a long enough gap ends in the disabled webhook described above. + +`RAZORPAY_WEBHOOK_SECRET_PREVIOUS` exists to close that window, mirroring for inbound deliveries what [ADR 09](../../../enterprise/70-design-decisions/09-webhook-rotation-grace.md) does for outbound ones. The procedure is: + +1. Set `RAZORPAY_WEBHOOK_SECRET_PREVIOUS` to the current secret and deploy. Nothing changes yet, because the current secret still verifies everything. +2. Generate the new secret in the Razorpay dashboard, set `RAZORPAY_WEBHOOK_SECRET` to it, and deploy. Deliveries signed with either secret now verify. +3. Watch the operations timeline. Every delivery that only the previous secret can verify writes a `WEBHOOK`/`WARN` system event naming the variable, so the rotation is visibly finished when those stop. +4. Delete `RAZORPAY_WEBHOOK_SECRET_PREVIOUS` and deploy. The old secret stops being honoured. + +Leaving the variable set indefinitely defeats the purpose of rotating a leaked secret, which is why step 4 is part of the procedure rather than optional cleanup. + +### RazorpayX payout deliveries + +RazorpayX payout events arrive at the same endpoint but are signed with `RAZORPAYX_WEBHOOK_SECRET`, a different value again. The route verifies against the payment-side secrets first and only consults the RazorpayX secret when that fails **and** the event name begins with `payout.`. That ordering is the safety property rather than an optimisation: because a non-payout event can never be accepted by the RazorpayX secret, holding a second secret cannot be used to smuggle a forged `payment.captured` through. + +--- + +## 4. Capture and settlement (dashboard checks) + +- [ ] **Automatic capture is on.** Verify at Settings → Payments → payment capture. Auto-capture is on by default, but if it has been turned off, payments sit in `authorized`, `payment.captured` never fires, and no booking is ever confirmed. The `payment_capture` request field that older integrations used is deprecated and this repository correctly does not send it; per-order overrides are available through the `payment.capture` and `payment.capture_options` objects on the Orders API, and the platform deliberately does not use them so that one dashboard setting governs every order ([capture settings](https://razorpay.com/docs/payments/payments/capture-settings/)). +- [ ] **The auto-refund window for uncaptured payments is understood.** A payment left in `authorized` past the account's `manual_expiry_period` is refunded automatically, at normal speed, so the customer sees it back in five to seven working days. Read the actual configured value off the dashboard rather than trusting a remembered default, because Razorpay's own pages have quoted three days and five days in different places. +- [ ] **The settlement cycle is recorded.** The standard domestic cycle is T+2 working days from capture, where working days exclude Sundays, the second and fourth Saturdays, and bank holidays; T+7 is the international cycle rather than a new-merchant probation ([settlements](https://razorpay.com/docs/payments/settlements/)). The finance owner needs this number to reconcile the bank statement against the ledger. +- [ ] **A real ₹1 payment has been taken end to end in live mode** and has produced a Payment row at SUCCEEDED, a confirmed appointment, balanced ledger entries and a consumer tax invoice. + +--- + +## 5. Refunds + +- [ ] A live refund has been issued against that ₹1 payment and has reached SUCCEEDED via the `refund.processed` webhook rather than by anyone editing a row. +- [ ] The team understands that live refunds settle in five to seven business days at normal speed. Test-mode refunds usually appear instantly, which is not a guarantee and must never be built into a flow; the only correct trigger for "the customer has their money" is `refund.processed`. +- [ ] Nobody has introduced a `speed` parameter. This platform always requests the default `normal` speed and never `optimum`, so it never pays the instant-refund fee and `refund.speed_changed` is informational only. Changing that is a pricing decision, not an engineering one. +- [ ] Refund idempotency is intact: every refund carries `X-Refund-Idempotency` set to the `Refund` row's id, which is minted before the gateway call and unchanged on the error path. A key derived from the payment id and amount would make two legitimate partial refunds of equal value collide, and the second would silently return the first refund instead of paying the customer again. + +--- + +## 6. Order metadata limits + +Razorpay caps order `notes` at **15 key-value pairs of at most 256 characters each**, and rejects the whole order with a `BAD_REQUEST_ERROR` when either limit is exceeded ([Orders API](https://razorpay.com/docs/api/orders/create/)). The receipt field is separately capped at 40 ASCII characters and must be unique per order. + +- [ ] Every producer of order notes has been counted against the 15-pair budget before any new key is added. `buildPaymentMetadata` in `lib/payments/operations/checkout.ts` already emits fifteen keys in the org-sponsored case, so it has no headroom left. +- [ ] No unbounded user-supplied string reaches `notes`. This is an open gap at the time of writing: the free-text booking note is validated as `z.string().optional()` with no maximum and is forwarded verbatim, so a note longer than 256 characters fails order creation and the customer cannot pay. It is tracked for the multi-currency and checkout PR that owns those files. + +--- + +## 7. Operations and observability + +- [ ] Sentry is receiving events from the payments subsystem, and the webhook route's signature-failure and parse-failure paths have been seen at least once in a preview environment so the alerting is known to work. +- [ ] The scheduled sweeps are running in production. Payment confirmation is durable because the `WebhookEvent` row is written before the 200 is returned, but recovery from a crashed handler depends on `sweep-stuck-webhook-events`, and recovery from an event that never arrived depends on `reconcile-payment-status`. Both are driven by the Netlify ticker every five minutes with GitHub Actions as a backstop; see [ADR 27](../../../enterprise/70-design-decisions/27-state-as-outbox-and-scheduled-ticker.md). +- [ ] No secret is logged. Payloads are scrubbed by `scrubWebhookPayload` before anything is written, and no code path prints `RAZORPAY_SECRET` or either webhook secret. +- [ ] Payment records are retained for at least eight years, as Indian tax law requires. Nothing in the money subsystem hard-deletes a Payment, Refund or invoice row, and that property must survive any future data-retention work. + +--- + +## Related Documents + +- [01-setup.md](./01-setup.md) — Account setup, keys, dashboard configuration and test credentials +- [02-architecture-and-flow.md](./02-architecture-and-flow.md) — Payment flow and revenue split +- [03-payout-flow.md](./03-payout-flow.md) — RazorpayX payout system and status mapping +- [04-kyc-and-onboarding.md](./04-kyc-and-onboarding.md) — KYC requirements and timelines +- [docs/payments/06-high-level-design.md](../../06-high-level-design.md) — Where money truth is written and which sweep closes each gap +- [docs/enterprise/50-operations/07-required-secrets.md](../../../enterprise/50-operations/07-required-secrets.md) — The full secrets manifest and what breaks when each one is missing diff --git a/lib/payments/payouts/razorpay-payouts.ts b/lib/payments/payouts/razorpay-payouts.ts index 3aa50e806..d71eb9707 100644 --- a/lib/payments/payouts/razorpay-payouts.ts +++ b/lib/payments/payouts/razorpay-payouts.ts @@ -6,7 +6,10 @@ */ import * as Sentry from "@sentry/nextjs"; -import { reportSentryError, reportSentryMessage } from "@/lib/observability/report"; +import { + reportSentryError, + reportSentryMessage, +} from "@/lib/observability/report"; import { ENABLE_LIVE_PAYOUTS } from "@/lib/feature-flags"; import { PaymentError } from "@/lib/payments/core/types"; import crypto from "crypto"; @@ -146,6 +149,50 @@ export interface PayoutWebhookEvent { // RazorpayX Payouts Service // ============================================ +/** + * #1377 — ceiling on any single RazorpayX HTTP call. Sized against the payout + * batch: the submission loop runs under a cron lock, and a request that has + * not answered in half a minute is not going to answer usefully. + */ +const RAZORPAYX_REQUEST_TIMEOUT_MS = 30_000; + +// RazorpayX accepts an `X-Payout-Idempotency` value of 4-36 characters drawn +// from letters, digits, hyphen, underscore and space, and rejects anything else +// with a 400 — so an over-long key is not a weaker duplicate guard, it is a +// payout that never leaves the building. +const PAYOUT_IDEMPOTENCY_MIN_LENGTH = 4; +const PAYOUT_IDEMPOTENCY_MAX_LENGTH = 36; +const PAYOUT_IDEMPOTENCY_ALLOWED_CHARS = /^[A-Za-z0-9 _-]+$/; + +/** + * Fold a caller's idempotency key onto one RazorpayX will accept. + * + * #1377 — both money-out paths overshot the limit. An organization payout sends + * `payout_` (43 characters) and a consultant payout prefers the + * `idempotencyKey` persisted on the row, `payout__` (72), + * so every live submission would have been refused at the header rather than + * deduplicated. The persisted value stays as it is — it is also the row's + * unique key and the Stripe idempotency key, neither of which is bounded this + * way — and only the gateway header is narrowed here. + * + * The fold is a pure function of the key, so the one property that makes a + * retry safe survives: the same payout row always derives the same slot, and a + * request that timed out after RazorpayX accepted it returns the original + * payout instead of paying a second time. + */ +export function boundPayoutIdempotencyKey(key: string): string { + if ( + key.length >= PAYOUT_IDEMPOTENCY_MIN_LENGTH && + key.length <= PAYOUT_IDEMPOTENCY_MAX_LENGTH && + PAYOUT_IDEMPOTENCY_ALLOWED_CHARS.test(key) + ) { + return key; + } + // 34 characters: inside the limit, inside the charset, and still readable as + // a payout key in the RazorpayX dashboard. + return `p_${crypto.createHash("sha256").update(key).digest("hex").slice(0, 32)}`; +} + export class RazorpayPayoutsService { private config: RazorpayXConfig; private baseUrl = "https://api.razorpay.com/v1"; @@ -184,24 +231,85 @@ export class RazorpayPayoutsService { `${this.config.keyId}:${this.config.keySecret}`, ).toString("base64"); - const response = await fetch(`${this.baseUrl}${endpoint}`, { - method, - headers: { - Authorization: `Basic ${auth}`, - "Content-Type": "application/json", - ...headers, - }, - body: body ? JSON.stringify(body) : undefined, - }); + let response: Response; + try { + response = await fetch(`${this.baseUrl}${endpoint}`, { + method, + headers: { + Authorization: `Basic ${auth}`, + "Content-Type": "application/json", + ...headers, + }, + body: body ? JSON.stringify(body) : undefined, + // #1377 — bare `fetch` has no default timeout, so a hung connection to + // api.razorpay.com hangs the caller for as long as the socket stays + // open. This is the same hazard `withRazorpaySdkTimeout` exists for on + // the payments client, and it is worse here: the payout batch holds a + // cron lock while it submits, so one stalled socket can wedge an + // entire disbursement run. Every payout submission carries + // `X-Payout-Idempotency`, so a timed-out request that DID reach + // RazorpayX returns the original payout on retry rather than paying + // twice. + signal: AbortSignal.timeout(RAZORPAYX_REQUEST_TIMEOUT_MS), + }); + } catch (cause) { + // AbortSignal.timeout rejects with a TimeoutError DOMException, and a + // DNS/socket failure rejects with a TypeError. Neither says whether the + // request reached RazorpayX, so both are surfaced as one retryable code + // rather than being flattened into an anonymous Error. + throw new PaymentError( + `RazorpayX request ${method} ${endpoint} did not complete: ${ + cause instanceof Error ? cause.message : String(cause) + }`, + "RAZORPAYX_REQUEST_FAILED", + "RAZORPAY", + cause, + ); + } if (!response.ok) { - const error = await response.json().catch(() => ({})); - throw new Error( - `RazorpayX API error: ${error.error?.description || response.statusText}`, + // Razorpay's error envelope is `{ error: { code, description, reason } }`. + // The old throw kept only `description`, so callers could not tell a + // 401 on bad credentials from a 400 on a malformed fund account from a + // 502 worth retrying — every failure read as one opaque string. Carry + // the gateway's own code and the HTTP status through instead. + const parsed: unknown = await response.json().catch(() => null); + const gatewayError = + parsed && + typeof parsed === "object" && + "error" in parsed && + typeof (parsed as { error: unknown }).error === "object" + ? ((parsed as { error: { code?: string; description?: string } }) + .error ?? {}) + : {}; + throw new PaymentError( + `RazorpayX API error (HTTP ${response.status}) on ${method} ${endpoint}: ${ + gatewayError.description || response.statusText || "no description" + }`, + gatewayError.code || `RAZORPAYX_HTTP_${response.status}`, + "RAZORPAY", + parsed, ); } - return response.json(); + // #1377 — the success body is read OUTSIDE the fetch() try above, so a + // reply whose headers arrived but whose body stalls trips the same + // AbortSignal here, and a non-JSON body (an edge/WAF error page) throws a + // SyntaxError. Either would escape as a bare exception and lose the + // retryable code the payout callers classify on. Neither says whether the + // payout was accepted, which is exactly the RAZORPAYX_REQUEST_FAILED case. + try { + return (await response.json()) as T; + } catch (cause) { + throw new PaymentError( + `RazorpayX response to ${method} ${endpoint} could not be read: ${ + cause instanceof Error ? cause.message : String(cause) + }`, + "RAZORPAYX_REQUEST_FAILED", + "RAZORPAY", + cause, + ); + } } // ============================================ @@ -361,7 +469,9 @@ export class RazorpayPayoutsService { notes: request.notes, }, { - "X-Payout-Idempotency": request.idempotencyKey, + "X-Payout-Idempotency": boundPayoutIdempotencyKey( + request.idempotencyKey, + ), }, ); } @@ -473,7 +583,20 @@ export class RazorpayPayoutsService { } /** - * Map RazorpayX payout status to our internal status + * Map RazorpayX payout status to our internal status. + * + * The four terminal RazorpayX states are `processed`, `rejected`, + * `cancelled`, `reversed` and `failed`; `queued`, `pending` and `processing` + * are intermediate. + * https://razorpay.com/docs/x/payouts/status-details/ + * + * #1377 — `failed` used to fall through to the `default` arm and be read as + * PENDING, i.e. as "still in flight". A payout that the bank refused would + * therefore never reach FAILED, so its earnings stayed BATCHED instead of + * being returned to READY and the consultant was never paid and never + * re-queued. The default arm is kept for genuinely unknown strings, where + * PENDING is the right answer because it keeps the reconciler polling + * instead of settling state on a guess. */ mapPayoutStatus( status: RazorpayPayoutStatus, @@ -488,6 +611,7 @@ export class RazorpayPayoutsService { return "COMPLETED"; case "reversed": case "rejected": + case "failed": return "FAILED"; case "cancelled": return "CANCELLED";