Story 2548: Replace Credits with Billing & Usage
Why this exists. The old /settings/firm/billing page showed an AI-credit balance,
a top-up checkout, and a per-lawyer credit-usage breakdown — vendor/token economics the firm was never supposed
to see. Story-2545 shipped four firm-safe /app/billing/usage/* reads (current-period, receipts,
breakdown, grants) plus the Story-2538 CREATE preflight. This story replaces the credits page IN PLACE with a
Billing & Usage dashboard built on that contract: page pools (contracted/onboarding/offline-purchased),
current-period usage with the READ(intake)/CREATE split, overage, allowance grants (including staff-recorded
cheque purchases), and usage receipts. No tokens, AI credits, model costs, or vendor economics ever render here.
Same route, new page (AC1)
src/app/[locale]/(workspace)/settings/firm/billing/page.tsx is edited in place — no parallel
route was created. The route still resolves BillingUsageRoute, RSC-prefetches
current-period into a per-request QueryClient, and hydrates
BillingUsagePage (src/components/settings/billing/billing-usage-page.tsx) so the
primary pools/totals render on first paint (pramaan-docs ADR-095). The agent-surface key moved from
settings.credits to settings.billing — a deliberate migration (the surface's job
changed completely), not a rename of the old capability.
Individual / Firm + regional rate card (AC2)
UsagePoolsSummary reads currentPeriod.currency (INR/USD, the regional rate card
identity) and derives an Individual/Firm badge from useAppGetSeatsAndBilling()'s seat count
(active + invited <= 1 reads as Individual). The backend has no separate
per-account-type GET; currency + seat count are the two real, live signals the customer-safe contract exposes.
Pools, usage split, overage (AC3)
PageUsageCurrentPeriodResponse is reconciled live from the immutable page ledger (Story-2545),
never a cached projection. UsagePoolsSummary renders contracted / onboarding / offline-purchased
pools each as granted-vs-remaining, then the intake/create used split, overage pages, and amount due — all in
integer micros, formatted via formatMoneyMicros.
Cheque purchase as an immutable grant (AC4)
AllowanceGrantsList renders GET /app/billing/usage/grants
(PageAllowanceGrantFirmView) — an offline purchase appears as grantType: "offline_purchase"
with its receipt reference (externalReference) and validity window
(validFrom/validUntil). The DTO itself omits grant_reason and the acting
staff id (Story-2545), so internal notes never reach this view.
Preflight primitive (AC5, AC6 — partial, gap flagged honestly)
PagePreflightBanner (src/components/shared/page-preflight-banner.tsx) is a real,
generic component wired to the live POST /app/billing/create-preflight mutation
(useAppCreatePagePreflight) via the new BFF proxy. It takes a kind
("intake" | "create" | "generate-version") and renders the estimated-pages + max-charge copy,
with the generate-version copy explicitly stating "Saving is free" before the billable-generation
line (the free-edit-vs-billable-generate distinction AC6 asks for). Storybook covers all three kinds plus
estimating/error states.
Honest gap. Wiring this banner into the live INTAKE upload drawer, the
first-CREATE action, and a Generate-new-version confirmation is NOT done in this PR. Two of those three call
sites (CREATE / Generate-new-version) have no live backend-wired UI yet in this app —
frame-summary-history.tsx, the closest existing surface, is explicitly a design prototype with mock
versions ("the live version reads frame versions from the backend" — not built). Wiring the third
(MatterIntakeDrawer) needs a pre-upload page-count estimate that no client-side PDF parser in this
repo currently produces (pdfjs-dist is only used to render already-uploaded documents). Forcing a
wire-in now would mean fabricating an estimate or touching the live upload flow without that input — flagged as a
follow-up ticket rather than shipped half-working.
Completion evidence (AC7)
UsageReceiptsTable renders GET /app/billing/usage/receipts
(PageUsageReceiptItem): actual pages, the allowance/overage split, amount, the document id as the
version identity, and the receipt id. A credit receipt (failure/retry/blank-page) shows "No charge"
instead of an amount — the reversal, not a fabricated charge.
Deletion census (AC9)
| Deleted | Was |
|---|---|
src/components/settings/billing/credits-page.tsx (+ test) | AI-credit balance + top-up UI |
src/components/settings/billing/razorpay-checkout.ts | Razorpay checkout modal launcher |
src/components/settings/billing/use-create-topup-order.ts | Credit top-up order mutation hook |
src/components/agents/chat-credit-indicator.tsx | Per-chat credit meter (unmounted anywhere already) |
src/lib/credits/ (gateway, razorpay-order, topup-order + tests) | Server-side credit/Razorpay gateway |
src/app/api/internal/credits/** (route + chat/[id] + razorpay/order + test) | Credit BFF proxy routes |
/app/firm/credits* mapping in kubb-client.ts | Stale browser→proxy route now nothing calls |
Backend /app/firm/credits* endpoints and their src/gen/ mirrors are untouched —
Story-2545/PR#700 did not remove them, so deleting the generated client would just regenerate on the next
pnpm codegen against the current mirror. Retiring those backend routes is a separate,
out-of-scope follow-up.
Matter list/detail page volume (AC8 — NOT done, flagged)
Skipped in this PR. The matters list register uses a fixed CSS grid template
(MATTER_LIVE_REGISTER_GRID) shared by a high-traffic, already-fragile surface (recent history:
Bug-1773 N+1 fix, Story-2267 optimistic creates) — adding a page-volume column is a real structural change
(grid template, header i18n key, MatterListMatter type, the RSC breakdown-by-matter fetch, and the
row cell), not a drop-in addition. Matter detail has no single overview page to extend either — the
bare /matters/[matterId] route is a pure redirect into route-per-mode subpages (Brief, Record,
etc.), and picking the right subpage needs a product call this PR does not make unilaterally. Recommended as a
follow-up Story once the matter-shell owner names the right subpage.
Storybook coverage (AC10)
| Component | States |
|---|---|
Settings/Billing/BillingUsagePage | Individual, Firm, Empty, Loading, LoadError |
Settings/Billing/UsagePoolsSummary | Individual, Firm, OfflineGrantApplied, Overage |
Settings/Billing/AllowanceGrantsList | OfflineGrant, Empty, Reversed |
Settings/Billing/UsageReceiptsTable | Default, Empty |
Shared/PagePreflightBanner | BeforeIntake, BeforeFirstCreate, BeforeGenerateNewVersion, Estimating, EstimateFailed |
Codegen / OpenAPI mirror note
Story-2545's PR (pramaan-io/pramaan-functions#700) was still open (checks green, not yet merged) when this
story started. The openapi mirror + permission-map diff from that PR were applied directly to
openapi/app-api.json/openapi/permission-map.json in this worktree (git-apply of the
exact committed diff) so pnpm codegen produced the real generated hooks/types/Zod schemas — not
hand-authored contract mirrors (HR-22). If PR#700's content changes before merge, re-sync
(git -C ../pramaan-functions pull origin main && pnpm codegen) and diff
src/gen/ before merging this PR.
Verification
pnpm lint # 0 errors, pre-existing warnings only
pnpm test # 1330 passed; 1 file times out under full-suite parallel load
# (clients-internal-route.test.ts), passes standalone/isolated —
# pre-existing flake, unrelated to this change (no credits/billing
# code in that file)
pnpm build # NEXT_PUBLIC_ENV=local GATEWAY_SERVICE_KEY=dummy pnpm build — clean
pnpm check:routes # 38 surfaces fresh
pnpm check:catalog # 22 L1 surfaces, all carded or unsupported
i18n: every new string added to both messages/en.json and messages/hi.json under
BillingUsage (+ SettingsNav.billingUsage, AgentSurfaces.settingsBilling);
the old Credits namespace and its keys are removed. Token scan
(rg '#[0-9a-fA-F]{3,8}|rgba?\(|hsla?\(') is clean on every changed file — all colors are
Badge/token-backed Tailwind utilities.
Files changed
87 file(s), +9947 / -4886 lines
| Path | Role | + | - |
|---|---|---|---|
| messages/en.json | Config | +60 | -34 |
| messages/hi.json | Config | +61 | -35 |
| openapi/app-api.json | Config | +4590 | -3434 |
| openapi/permission-map.json | Config | +42 | -0 |
| src/app/[locale]/(workspace)/settings/firm/billing/page.tsx | App route | +46 | -12 |
| src/app/api/internal/billing/create-preflight/route.ts | App route | +31 | -0 |
| src/app/api/internal/billing/usage/breakdown/route.ts | App route | +37 | -0 |
| src/app/api/internal/billing/usage/current-period/route.ts | App route | +15 | -0 |
| src/app/api/internal/{credits => billing/usage/grants}/route.ts | App route | +3 | -3 |
| src/app/api/internal/billing/usage/receipts/route.ts | App route | +25 | -0 |
| src/app/api/internal/credits/chat/[id]/route.ts | App route | +0 | -27 |
| src/app/api/internal/credits/razorpay/order/route.test.ts | App route | +0 | -111 |
| src/app/api/internal/credits/razorpay/order/route.ts | App route | +0 | -77 |
| src/components/agents/chat-credit-indicator.tsx | Component | +0 | -46 |
| src/components/settings/billing/allowance-grants-list.stories.tsx | Component | +49 | -0 |
| src/components/settings/billing/allowance-grants-list.tsx | Component | +85 | -0 |
| src/components/settings/billing/billing-fixtures.ts | Component | +189 | -0 |
| src/components/settings/billing/billing-usage-page.stories.tsx | Component | +163 | -0 |
| src/components/settings/billing/billing-usage-page.tsx | Component | +108 | -0 |
| src/components/settings/billing/credits-page.test.tsx | Component | +0 | -235 |
| src/components/settings/billing/credits-page.tsx | Component | +0 | -351 |
| src/components/settings/billing/presentation.ts | Component | +52 | -0 |
| src/components/settings/billing/razorpay-checkout.ts | Component | +0 | -89 |
| src/components/settings/billing/usage-pools-summary.stories.tsx | Component | +65 | -0 |
| src/components/settings/billing/usage-pools-summary.tsx | Component | +127 | -0 |
| src/components/settings/billing/usage-receipts-table.stories.tsx | Component | +35 | -0 |
| src/components/settings/billing/usage-receipts-table.tsx | Component | +102 | -0 |
| src/components/settings/billing/use-create-topup-order.ts | Component | +0 | -29 |
| src/components/settings/settings-sidebar.tsx | Component | +5 | -5 |
| src/components/shared/page-preflight-banner.stories.tsx | Component | +81 | -0 |
| src/components/shared/page-preflight-banner.tsx | Component | +75 | -0 |
| src/gen/client/appGetCurrentPeriodUsage.ts | Generated contract | +62 | -0 |
| src/gen/client/appGetUsageBreakdown.ts | Generated contract | +59 | -0 |
| src/gen/client/appListFirmAllowanceGrants.ts | Generated contract | +56 | -0 |
| src/gen/client/appListUsageReceipts.ts | Generated contract | +59 | -0 |
| src/gen/client/index.ts | Generated contract | +4 | -0 |
| src/gen/hooks/index.ts | Generated contract | +32 | -0 |
| src/gen/hooks/useAppGetCurrentPeriodUsage.ts | Generated contract | +127 | -0 |
| src/gen/hooks/useAppGetCurrentPeriodUsageSuspense.ts | Generated contract | +126 | -0 |
| src/gen/hooks/useAppGetUsageBreakdown.ts | Generated contract | +127 | -0 |
| src/gen/hooks/useAppGetUsageBreakdownSuspense.ts | Generated contract | +125 | -0 |
| src/gen/hooks/useAppListFirmAllowanceGrants.ts | Generated contract | +119 | -0 |
| src/gen/hooks/useAppListFirmAllowanceGrantsSuspense.ts | Generated contract | +117 | -0 |
| src/gen/hooks/useAppListUsageReceipts.ts | Generated contract | +127 | -0 |
| src/gen/hooks/useAppListUsageReceiptsSuspense.ts | Generated contract | +125 | -0 |
| src/gen/index.ts | Generated contract | +157 | -0 |
| src/gen/schemas/pageAllowanceGrantFirmListResponse.json | Generated contract | +117 | -0 |
| src/gen/schemas/pageAllowanceGrantFirmView.json | Generated contract | +91 | -0 |
| src/gen/schemas/pageUsageBreakdownItem.json | Generated contract | +75 | -0 |
| src/gen/schemas/pageUsageBreakdownResponse.json | Generated contract | +101 | -0 |
| src/gen/schemas/pageUsageCurrentPeriodResponse.json | Generated contract | +102 | -0 |
| src/gen/schemas/pageUsageReceiptItem.json | Generated contract | +92 | -0 |
| src/gen/schemas/pageUsageReceiptListResponse.json | Generated contract | +122 | -0 |
| src/gen/types/AppGetCurrentPeriodUsage.ts | Generated contract | +69 | -0 |
| src/gen/types/AppGetUsageBreakdown.ts | Generated contract | +90 | -0 |
| src/gen/types/AppListFirmAllowanceGrants.ts | Generated contract | +62 | -0 |
| src/gen/types/AppListUsageReceipts.ts | Generated contract | +98 | -0 |
| src/gen/types/PageAllowanceGrantFirmListResponse.ts | Generated contract | +19 | -0 |
| src/gen/types/PageAllowanceGrantFirmView.ts | Generated contract | +80 | -0 |
| src/gen/types/PageUsageBreakdownItem.ts | Generated contract | +70 | -0 |
| src/gen/types/PageUsageBreakdownResponse.ts | Generated contract | +30 | -0 |
| src/gen/types/PageUsageCurrentPeriodResponse.ts | Generated contract | +85 | -0 |
| src/gen/types/PageUsageReceiptItem.ts | Generated contract | +78 | -0 |
| src/gen/types/PageUsageReceiptListResponse.ts | Generated contract | +29 | -0 |
| src/gen/types/index.ts | Generated contract | +71 | -0 |
| src/gen/zod/appGetCurrentPeriodUsageSchema.ts | Generated contract | +96 | -0 |
| src/gen/zod/appGetUsageBreakdownSchema.ts | Generated contract | +105 | -0 |
| src/gen/zod/appListFirmAllowanceGrantsSchema.ts | Generated contract | +83 | -0 |
| src/gen/zod/appListUsageReceiptsSchema.ts | Generated contract | +117 | -0 |
| src/gen/zod/index.ts | Generated contract | +50 | -0 |
| src/gen/zod/pageAllowanceGrantFirmListResponseSchema.ts | Generated contract | +28 | -0 |
| src/gen/zod/pageAllowanceGrantFirmViewSchema.ts | Generated contract | +52 | -0 |
| src/gen/zod/pageUsageBreakdownItemSchema.ts | Generated contract | +51 | -0 |
| src/gen/zod/pageUsageBreakdownResponseSchema.ts | Generated contract | +31 | -0 |
| src/gen/zod/pageUsageCurrentPeriodResponseSchema.ts | Generated contract | +63 | -0 |
| src/gen/zod/pageUsageReceiptItemSchema.ts | Generated contract | +69 | -0 |
| src/gen/zod/pageUsageReceiptListResponseSchema.ts | Generated contract | +30 | -0 |
| src/lib/billing-usage/gateway.ts | Library | +200 | -0 |
| src/lib/credits/gateway-razorpay.test.ts | Library | +0 | -63 |
| src/lib/credits/gateway.ts | Library | +0 | -105 |
| src/lib/credits/razorpay-order.test.ts | Library | +0 | -91 |
| src/lib/credits/razorpay-order.ts | Library | +0 | -92 |
| src/lib/credits/topup-order.ts | Library | +0 | -25 |
| src/lib/kubb-client.ts | Library | +6 | -5 |
| src/lib/surface-registry/page-card/catalog.ts | Library | +2 | -2 |
| src/lib/surface-registry/route-registry.ts | Library | +6 | -6 |
| tests/unit/lib/kubb-client.test.ts | Test | +9 | -9 |