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.
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.
| Route | Session | What it does |
|---|---|---|
POST /admin/grant-codes | PlatformAdmin | Mint a campaign code (server-generates the bearer code when omitted) |
GET /admin/grant-codes | Staff | List codes with live N / max tally + derived status |
GET /admin/grant-codes/{code_id} | Staff | One code |
POST /admin/grant-codes/{code_id}/disable | PlatformAdmin | Soft-disable with a reason (idempotent) |
GET /admin/firms/{firm_id}/payment-orders | Staff | Orders + mandate evidence + D17 disputed flag |
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.
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).
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.
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.
openapi/admin-api.json; pramaan-admin's
provisional pending-admin-contracts-2749.ts maps onto them via pnpm kubb:generate.
Two deliberate divergences from that scaffold, because the backend is the source of truth:
validUntil is required (the DB column is NOT NULL), and money is amountPaise
not amountMicros (the payment rail is paise-native).perUserOnce is always true and a create with false is a 422 — per-person
uniqueness is a hard DB constraint (UNIQUE (grant_code_id, redeemed_by_user_id), D26), so
a code redeemable twice by one person is unrepresentable.grant_code.created / grant_code.disabled),
registered in the audit registry and the route-coverage manifest. An idempotent re-disable of an
already-disabled code writes no second row.| Question | Answer |
|---|---|
| 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/features/admin_grant_codes.feature + tests/steps/test_admin_grant_codes.py — mint→list with tally, disable keeps evidence + idempotent, SB-19 authz (support engineer 403, staff read 200).tests/integration/test_admin_firm_payments.py — orders + mandate evidence readable; D17 disputed flag true when an order is disputed.tests/unit/test_grant_code_admin_logic.py — status derivation ordering + code generator.