PR: #647: feat(billing): replace credits with Billing & Usage [Story-2548] by @pramaan-board-bot[bot]
View PR →

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)

DeletedWas
src/components/settings/billing/credits-page.tsx (+ test)AI-credit balance + top-up UI
src/components/settings/billing/razorpay-checkout.tsRazorpay checkout modal launcher
src/components/settings/billing/use-create-topup-order.tsCredit top-up order mutation hook
src/components/agents/chat-credit-indicator.tsxPer-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.tsStale 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)

ComponentStates
Settings/Billing/BillingUsagePageIndividual, Firm, Empty, Loading, LoadError
Settings/Billing/UsagePoolsSummaryIndividual, Firm, OfflineGrantApplied, Overage
Settings/Billing/AllowanceGrantsListOfflineGrant, Empty, Reversed
Settings/Billing/UsageReceiptsTableDefault, Empty
Shared/PagePreflightBannerBeforeIntake, 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.jsonConfig+60-34
messages/hi.jsonConfig+61-35
openapi/app-api.jsonConfig+4590-3434
openapi/permission-map.jsonConfig+42-0
src/app/[locale]/(workspace)/settings/firm/billing/page.tsxApp route+46-12
src/app/api/internal/billing/create-preflight/route.tsApp route+31-0
src/app/api/internal/billing/usage/breakdown/route.tsApp route+37-0
src/app/api/internal/billing/usage/current-period/route.tsApp route+15-0
src/app/api/internal/{credits => billing/usage/grants}/route.tsApp route+3-3
src/app/api/internal/billing/usage/receipts/route.tsApp route+25-0
src/app/api/internal/credits/chat/[id]/route.tsApp route+0-27
src/app/api/internal/credits/razorpay/order/route.test.tsApp route+0-111
src/app/api/internal/credits/razorpay/order/route.tsApp route+0-77
src/components/agents/chat-credit-indicator.tsxComponent+0-46
src/components/settings/billing/allowance-grants-list.stories.tsxComponent+49-0
src/components/settings/billing/allowance-grants-list.tsxComponent+85-0
src/components/settings/billing/billing-fixtures.tsComponent+189-0
src/components/settings/billing/billing-usage-page.stories.tsxComponent+163-0
src/components/settings/billing/billing-usage-page.tsxComponent+108-0
src/components/settings/billing/credits-page.test.tsxComponent+0-235
src/components/settings/billing/credits-page.tsxComponent+0-351
src/components/settings/billing/presentation.tsComponent+52-0
src/components/settings/billing/razorpay-checkout.tsComponent+0-89
src/components/settings/billing/usage-pools-summary.stories.tsxComponent+65-0
src/components/settings/billing/usage-pools-summary.tsxComponent+127-0
src/components/settings/billing/usage-receipts-table.stories.tsxComponent+35-0
src/components/settings/billing/usage-receipts-table.tsxComponent+102-0
src/components/settings/billing/use-create-topup-order.tsComponent+0-29
src/components/settings/settings-sidebar.tsxComponent+5-5
src/components/shared/page-preflight-banner.stories.tsxComponent+81-0
src/components/shared/page-preflight-banner.tsxComponent+75-0
src/gen/client/appGetCurrentPeriodUsage.tsGenerated contract+62-0
src/gen/client/appGetUsageBreakdown.tsGenerated contract+59-0
src/gen/client/appListFirmAllowanceGrants.tsGenerated contract+56-0
src/gen/client/appListUsageReceipts.tsGenerated contract+59-0
src/gen/client/index.tsGenerated contract+4-0
src/gen/hooks/index.tsGenerated contract+32-0
src/gen/hooks/useAppGetCurrentPeriodUsage.tsGenerated contract+127-0
src/gen/hooks/useAppGetCurrentPeriodUsageSuspense.tsGenerated contract+126-0
src/gen/hooks/useAppGetUsageBreakdown.tsGenerated contract+127-0
src/gen/hooks/useAppGetUsageBreakdownSuspense.tsGenerated contract+125-0
src/gen/hooks/useAppListFirmAllowanceGrants.tsGenerated contract+119-0
src/gen/hooks/useAppListFirmAllowanceGrantsSuspense.tsGenerated contract+117-0
src/gen/hooks/useAppListUsageReceipts.tsGenerated contract+127-0
src/gen/hooks/useAppListUsageReceiptsSuspense.tsGenerated contract+125-0
src/gen/index.tsGenerated contract+157-0
src/gen/schemas/pageAllowanceGrantFirmListResponse.jsonGenerated contract+117-0
src/gen/schemas/pageAllowanceGrantFirmView.jsonGenerated contract+91-0
src/gen/schemas/pageUsageBreakdownItem.jsonGenerated contract+75-0
src/gen/schemas/pageUsageBreakdownResponse.jsonGenerated contract+101-0
src/gen/schemas/pageUsageCurrentPeriodResponse.jsonGenerated contract+102-0
src/gen/schemas/pageUsageReceiptItem.jsonGenerated contract+92-0
src/gen/schemas/pageUsageReceiptListResponse.jsonGenerated contract+122-0
src/gen/types/AppGetCurrentPeriodUsage.tsGenerated contract+69-0
src/gen/types/AppGetUsageBreakdown.tsGenerated contract+90-0
src/gen/types/AppListFirmAllowanceGrants.tsGenerated contract+62-0
src/gen/types/AppListUsageReceipts.tsGenerated contract+98-0
src/gen/types/PageAllowanceGrantFirmListResponse.tsGenerated contract+19-0
src/gen/types/PageAllowanceGrantFirmView.tsGenerated contract+80-0
src/gen/types/PageUsageBreakdownItem.tsGenerated contract+70-0
src/gen/types/PageUsageBreakdownResponse.tsGenerated contract+30-0
src/gen/types/PageUsageCurrentPeriodResponse.tsGenerated contract+85-0
src/gen/types/PageUsageReceiptItem.tsGenerated contract+78-0
src/gen/types/PageUsageReceiptListResponse.tsGenerated contract+29-0
src/gen/types/index.tsGenerated contract+71-0
src/gen/zod/appGetCurrentPeriodUsageSchema.tsGenerated contract+96-0
src/gen/zod/appGetUsageBreakdownSchema.tsGenerated contract+105-0
src/gen/zod/appListFirmAllowanceGrantsSchema.tsGenerated contract+83-0
src/gen/zod/appListUsageReceiptsSchema.tsGenerated contract+117-0
src/gen/zod/index.tsGenerated contract+50-0
src/gen/zod/pageAllowanceGrantFirmListResponseSchema.tsGenerated contract+28-0
src/gen/zod/pageAllowanceGrantFirmViewSchema.tsGenerated contract+52-0
src/gen/zod/pageUsageBreakdownItemSchema.tsGenerated contract+51-0
src/gen/zod/pageUsageBreakdownResponseSchema.tsGenerated contract+31-0
src/gen/zod/pageUsageCurrentPeriodResponseSchema.tsGenerated contract+63-0
src/gen/zod/pageUsageReceiptItemSchema.tsGenerated contract+69-0
src/gen/zod/pageUsageReceiptListResponseSchema.tsGenerated contract+30-0
src/lib/billing-usage/gateway.tsLibrary+200-0
src/lib/credits/gateway-razorpay.test.tsLibrary+0-63
src/lib/credits/gateway.tsLibrary+0-105
src/lib/credits/razorpay-order.test.tsLibrary+0-91
src/lib/credits/razorpay-order.tsLibrary+0-92
src/lib/credits/topup-order.tsLibrary+0-25
src/lib/kubb-client.tsLibrary+6-5
src/lib/surface-registry/page-card/catalog.tsLibrary+2-2
src/lib/surface-registry/route-registry.tsLibrary+6-6
tests/unit/lib/kubb-client.test.tsTest+9-9