Story 2193: quote the price, let someone with a phone line place the order — the Razorpay buy-side goes egress-free

WHY. The catastrophe this Story prevents is a credit purchase that hangs 15 seconds and dies with a 500 — every “Buy credits” click failing before Checkout ever opens. Root cause (verified 2026-07-09): the pramaan-app-api Lambda sits in the default VPC with no NAT gateway, so it cannot reach api.razorpay.com. Razorpay order-create was the first outbound HTTP call the backend ever made, and it hit a wall no other code path had touched. The founder decision is deliberate: do NOT add NAT or an egress Lambda. A backend that cannot dial the public internet is a smaller attack surface — it reinforces the no-internet-from-backend posture. So the outbound call moves to the one tier that already has a phone line, the Next.js BFF, and the backend keeps the one thing it must never delegate: the money math. The load-bearing invariant is that amount, GST, and notes are computed here, under firm RLS — the browser and the BFF pass those numbers through untouched and never author a price or a firm/credit count.

🛰 Satellite — the one sentence

POST /app/firm/credits/razorpay/order stops calling Razorpay and becomes a pure quote: it prices a top-up in integer paise under firm RLS (base + GST from billing.pricing_config, 1 credit = ₹1) and returns {credits, base_paise, gst_paise, gst_pct, total_paise, currency, notes} — the notes being exactly what S8’s signature-verified webhook mint reads back. The Next.js BFF takes that quote and places the actual Razorpay order server-side with keys that live only in its env. No outbound HTTP leaves the backend; S8 is untouched.

✈️ Airplane — the clerk who quotes, but now hands the till-run to the runner

Same credit shop, same counter clerk from Story 2167 — with one change forced by the building. The clerk’s counter is in a back room with no outside phone line (the VPC-with-no-NAT). Story 2167 had the clerk phoning the payment company directly to open an order; that call now rings out forever. So the clerk still does the part only the clerk can do — quotes the price to the exact paisa from the pinned price list on the wall (pricing_config), and writes the firm, credit count, and amount on a numbered ticket (notes). But instead of phoning the payment company, the clerk hands that ticket to a runner in the front office (the Next.js BFF), who does have a phone line. The runner reads the amount and notes straight off the ticket, phones Razorpay, and brings back the order id. Crucially, the runner is a courier, not a pricer: he does not decide the amount — he reads it. And the till (the credit mint) is still bolted shut behind S8’s signature stamp, which this Story does not touch.

🔬 Microscopic — what changed, part by part

1. The route drops the egress (seats_and_billing/router.py)

The handler (now quote_razorpay_topup_order, operationId appQuoteRazorpayTopupOrder) keeps the whole money spine unchanged: resolve the firm from RLS, read (gst_pct, min_topup_inr, ai_markup, fx) from pricing_config, call the pure topup_order_pricing(...) (below-min still maps to a typed 422 pramaan.billing.below_min_topup). What is deleted is the tail: the load_razorpay_keys() + create_razorpay_order(build_order_params(...)) call and its 503/502 egress error branches. In their place the route builds the notes and returns the quote. No httpx, no keys, no outbound socket.

2. The response contract — a priced quote, not a placed order (dtos.py)

The old RazorpayTopupOrderResponse (which carried order_id and razorpay_key_id — both facts only Razorpay can produce) is replaced by RazorpayTopupQuoteResponse + a typed RazorpayTopupNotes. The frozen shape:

{
  credits: int,           # 1 credit = ₹1 (base)
  base_paise: int,
  gst_paise: int,
  gst_pct: number,
  total_paise: int,       # amount to charge = base + GST
  currency: "INR",
  notes: { firm_id, credits, paise_charged, base_paise, gst_paise, gst_pct,
           ai_markup, fx_usd_to_inr }   # exactly what S8's webhook mint reads
}

Every number is authored by the backend under RLS. The BFF forwards notes to Razorpay verbatim (Razorpay stores each value as a string); payment.captured/order.paid then round-trip it into S8’s mint. ai_markup/fx_usd_to_inr ride along for audit and, per D-14, never change the credit count — the ₹-peg and the usage-time markup lever are unchanged from 2167.

3. Dead egress deleted, but the webhook’s key loader stays (webhooks/razorpay.py)

create_razorpay_order, build_order_params, RazorpayOrderError, and the orders URL/timeout constants are gone — nothing in the VPC-locked backend places an order anymore. But load_razorpay_keys and RazorpayNotConfiguredError stay: S8’s inbound webhook still calls load_razorpay_keys().webhook_secret to verify the X-Razorpay-Signature HMAC. Inbound signature verification is not egress; it needs the secret, not a phone line. That is why “remove the Razorpay key env from the lambda deploy” turned out to be a no-op — there is no RAZORPAY_* env in deploy-app-api.yml, and the webhook still needs RAZORPAY_SECRET_ARN.

4. The security seam holds

The Razorpay key secret now lives only in the BFF’s server env (never NEXT_PUBLIC, never the browser). The backend’s request path no longer holds a buy-side key at all. And the invariant that matters most is preserved by construction: because the price and notes are computed here under RLS and merely passed through the BFF, a compromised or buggy BFF cannot mint a firm cheaper credits — and the mint itself is still gated by S8’s signature over the raw callback body.

What this Story does not do

Deliberately absent / unchangedWhere it lives
Calling api.razorpay.com/v1/ordersThe Next.js BFF (/api/internal/credits/razorpay/order) — separate repo/PR per pramaan-docs ADR-031
Minting credits / the notes round-tripS8 — the signature-verified webhook, untouched by this Story
NAT gateway / egress LambdaNowhere — founder decision: backend stays internet-free
A migrationNone — pure code + contract change

Test evidence

CheckResult
tests/integration/test_app_razorpay_quote.py (new)System-boundary: real POST → priced quote; asserts full-DTO round-trip, notes.firm_id is the server-resolved firm, total_paise == base + gst, and that the response carries no order_id/razorpay_key_id — proof no egress occurred.
Razorpay unit tests (test_razorpay.py)16 pass. Dropped the two build_order_params tests (function deleted); signature-verify + pricing + event-parse tests unchanged.
S8 webhook integration (test_razorpay_webhook_route.py, test_razorpay_mint_reversal.py)Pass unchanged — the notes the quote emits still mint/reverse exactly-once.
Full suite2648 passed against the isolated test DB.
Contractsmake openapi + gen_permissions regenerated; openapi/app-api.json and openapi/permission-map.json committed for the renamed operationId and new DTO. alembic check clean (no migration).
Recall in one sentence: Story 2193 turns the Razorpay top-up route into a pure, RLS-authored quote — the VPC-locked backend prices the order (base + GST, 1 credit = ₹1, markup/FX recorded not re-charged) and hands the numbers to the internet-capable BFF to actually place, so no outbound call ever leaves the backend and S8’s signed mint stays the only door to a credit.