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.
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.
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.
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.
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.
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.
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.
| Deliberately absent / unchanged | Where it lives |
|---|---|
Calling api.razorpay.com/v1/orders | The Next.js BFF (/api/internal/credits/razorpay/order) — separate repo/PR per pramaan-docs ADR-031 |
Minting credits / the notes round-trip | S8 — the signature-verified webhook, untouched by this Story |
| NAT gateway / egress Lambda | Nowhere — founder decision: backend stays internet-free |
| A migration | None — pure code + contract change |
| Check | Result |
|---|---|
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 suite | 2648 passed against the isolated test DB. |
| Contracts | make 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). |