Story-2722 - pramaan-admin - Billing surfaces

Campaign grant-code management and firm payment/mandate read views

Two staff surfaces land ahead of their backend: a new /grant-codes control room where the founder mints and disables campaign codes customers redeem at signup for page credit (with live redemption caps), and read-only payment-order + mandate evidence plus the D17 disputed-account flag on a firm's Billing tab. Both consume admin-api routes proposed in pramaan-io/pramaan-spec#2749, which is not yet built on pramaan-functions main.

Before

Grant codes existed only as customer-redemption plumbing (pramaan-functions #747: billing.grant_code tables + the app-api /provision/redeem-grant path). The founder could not mint or disable a campaign code without a deploy, and no admin surface existed. The Razorpay payment core (#740) built orders/mandates/webhooks but exposed nothing on admin-api, so staff could not read a firm's payment orders, mandate evidence, or dispute state from the control room.

After

/grant-codes lists every campaign code with a live N / max redemption tally and status, mints a new code (label, optional custom code, pages, cap, validity window, per-user binding), and disables an active code with a required reason — append-only, redemption evidence is never deleted. The Firm Billing tab gains a read-only FirmPaymentsPanel: payment orders (provider order id, amount, state, linked mandate), mandate evidence (provider mandate id, state, provider customer, consent timestamp), and a prominent disputed-account banner + badge when D17 is set.

Backend dependency — this is the admin half of a split

The originating ticket assumed the admin contracts had shipped. They had not: #747 touched only openapi/app-api.json, and #740 added only POST /webhooks/razorpay to openapi/admin-api.json. Verified against pramaan-functions main directly (router registry + make openapi snapshot). The missing admin routes are now tracked in pramaan-io/pramaan-spec#2749 (Admin grant-code management + payment/mandate read endpoints). This PR is the admin UI, built against those proposed shapes so it lands ahead of the backend.

Proposed upstream route (#2749) Admin consumer
GET /admin/grant-codes billingGateway.listGrantCodes() → /grant-codes page
POST /admin/grant-codes mintGrantCode action → GrantCodeManager
POST /admin/grant-codes/{id}/disable disableGrantCode action → DisableGrantCodeDialog
GET /admin/firms/{id}/payment-orders (+ mandates, disputed) billingGateway.listFirmPayments() → FirmPaymentsPanel

New surfaces

Wiring notes

  • No hand-written src/gen. The generated contract snapshot is untouched. Provisional DTO shapes live in src/lib/billing/pending-admin-contracts-2749.ts — plain TS types, clearly marked transient, deleted after #2749 merges and pnpm kubb:generate runs. The file is named to avoid the **/contracts/** enforcement path.
  • One fetch seam per read/write, marked TODO(#2749). Each new billingGateway method casts the upstream payload to the provisional shape at the exact spot where assertContract(<genZodSchema>, …) will go once the schema exists — no runtime parse until then, by design.
  • Reads are fetched server-side alongside the rest of the firm detail RSC (fetchFirmBillingExtras); the payments read degrades to an "unavailable" panel on the pre-backend 404 and a distinct access-denied state on 403, so the Billing tab never breaks while #2749 is pending. The /grant-codes list fetch mirrors /pricing: 403 renders AccessRestricted, other errors throw.
  • Writes are server actions (mintGrantCode, disableGrantCode in src/lib/actions/grant-codes.ts) with zod input validation, typed field errors, persistent form errors, and ADR-124 business-action audit logging (new grant_code_minted/grant_code_disabled actions, grant_code target type). Server-confirmed only: on success the client calls router.refresh() — no optimistic cache flip anywhere.
  • Client islands never import a "use server" action file directly; the RSC pages pass the actions down as props (onMintCode, onDisableCode), mirroring the existing onRecordGrant pattern.
  • pramaan-docs ADR-124: no new manual span. The new writes route through audited server actions over the existing gateway HTTP boundary (ADR-104 bridge); this PR adds no new handler, queue, or job boundary of its own.

Storybook coverage

Verification