Story 2167: quote the price and check the receipt — the Razorpay buy-side, minus the money move

WHY. The catastrophe this Story prevents is charging a firm the wrong rupee amount, or trusting a forged “you were paid” callback. Selling AI credits over Razorpay has exactly two dangerous moments: the moment we quote a price (get the base, the 18% GST, or the credit count wrong and every invoice is wrong — in floats, wrong by a rounding drift nobody can reconcile), and the moment a callback claims a payment settled (if we believe an unsigned body, anyone who knows a firm id can mint free credits). This Story builds both guards — a Decimal-exact, config-sourced price computed in integer paise, and a constant-time HMAC over the raw callback body — and deliberately stops before the actual credit mint. The mint is S8’s job, behind the very signature this Story ships. The credit peg is the load-bearing decision: 1 credit = ₹1. The 3× markup is a usage-time lever (already live on the debit path); applying it again at purchase would make a firm pay ₹1,180 for ₹303 of wallet — double-counted margin, economically firm-negative. So markup and FX are recorded on the order for audit but never touch the credit count.

🛰 Satellite — the one sentence

A firm-facing POST /app/firm/credits/razorpay/order that reads GST + min-top-up from billing.pricing_config, prices a top-up in integer paise (base + 18% GST, 1 credit = ₹1) in Decimal, creates a Razorpay Order carrying {firm_id, credits, paise_charged} in its notes, plus a bare-HMAC verify_razorpay_signature primitive that S8’s webhook will authenticate the settlement callback with — the API key read from Secrets Manager, never hardcoded, and no credits minted anywhere in this Story.

✈️ Airplane — a shop counter that quotes and stamps, but never opens the till

Picture the credit shop with a counter clerk. A firm walks up and says “₹1,000 of credits, please.” The clerk does three things and no more: (1) quotes — looks up today’s tax rate and minimum from the pinned price list on the wall (pricing_config, not a number memorized in code), works the sum on paper to the exact paisa (₹1,000 + ₹180 GST = ₹1,180), and hands over a numbered ticket with the firm, the credit count, and the amount written on it; (2) sends the firm to the payment window (Razorpay) with that ticket; (3) keeps a signature stamp so that when the payment window later slides back a “paid” slip, the clerk can verify it is genuinely from the window and not a forgery. What the clerk never does in this Story is open the credit till — that lever (the mint) is bolted shut until S8, and it only turns for a slip that passes the stamp. The markup? That is the usage meter by the door, charged when credits are spent, not when they are bought — charging it here too would be charging rent twice.

🔬 Microscopic — the four moving parts

1. The price, in Decimal → integer paise (credits.py)

topup_order_pricing(topup_inr, gst_pct, min_topup_inr) is pure money math with one home (D-7). At the peg INR_PER_CREDIT = 1, ₹1,000 buys exactly 1,000 credits. The base is 1000 × 100 = 100_000 paise; GST is round_half_up(100_000 × 18 / 100) = 18_000 paise; the firm is charged 118_000 paise. Every intermediate is a Decimal; the result is a frozen TopupPricing of four ints — no float ever touches money (D-1/D-2). Below min_topup_inr it raises ValueError, which the route maps to a typed 422 pramaan.billing.below_min_topup. Note what is absent: ai_markup and fx_usd_to_inr are not arguments. They cannot change the credit count because the credit is a ₹-denominated unit and the markup already lives on the spend side.

2. The signature check — bare HMAC, no clock (webhooks/razorpay.py)

verify_razorpay_signature(payload, signature, secret) is the security twin of the Stripe verifier, with one deliberate difference: Razorpay signs the raw body alone — HMAC-SHA256(raw_body, webhook_secret), hex — with no timestamp in the signed payload and therefore no tolerance window. The compare is hmac.compare_digest (constant-time, so a forger cannot probe the secret byte-by-byte via timing). A tampered body, a wrong secret, a missing header, or an empty secret each raise RazorpaySignatureError. This Story ships and unit-tests the primitive; S8’s webhook route is the caller.

3. The key, from Secrets Manager — never hardcoded (load_razorpay_keys)

The API key id/secret and webhook secret live in AWS Secrets Manager; only the ARN is config (settings.razorpay_secret_arn), and even that is env-overridable. The loader checks a local env override first (so the test suite and local dev never call AWS), else fetches and JSON-parses the secret once per warm Lambda (lru_cache). The order-create POST goes out over httpx with Basic auth — an auto-instrumented client (ADR-124 T1), so no hand-rolled span.

4. The order — a self-describing ticket for S8 (router.py)

The notes dict carries everything S8’s mint needs to credit deterministically without re-deriving anything from money: firm_id, credits, paise_charged, plus the base_paise/gst_paise/gst_pct and the ai_markup/fx_usd_to_inr that priced this row — frozen per order (ADR-158: a later config edit never re-prices a placed order). markup/fx are on the ticket for audit only. The route mutates no local row, so it is allowlisted in the audit coverage gate with that reason; the mint that does write the append-only ledger is S8.

What this Story does not do

Deliberately absentWhere it lives
Minting credits / writing credit_ledgerS8 — the signature-verified webhook, behind this Story’s verify primitive
RefundsS8
A new table or migrationNone — reuses the S6 provider-generic credit_ledger; S7 is pure code
Applying markup/FX to the credit countThe usage/debit path (already live) — markup is a spend lever, not a buy lever (D-14)
Recall in one sentence: Story 2167 quotes a Razorpay top-up to the exact paisa from config (base + GST, 1 credit = ₹1, markup/FX recorded but never re-charged) and ships the constant-time raw-body HMAC that will authenticate the settlement — then stops at the till, leaving the mint for S8.