> ## Documentation Index
> Fetch the complete documentation index at: https://docs.brainstormer.io/llms.txt
> Use this file to discover all available pages before exploring further.

# Moving a Rate Card Between Environments

> Export a billing rate card from dev and import it into staging or production as a draft — what travels, what does not, and why activation stays a separate step.

## 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.**

<Note>
  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.
</Note>

## The flow

<Steps>
  <Step title="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.
  </Step>

  <Step title="Export">
    On the selected version, press **Export card** (or run the CLI). You get a
    JSON file carrying the card plus its provenance.
  </Step>

  <Step title="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.
  </Step>

  <Step title="Create the draft">
    Re-run with `--apply`. A **draft** is created — nothing about what customers
    are charged has changed yet.
  </Step>

  <Step title="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.
  </Step>
</Steps>

## Commands

```bash theme={null}
# What is here, and which card is which
docker compose run --rm --no-deps -T bot \
  node dist/cli/rate-card.js --list

# Read a card out (omit --version for the ACTIVE card)
docker compose run --rm --no-deps -T bot \
  node dist/cli/rate-card.js --export --version 4 --out /tmp/rate-card.json

# Dry run on the target environment. Writes NOTHING.
docker compose run --rm --no-deps -T bot \
  node dist/cli/rate-card.js --import /tmp/rate-card.json

# Create the draft, after reading the above.
docker compose run --rm --no-deps -T bot \
  node dist/cli/rate-card.js --import /tmp/rate-card.json --apply \
  --note "promoting dev v4 after the golden replay"
```

<Warning>
  `--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.
</Warning>

`--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

```json theme={null}
{
  "documentVersion": 1,
  "provenance": {
    "exportedFrom": "development",
    "exportedAt": "2026-09-08T09:12:44.108Z",
    "exportedBy": "ops@example.com",
    "sourceVersion": 4,
    "sourceStatus": "active",
    "sourceActivatedAt": "2026-09-06T15:42:31.000Z",
    "sourceNotes": "v4: repriced ingest after the Apify contract change"
  },
  "policySha256": "1f4a…",
  "policy": {
    "creditValueUsd": 0.00375,
    "defaultMargin": 2,
    "items": [],
    "fees": []
  }
}
```

`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:

```bash theme={null}
# On each environment
node dist/cli/rate-card.js --list
# v4  active    sha256:1f4a0c9d2b31
```

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

<AccordionGroup>
  <Accordion title="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.
  </Accordion>

  <Accordion title="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.
  </Accordion>
</AccordionGroup>

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:

```
Imported from development v4 (active, activated 2026-09-06T15:42:31.000Z)
· sha256:1f4a0c9d2b31… · exported 2026-09-08T09:12:44.108Z by ops@example.com
· import note: promoting dev v4 after the golden replay
```

Because the checksum is in that line, lineage is also one query away:

```sql theme={null}
SELECT version, status, notes
  FROM billing_rate_policies
 WHERE notes LIKE '%sha256:1f4a0c9d2b31%';
```

## 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

| Method | Path | Notes |
| - | - | - |
| GET | `/rate-policies/:version/export` | Superadmin only. Returns `{ success, filename, checksum, document }`, where `document` is the pre-serialized file as a **string** — the gateway re-serializes bodies, and byte-for-byte output is what makes the file diffable and checksumable. |

Source: `services/bot/src/services/rate-card-document.ts` ·
`services/bot/src/cli/rate-card.ts` ·
`services/bot/src/routes/rate-policies.routes.ts`.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.