Story 2749: Admin grant-code management + payment/mandate read views

Adds the missing admin-api surface over two domains that already exist: the Story-2715 grant-code tables (#747) and the Story-2714 Razorpay payment domain (#740). The founder can now mint and disable a campaign code without a deploy, and staff can read a firm's payment orders, mandate evidence, and dispute state from the control room. Consumed by pramaan-admin (#2722), which regenerates its client from openapi/admin-api.json. No new domain, no money/redemption/cap logic — this is a read/write skin over frozen tables.

The shape: two halves, one fix-surface

Both halves are the same thing — staff-only admin-api billing endpoints over tables that already carry a staff_all_policy (migrations 0196/0197). Nothing here is net-new domain code; the write path reuses the grant-code tables and the read path reuses the payment tables.

RouteSessionWhat it does
POST /admin/grant-codesPlatformAdminMint a campaign code (server-generates the bearer code when omitted)
GET /admin/grant-codesStaffList codes with live N / max tally + derived status
GET /admin/grant-codes/{code_id}StaffOne code
POST /admin/grant-codes/{code_id}/disablePlatformAdminSoft-disable with a reason (idempotent)
GET /admin/firms/{firm_id}/payment-ordersStaffOrders + mandate evidence + D17 disputed flag

1. Writes are platform-admin; reads are any staff (SB-19)

Authority is enforced by the session dependency, not a require_permission literal — grant codes are platform config, not firm-owned, so the same split the page-allowance-grant routes use applies: PlatformAdminDbDep on mint/disable, StaffDbDep on every read. A support engineer minting a code gets a 403 from the dependency before the handler runs; the BDD scenario proves it by going through the mounted app, not by calling the service.

2. Disabling a code needs a home for the reason — and the codebase already has the shape

"Disable a code (soft state flip; never deletes redemption evidence). Reason captured." A soft-disable that captures a reason needs somewhere to put the reason. Deriving it from the audit log would force a LATERAL join on every list row.

The lazy, correct answer was already in the repo: business.firm_support_grant stores revoked_at + revoked_reason as columns. Migration 0200 mirrors that on billing.grant_code with disabled_at + disabled_reason — additive, nullable, expand-only, no RLS/GRANT change (the existing staff_all_policy and app-role grant cover the new columns). Disabling flips is_active false and stamps both; no redemption row is ever touched. The status the list shows is derived, most-salient-fact first:

disabled  (is_active = false)   -- the staff action wins even if also expired/full
expired   (now >= expires_at)
exhausted (redemptions >= cap)
active    (everything else, incl. not-yet-valid)

That ordering is the one branch worth a unit test on its own (tests/unit/test_grant_code_admin_logic.py), alongside the bearer-code generator (upper-case, no O/0/I/1/L glyphs a human mistypes off a slide).

3. The redemption door stays the only door

src/domains/grant_code/admin.py owns the code's own lifecycle only. It never mints a redemption, moves a page balance, or writes the ledger. The one path from a code to pages is still POST /provision/redeem-grant (Story-2715). Mint/list/disable cannot shortcut the cap, the per-person uniqueness, or the 90-day page expiry — those live on the redemption path and its DB constraints, untouched here.

4. Payment reads: paise, provider ids, and the D17 flag

GET /admin/firms/{firm_id}/payment-orders returns one bundle: orders[], mandates[], and disputed. Money crosses the boundary in paise — the unit the payment tables store — never a rescaled "micros" value or a formatted rupee string. Provider identifiers are exposed on purpose: staff cross-reference them against the Razorpay dashboard, exactly as the Firm Admin's own mandate view already surfaces the token.

D17 is a derived boolean: disputed = any order in state 'disputed'. A disputed firm is visibly flagged in the control room with no new column and no new state — the order lifecycle already carries 'disputed' (migration 0194's CHECK). The order→mandate link the admin scaffold typed is null: no FK joins an order to a mandate in the schema, and mandates are listed alongside, so the field is kept for shape parity and always null.

5. Contract + audit

Review gate

QuestionAnswer
New cloud capability?None. Reads/writes over existing billing.* tables the admin Lambda already reaches.
Who grants it, where?N/A — no new grant. Tables carry staff_all_policy + app-role SELECT/INSERT/UPDATE from migrations 0196/0197.
What check goes red if absent?tests/contract/test_permission_map.py + tests/unit/test_audit_route_coverage.py (new routes must be classified); RLS on the tables (staff-only) is proven by the route tests returning rows only under a staff session.
Observed red?Yes — alembic check went red on an unmodelled column comment (fixed by dropping it); the disable-evidence BDD step went red on a missing firm seed (fixed to a bare uuid, since firm_id carries no standalone FK).
Legacy deleted?Nothing to delete — net-new admin surface. No compat path introduced.
Deploy smoke exercises the change?N/A for this Story (no deploy-workflow change); route behaviour is proven by pytest-bdd + integration tests hitting the mounted app.

Tests