PR: #549: Story-2194: Razorpay egress moves to the Next.js BFF (backend stays internet-free) by @ankitmaheshwari-com
View PR →

Files changed

33 file(s), +1243 / -622 lines

Path Role + -
openapi/app-api.jsonConfig+78-24
openapi/permission-map.jsonConfig+1-1
src/app/api/internal/credits/razorpay/order/route.test.tsApp route+67-13
src/app/api/internal/credits/razorpay/order/route.tsApp route+59-8
src/components/settings/billing/credits-page.test.tsxComponent+5-5
src/components/settings/billing/credits-page.tsxComponent+5-7
src/components/settings/billing/use-create-topup-order.tsComponent+29-0
src/gen/client/appCreateRazorpayTopupOrder.tsGenerated contract+0-72
src/gen/client/appQuoteRazorpayTopupOrder.tsGenerated contract+72-0
src/gen/client/index.tsGenerated contract+1-1
src/gen/hooks/index.tsGenerated contract+4-4
src/gen/hooks/useAppCreateRazorpayTopupOrder.tsGenerated contract+0-156
src/gen/hooks/useAppQuoteRazorpayTopupOrder.tsGenerated contract+156-0
src/gen/index.tsGenerated contract+34-32
src/gen/schemas/razorpayTopupNotes.jsonGenerated contract+62-0
src/gen/schemas/razorpayTopupOrderResponse.jsonGenerated contract+0-59
src/gen/schemas/razorpayTopupQuoteResponse.jsonGenerated contract+110-0
src/gen/types/AppCreateRazorpayTopupOrder.tsGenerated contract+0-77
src/gen/types/AppQuoteRazorpayTopupOrder.tsGenerated contract+77-0
src/gen/types/RazorpayTopupNotes.tsGenerated contract+55-0
src/gen/types/RazorpayTopupOrderResponse.tsGenerated contract+0-53
src/gen/types/RazorpayTopupQuoteResponse.tsGenerated contract+51-0
src/gen/types/index.tsGenerated contract+15-14
src/gen/zod/{appCreateRazorpayTopupOrderSchema.ts => appQuoteRazorpayTopupOrderSchema.ts}Generated contract+35-35
src/gen/zod/index.tsGenerated contract+14-13
src/gen/zod/razorpayTopupNotesSchema.tsGenerated contract+35-0
src/gen/zod/razorpayTopupOrderResponseSchema.tsGenerated contract+0-29
src/gen/zod/razorpayTopupQuoteResponseSchema.tsGenerated contract+37-0
src/lib/credits/gateway-razorpay.test.tsLibrary+18-10
src/lib/credits/gateway.tsLibrary+15-9
src/lib/credits/razorpay-order.test.tsLibrary+91-0
src/lib/credits/razorpay-order.tsLibrary+92-0
src/lib/credits/topup-order.tsLibrary+25-0

Story-2194: Razorpay egress moves to the Next.js BFF

Story-2192 wired the Razorpay Checkout modal, but the actual order-create hung and returned 500. Root cause (verified 2026-07-09): the pramaan-app-api Lambda sits in the default VPC with no NAT, so it cannot reach api.razorpay.com — Razorpay order-create was the first outbound HTTP the backend ever tried. Founder decision: do not add NAT/egress; keep the backend internet-free. Next.js (which has internet) places the Razorpay order.

The split

TierResponsibility
Backend (pramaan-functions, Story-2193 — merged) POST /app/firm/credits/razorpay/order now QUOTES only — prices the top-up under firm RLS and returns RazorpayTopupQuoteResponse (totalPaise, basePaise, gstPaise, gstPct, credits, notes). No egress, no Razorpay keys on the Lambda.
App (pramaan-app — this story) The Next route calls the backend quote, then places the Razorpay order server-side with keys from Next server env, and returns the Checkout shape the #2192 modal already consumes.

What landed

Money-integrity invariant — the note that would have broken minting

The S8 mint webhook (unchanged) reads Razorpay order notes by snake_case keys (firm_id, credits, …) and treats every value as a string. But the app-api quote emits notes in camelCase (firmId, paiseCharged, …). Forwarding them verbatim would drop firm_id and the webhook would silently refuse to mint. So the BFF remaps to the exact shape the webhook reads, mirroring the retired backend build_order_params:

notes = {
  firm_id:       String(notes.firmId),
  credits:       String(notes.credits),
  paise_charged: String(notes.paiseCharged),
  base_paise:    String(notes.basePaise),
  gst_paise:     String(notes.gstPaise),
  gst_pct:       String(notes.gstPct),
  ai_markup:     String(notes.aiMarkup),
  fx_usd_to_inr: String(notes.fxUsdToInr),
}

Amount, GST and notes are all computed by the backend under RLS — the browser/BFF never set the price. The Razorpay secret lives in Next server env only (never NEXT_PUBLIC, never logged). The webhook stays the only credit-minting path.

Verification