Buy pages, and never pay twice.
Billing & Usage gains the prepaid page pack. The hard part is not the checkout — it is the gap after it: Razorpay has the money, the pages have not landed, and the rational thing for a customer staring at an unchanged balance is to pay again. This story closes that gap, and puts an expiry date on every balance that has one.
What counsel sees
100 / 500 / 1,000 pages at the ruled flat ₹20 a page (OQ-3). The button names its target — "Buy 500 pages", never a bare "Buy".
Between capture and credit: "we have your payment, pages are on the way." The buy action is withheld, so a second payment is not offered (SB-21).
Every expiring balance shows its date; inside the final 30 days it says so, and a lapsed one is marked expired (SB-30).
When every pool is exhausted, the block is named along with the way out — and the copy keeps saying that reading is never blocked (SB-18).
A non-INR account is never shown Razorpay, only sales (SB-32).
A Member is told who buys pages, and sees no firm amounts at all (SB-19 / D15).
The amount is never the browser's to state
The acceptance criterion is "no client-computed amounts: server quote only", and the way it is met is that the request has no amount field to tamper with. The browser posts { packId }; src/lib/billing-usage/page-packs.ts is the single place a price lives, and the BFF reads the amount from there. A forged packId does not resolve to a pack, so it cannot become a ₹0 order. This mirrors the same law in the functions payment core (payment/orders.py): the browser never states a number we believe.
The path a purchase takes
| Step | Where it runs | What it does |
|---|---|---|
| Pick a pack | browser | BuyPagesCard posts packId only. |
| Refuse or quote | POST /api/internal/billing/page-packs/buy | Firm-Admin gate → region gate → price from config → Razorpay order. |
| Create the order | Vercel (this app) | ADR-167 forbids a NAT gateway, so a functions Lambda cannot reach api.razorpay.com. The egress is here; key_secret never leaves the server. |
| Authorize | Razorpay Checkout | Loaded on demand — nobody who is not buying pays for the vendor bundle. CSP already allows it. |
| Credit the pages | pramaan-functions webhook | The only thing that mints pages. The browser callback is UX and writes nothing. |
| Close the gap | browser | Pending is held in the client store, poll every 5s, cleared when offlinePurchasedPages passes the baseline taken at authorization. |
Why the pending state is client-only
It records the one fact the server cannot tell us during the gap: this browser handed money over. It is persisted (store v5, whitelisted through partialize, migrated from v4) because the gap outlives a reload. It clears on a baseline — the purchased pool passing its value at authorization — rather than on arithmetic, so an unrelated concurrent grant cannot clear it early. After ten minutes it stops promising and points at support, because a spinner that never resolves is a lie.
Review path
| Storybook state | Expected proof |
|---|---|
| Settings / Billing / BuyPagesCard / Pack Catalogue | Three packs at ₹2,000 / ₹10,000 / ₹20,000, each showing ₹20 per page; button reads "Buy 100 pages". |
| Settings / Billing / BuyPagesCard / Payment Pending | "Payment received — pages are on the way"; no buy button anywhere in the card. |
| Settings / Billing / BuyPagesCard / United States Account | Sales link only; no packs, no checkout. |
| Settings / Billing / BuyPagesCard / Member Not Firm Admin | "Only a Firm Admin can buy pages"; no action offered. |
| Settings / Billing / BillingUsagePage / Zero Balance | The empty-balance notice above the pools, with the reading-is-unaffected sentence. |
| Settings / Billing / BillingUsagePage / Expiring Balances | A card purchase 12 days from lapsing flagged "Expires in 12 days"; a lapsed grant marked "Expired". |
| Settings / Billing / BillingUsagePage / Overage Spike | Overage pages and amount, shown to the Firm Admin only. |
What this story does not do
The emailed advance notice (SB-30, second half) is a pramaan-functions job. This story delivers the in-app half: the date and the 30-day flag.
A live captured payment on staging is proved by Chore-2718 (station liveness smoke), not here. This surface needs RAZORPAY_KEY_ID / RAZORPAY_KEY_SECRET in the Vercel environment; without them the route returns a typed checkout_unavailable and the card says so rather than failing silently.
Region is inferred from billing currency, because no app-api response carries business.firms.region yet — the ADR-168 claim exists in the database and in the signup DTOs, but nothing exposes it to this surface. An unpriced account defaults to India per the RFD's own G5c rule. Every call site funnels through src/lib/billing-usage/region.ts, so exposing the real field is a one-line change.
Files changed
28 file(s), +1971 / -28 lines
| Path | Role | + | - |
|---|---|---|---|
| messages/en.json | Config | +37 | -2 |
| messages/hi.json | Config | +37 | -2 |
| openapi/permission-map.json | Config | +7 | -0 |
| src/app/[locale]/(workspace)/settings/firm/billing/page.tsx | App route | +6 | -3 |
| src/app/api/internal/billing/page-packs/buy/route.ts | App route | +119 | -0 |
| src/components/settings/billing/allowance-grants-list.tsx | Component | +35 | -13 |
| src/components/settings/billing/billing-alerts.tsx | Component | +78 | -0 |
| src/components/settings/billing/billing-fixtures.ts | Component | +48 | -0 |
| src/components/settings/billing/billing-usage-page.stories.tsx | Component | +97 | -0 |
| src/components/settings/billing/billing-usage-page.tsx | Component | +19 | -1 |
| src/components/settings/billing/buy-pages-card.stories.tsx | Component | +85 | -0 |
| src/components/settings/billing/buy-pages-card.test.tsx | Component | +149 | -0 |
| src/components/settings/billing/buy-pages-card.tsx | Component | +110 | -0 |
| src/components/settings/billing/buy-pages-states.tsx | Component | +74 | -0 |
| src/components/settings/billing/page-pack-options.tsx | Component | +74 | -0 |
| src/components/settings/billing/presentation.test.ts | Component | +93 | -0 |
| src/components/settings/billing/presentation.ts | Component | +105 | -0 |
| src/components/settings/billing/use-page-pack-purchase.ts | Component | +151 | -0 |
| src/lib/billing-usage/checkout.ts | Library | +142 | -0 |
| src/lib/billing-usage/page-packs.test.ts | Library | +59 | -0 |
| src/lib/billing-usage/page-packs.ts | Library | +39 | -0 |
| src/lib/billing-usage/razorpay.ts | Library | +93 | -0 |
| src/lib/billing-usage/region.ts | Library | +36 | -0 |
| src/lib/surface-registry/route-registry.ts | Library | +2 | -2 |
| src/stores/ui-store.test.ts | Test | +7 | -2 |
| src/stores/ui-store.ts | Source | +50 | -3 |
| tests/features/buy-pages.feature | Test | +44 | -0 |
| tests/steps/buy-pages.steps.ts | Test | +175 | -0 |