billing.plan, billing.pricing_config). But those rows were
read-only to the running app — the only way to change a number was still a
migration and a deploy. That is a dial you can see but not turn. The catastrophe
this Story prevents is the founder needing an engineer to change a price: markup
3×→4×, GST 18→12, a plan retired — each of those is a commercial decision,
not a code change. Story-2163 ships the S2 write API so staff turn the knob by editing data.
It does so under three laws that never bend: a firm can never edit its own pricing (STAFF-scoped
only), every edit is audited old→new, and an edit affects the next order/run
only — past ledger rows and past orders are immutable (append-only, pramaan-docs ADR-158).
A staff-only /admin/billing surface reads and PATCHes every commercial number on
billing.plan (per plan) and billing.pricing_config (global), with the
write gated at two layers (a platform-admin dependency plus a new RLS FOR UPDATE
policy), each PATCH audited as a sparse old→new diff, and the change picked up live by the
next debit because the debit path already reads the row.
S1 mounted a thermostat (the config rows) but left it behind glass. Story-2163 fits the
knob and a keyed faceplate. The knob is the PATCH route. The lock is two independent
tumblers that must both turn: the app-layer dependency verifies the caller is an active
platform admin, and the database-layer RLS policy independently refuses the UPDATE
unless the session carries app.is_staff=true. A firm-scoped session — the one a
law firm's own app uses — sets neither, so a firm literally cannot turn its own price knob even
if a bug let the request through. And every turn of the knob leaves a tamper-evident mark in the
audit log: who, when, from what to what.
src/shared/routes/billing_config.pyFive endpoints under /admin/billing, mounted on the admin Lambda:
| Method + path | Session dep | Purpose |
|---|---|---|
GET /plans | Staff | Every plan, active first — inactive/legacy rows kept so a retired plan can be found and reactivated (lifecycle doctrine). |
GET /plans/{plan_key} | Staff | One plan; 404 pramaan.billing_plan.not_found. |
PATCH /plans/{plan_key} | Platform admin | Sparse edit of a plan's numbers. |
GET /pricing-config | Staff | The single global-knobs row. |
PATCH /pricing-config | Platform admin | Sparse edit of the global knobs. |
Reads use StaffDbDep (any active staff, Tier-1); writes use PlatformAdminDbDep
(platform-admin only, no firm in the URL) — the same read-staff / write-admin split the existing
firm-billing route uses.
S1 granted the app role only SELECT on the config tables. A route cannot
UPDATE a row it has no privilege on, and FORCE ROW LEVEL SECURITY subjects
every writer to a policy that did not exist. Migration 0159 opens exactly one door and no more:
GRANT UPDATE ON TABLE billing.plan TO pramaan_app;
CREATE POLICY config_staff_write_policy ON billing.plan
FOR UPDATE
USING (current_setting('app.is_staff', true)::bool = true)
WITH CHECK (current_setting('app.is_staff', true)::bool = true);
Only UPDATE — never INSERT/DELETE. Adding or retiring a
plan, and creating the singleton row, stay migration-owned (the config-driven taxonomy flip, ADR-151).
The migration is additive and fully reversible; the roundtrip reports no model drift.
Every PATCH field is optional (sparse edit). Ranges are enforced in Pydantic before a value reaches
the row: ai_markup>0, fx>0, gst_pct 0–28 (the
statutory ceiling), min_topup≥0, min_seats≥1. The DB CHECKs from S1
(markup/fx positive, cap_warn 0–100) remain the authoritative floor.
Sparse semantics differ by table because nullability differs. For a plan, an omitted field is left
unchanged and an explicit null clears a nullable column (seat price is genuinely
null on Startup) — so the service applies model_dump(exclude_unset=True). For the
global config, every column is required, so a null is not a clear; nulls are dropped on apply.
Each service loads the row, computes the set of fields whose value actually changes, and if that set
is empty returns unchanged with no UPDATE and no audit row (an idempotent no-op is not
an event). Otherwise it applies only the changed fields, flushes, and emits a typed
billing_plan.edited / pricing_config.edited diff. Two new audit subjects were
registered in the closed registry (area settings, no matter scope); the diff carries only
commercial numbers, never PII. The route-coverage manifest maps both PATCH routes to their action, so
a forgotten emit would fail the build.
Nothing in this Story touches a past row. The edit is a single UPDATE of the config
row; the append-only ledger and past orders are separate tables, never written here. The AI debit path
already reads ai_markup × fx_usd_to_inr live from the config row (S1) and stamps the
markup it applied onto each frozen ledger line — so a dial move re-prices the next run and
can never re-price a past invoice. The test proves this end-to-end: PATCH the markup, then
load_ai_pricing returns the new value on a fresh session.
ruff check — clean.load_ai_pricing; range
validation (gst 29, markup 0, min_seats 0) → 422; non-admin staff read-but-not-write → 403;
non-staff blocked entirely → 403; unknown plan → 404.alembic check
→ no new upgrade operations.make openapi + make gen-permissions — admin-api snapshot and
permission map regenerated and committed; app-api unchanged (admin-only surface).