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.
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.
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.
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.
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.
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.
| Deliberately absent | Where it lives |
|---|---|
Minting credits / writing credit_ledger | S8 — the signature-verified webhook, behind this Story’s verify primitive |
| Refunds | S8 |
| A new table or migration | None — reuses the S6 provider-generic credit_ledger; S7 is pure code |
| Applying markup/FX to the credit count | The usage/debit path (already live) — markup is a spend lever, not a buy lever (D-14) |