Story 2545: Firm-safe customer Billing & Usage API
API Story — walkthrough exemption. Story-2545 is the API layer only; the consuming firm-facing UI is Story-2548 (pramaan-app). This note ships anyway so pr-closes-check's walkthrough gate is satisfied, per the ticket's explicit exemption.
Why this exists. The commercial page ledger (billing.page_billing_ledger, Story-2539) and its vendor cost ledger (Story-2543's unit_economics.py) sit behind admin-only routes. A firm has no way to see its own usage, receipts, or an admin-issued cheque grant — and it must never see vendor economics (tokens, model names, Textract/OpenRouter identity, storage allocation, margin). This story adds four /app/billing/usage/* GET routes that reconcile customer pages and money live from the immutable ledger, with matter-team-gated breakdowns.
Endpoints
| Route | Purpose |
|---|---|
GET /app/billing/usage/current-period | Contracted / onboarding / offline-purchased / used / remaining pages, overage, and money owed — reconciled live from ledger events, never a cached read. |
GET /app/billing/usage/receipts | Customer pages+money receipts: INTAKE/CREATE usage, credits, and CREATE preflight authorizations. Filters: verb, matterRef, documentId, period. |
GET /app/billing/usage/breakdown | Grouped by dimension=verb|matter|document|period; every row carries the one contractual READ(intake) vs CREATE split. |
GET /app/billing/usage/grants | Admin-issued allowance grants/reversals, firm-safe (drops grant_reason and granted_by_staff_user_id). |
Reconciliation: reading live, not the cached projection
billing.firm_monthly_usage is only rebuilt after a staff grant/reversal (page_allowance_grants.py) — INTAKE/CREATE charges never trigger a rebuild. Reading that table for the current-period response would lag a customer's just-completed charge. compute_current_period_summary instead re-derives every number directly from billing.page_billing_ledger with the same event-sign grammar the projection uses (usage:+1, credit:-1, reversal:-1), so it always reconciles exactly (AC1) regardless of when the projection last ran.
Matter-team gating without a hand-rolled permission check
business.matters' own RLS policy is business.can_read_matter(firm_id, id) (pramaan-docs ADR-099): firm_admin/firm_editor see every matter, a firm_member sees only matters they hold a business.matter_assignments row for. billing.page_billing_ledger itself carries only firm-level RLS — it isn't a matter-content table. Rather than re-implementing the assignment check, a matter-scoped read (matterRef filter, or dimension=matter/document breakdown) JOINs business.matters / documents.documents, so the existing RLS policy silently restricts which rows are even joinable — the same reuse-before-write instinct as the rest of this domain.
Firm-wide reads carry no extra gate. Current-period, unfiltered receipts, breakdown by verb/period, and the grant list follow the seats-and-billing precedent (src/app_api/seats_and_billing/router.py): any active firm member, RLS firm-isolation only, no require_permission dependency. That is the "firm billing authorization follows the existing settings policy" half of the acceptance criteria; the matter-team join above is the other half.
Never vendor economics
Every response DTO lives in src/domains/page_billing_ledger/customer_dto.py and is statically proven free of token/model/vendor/cost/margin/storage field names (tests/unit/test_page_billing_ledger_customer_dto.py). The platform-private vendor cost ledger (src/shared/routes/unit_economics.py, Story-2543) remains mounted only on the admin surface; nothing here reads from vendor_cost_event.
Verification
uv run ruff check uv run pytest tests/unit tests/integration/test_page_billing_ledger_customer_read.py tests/integration/test_rls_isolation.py make openapi make gen-permissions
Integration coverage: current-period reconciliation against seeded grants+usage, receipt field/vendor exclusion and filters, matter-breakdown gating (admin sees all, assigned member sees their matter, unassigned member sees nothing), a cross-firm RLS backstop (a mismatched firm_id argument still returns zero rows), and the customer-safe grant view hiding staff notes.