Two new global config tables in the billing schema —
billing.plan (one row per commercial plan) and billing.pricing_config
(one row of cross-plan knobs) — are seeded with the founder's launch numbers, wired so
firm_subscription.plan is an FK into the plan catalog, and read by the AI debit path so
the markup and FX come from a row a staff member can edit instead of a constant a developer has to
redeploy.
Think of the old code as a heater with the temperature soldered in: to run warmer you
desolder the resistor and fit a new one (edit the constant, redeploy). Story-2159 replaces the
solder joint with a thermostat dial. The dial is billing.pricing_config:
a single row holding ai_markup=3.0, fx_usd_to_inr=110,
gst_pct=18, min_topup_inr=1000, wallet_floor_usd=1000,
cap_warn_pct=60. The per-plan knobs (seat price, bonus credits, storage, seat minimums)
are a second dial-board, billing.plan, one row per plan. The heater's control circuit —
the AI debit path — is rewired to read the dial each time instead of the soldered value.
Crucially, the wiring change is surgical: the debit's math contract, its once-per-run idempotency,
and the append-only ledger are untouched; only the two numbers it multiplies by now come off the
dial.
billing.plan — the per-plan dial boardOne row per commercial plan, every column an editable number. The migration seeds the two go-forward plans with the founder's placeholders (RFD §14):
startup min_seats=1 bonus_credits_inr=5000 free_storage_gb=5 storage_upgradeable=false
business min_seats=3 seat_price_inr=25000 free_storage_gb=100 storage_overage_multiple=5 usage_matrix_enabled=true
Per-plan nullability is intentional: seat_price_inr is NULL on Startup and
bonus_credits_inr is NULL on Business — a number that does not apply to a plan is
absent, not a misleading zero. monthly_allowance_inr_per_seat is seeded NULL on
Business because the founder left it unpinned in §14; staff set it in S2 rather than the
migration inventing a commercial number.
billing.pricing_config — the single cross-plan dialA singleton by construction: the primary key is a boolean defaulted true and CHECKed true, so a second row is a constraint violation, not a race. The founder launch values live as column server-defaults, and the migration inserts exactly one row:
ai_markup=3.0 fx_usd_to_inr=110 gst_pct=18 min_topup_inr=1000 wallet_floor_usd=1000 cap_warn_pct=60
firm_subscription.plan — a CHECK becomes an FKThe plan column was guarded by a hardcoded plan IN ('trial','starter','growth',
'enterprise') CHECK — the very kind of in-code taxonomy ADR-151 forbids. The migration drops
that CHECK and adds fk_firm_subscription_plan → billing.plan.plan_key: the allowed
set is now the catalog, editable without a deploy. The subtle part is not breaking firms
already on the old plans. The live app still writes trial on firm creation, so
a validated FK against a two-row (startup/business) table would reject every new firm. The migration
therefore also seeds the four legacy keys as inactive transitional anchor rows
(is_active=false, commercial fields NULL) so the FK validates for existing and new
subscriptions until the taxonomy migration (S2+) flips the app to the new keys. Seeding only the two
go-forward plans would have been "done at the component boundary" — green in a two-plan unit test,
broken the moment a real firm is created.
The pure credit-math core gains one optional parameter: debit_millicredits(...,
usd_to_inr=CREDITS_PER_USD). The default is the existing display peg, so every current caller
and unit test is byte-for-byte unchanged. The write path
(credit_ledger_writes.record_run_debit) now sources the two numbers from config:
config_markup, fx = await load_ai_pricing(session) # billing.pricing_config, one bounded SELECT
fee = platform_fee if platform_fee is not None else config_markup
millicredits = credits.debit_millicredits(cost_usd=..., platform_fee=fee, usd_to_inr=fx)
load_ai_pricing falls back to the as-built constants if the config row is missing, so
a debit can never fail on a config read — the same fallback-only posture the model-cost wave used.
The frozen platform_fee ledger column now stamps the config markup actually applied, so
per-row stamping (ADR-158) still protects past invoices when the dial later moves. The
ON CONFLICT (firm_id, run_id) once-per-run guard, the append-only trigger, and the
bucket-draw are all exactly as they were: this Story changes which two numbers the debit
multiplies by, nothing about when or whether it writes.
| Left out | Why |
|---|---|
| Staff admin edit route / DTOs | S2 owns the human surface; S1 is only the tables + seed the reader. |
| Migrating live firms off trial/starter/growth/enterprise | Grandfathered via inactive anchor rows; the taxonomy flip is a later story, not an S1 data rewrite. |
| Flipping live billing to 3×/₹110 | The debit path is gated by credit_metering_enabled (off in prod); wiring the source now is inert until metering is enabled separately. |
| Per-row FX ledger column | The effective rate is recoverable from stored micro-USD cost + millicredits; a dedicated column is a Wave-2 order-record concern (RFD §5.4), not asked here. |
| Any hardcoded commercial number | The whole point — every value is a seeded, editable row (unpinned ones seeded NULL, not invented). |
billing rows (plan + pricing_config),
makes firm_subscription.plan an FK into that catalog without breaking grandfathered
firms, and has the AI debit read markup & FX from the config row — all additive, reversible,
and with the append-only ledger and debit idempotency untouched.