Skip to main content

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

--export > file.json produces a corrupt file. The shared logger writes structured JSON to stdout and the database pool announces itself there at import time, so the redirect captures a log line ahead of the document. --out is required for exactly that reason.
--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:
A file whose policy was edited after export is rejected: it is no longer the card its provenance names, and importing it would stamp a lineage that never happened. Re-export from the environment that holds the card you want.

Two rules the importer enforces

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 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.
Every imported document goes through 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 in notes, which is what the version rail on /admin/billing-rates already renders:
Because the checksum is in that line, lineage is also one query away:

Why import is not in the admin UI

The same four reasons the plan catalog’s importer is CLI-only:
  1. It writes a card into an environment’s billing system — a deploy-shaped operation, which belongs beside the migration runner in the deploy shell.
  2. The document is meant to be reviewed as a diff before it is applied.
  3. 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.
  4. 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.
The panel on /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.