The problem
A rate card is built and simulated on dev, where being wrong is cheap. It then has to reach staging and production. Without a transfer path that means retyping a multi-kilobyte JSON document into another environment’s admin page — and the active card prices every charge the platform makes. This is the sibling of the plan catalog’s document (services/auth/src/cli/plan-config.ts) and follows the same posture: export
is an endpoint, import is the CLI.
A rate card is easier to move than the plan catalog in one respect: the policy
document (
creditValueUsd, defaultMargin, items[], fees[]) names no
per-environment identifier at all — no Stripe Price ids — so the whole card
travels and there is nothing to re-bind on the other side.The flow
1
Build and simulate on dev
Create the draft at
/admin/billing-rates, then replay a real subject
through it with the simulator before you move it anywhere. The environment
you send it to has no way of knowing it was never tried.2
Export
On the selected version, press Export card (or run the CLI). You get a
JSON file carrying the card plus its provenance.
3
Dry run on the target
Copy the file to the target environment and run the import with no
--apply. It validates the document, reports which local version the draft
would take, and shows how the card differs from the one that is live there.
It writes nothing.4
Create the draft
Re-run with
--apply. A draft is created — nothing about what customers
are charged has changed yet.5
Activate, deliberately
Simulate the new draft on the target environment, then activate it at
/admin/billing-rates with operator notes. There is no --activate flag
anywhere in this feature, and one must not be added.Commands
--import without --apply is the dry run, and that is the default on
purpose: the destructive form must be the one you have to type. --dry-run
spells the default out, and is refused alongside --apply rather than silently
losing to it.
The document
policySha256 is a checksum over the policy alone, in a canonical key
order — so two files carrying the same card have the same checksum whoever
exported them and whenever. That is what makes “is production running the card
we tested on dev?” a comparison rather than an excavation:
Two rules the importer enforces
Version numbers are per-environment and never travel
Version numbers are per-environment and never travel
Dev’s v4 is not staging’s v4 — they are two independent
max(version) + 1
sequences that happen to have started in the same place. The source version
is recorded as provenance and never honoured: the importer allocates the
next free local version inside the same advisory lock the “New draft”
button uses, and the INSERT carries no ON CONFLICT, so an allocation
that somehow collided aborts rather than overwriting a card that has priced
real money.An import creates a draft and stops
An import creates a draft and stops
An activated card is immutable and has priced real money. Activation is the
one action that changes what every customer is charged, it requires operator
notes the API refuses to proceed without, and it belongs to a human looking
at a simulation — not to the tail end of a file copy.
parseRatePolicy — the same validator the
create endpoint uses — so an ambiguous card (two rules of equal specificity) or
a cap finer than the ledger’s 4-decimal credit scale is rejected whole, with the
offending rule named. Nothing partial is ever written.
Provenance on the draft
The draft the importer creates carries its lineage innotes, which is what the
version rail on /admin/billing-rates already renders:
Why import is not in the admin UI
The same four reasons the plan catalog’s importer is CLI-only:- It writes a card into an environment’s billing system — a deploy-shaped operation, which belongs beside the migration runner in the deploy shell.
- The document is meant to be reviewed as a diff before it is applied.
- The dry run is the safety mechanism and it has to be read. A CLI prints what it would create and waits; a web form invites a click-through.
- A production admin UI is reachable by anyone holding a superadmin session. Its database is reachable by whoever holds the deploy shell. Those are not the same blast radius, and this operation belongs to the smaller one.
/admin/billing-rates therefore hands over the exact commands
instead of offering an upload field.
Endpoint
Source:
services/bot/src/services/rate-card-document.ts ·
services/bot/src/cli/rate-card.ts ·
services/bot/src/routes/rate-policies.routes.ts.
