Billing Provisioning — Behavior & Architecture¶
Scope. How the billing provisioner is wired (routes ↔ methods ↔ Stripe), the rules provision_rate_card_entry and provision_org_flat_meter follow when an entry already exists, and the explicit semantics of mid-cycle price swaps on a metered Stripe SubscriptionItem.
Companion docs: migration-rules.md (per-SKU price authority), prd.md (the why), data_flow/billing.md (runtime contract & meter-correctness guarantees).
Authoritative code: app/methods/billing_rate_card.py, app/methods/billing_preflight.py, app/core/integrations/stripe/client.py, app/routes/billing_routes.py.
If this file disagrees with the PRD, the PRD wins and this file is wrong — open a PR to reconcile.
Provisioning rules¶
The provisioner takes the least-invasive path that lands the target Stripe state. The decision is driven by the existing DB rate-card row and the live subscription snapshot — not by Stripe idempotency keys alone (those are a 24h replay cache, not a "find existing" lookup). These rules govern both provision_rate_card_entry (SKU) and provision_org_flat_meter (flat) unless noted.
R1 — One product per meter. The product is resolved find-or-create by meter (find_or_create_product_for_meter), never per-org: one product is shared across every org billing that meter (the meter is the global SKU, the price is the org lever, the SubscriptionItem ties a price to a subscription). The DB row's existing_product_id is only a drift hint — if it disagrees with the resolved product the provisioner logs rate_card.product.reassigned and proceeds with the resolved one; it never selects the product. The product carries no org-specific metadata.
R2 — Fetch active, non-deprecated only. Product lookup (list_products_for_meter) searches active:'true' AND metadata['meter_event_name']:'<meter>' AND -metadata['canonical']:'false'; prices are listed with active=True. Archived (deprecated) products are excluded by the active filter; the -canonical:'false' negation also drops any product flagged deprecated that was somehow left active. Freshly minted products (no canonical key) and the canonical (canonical="true") are unaffected.
R3 — Oldest-wins canonical. When several products or prices match, pick the oldest by Stripe created (tie-break on id). This is deterministic, so every caller — and any out-of-band reconciliation — converges on the same canonical instead of whichever Stripe Search/list happens to yield first.
R4 — Reuse a matching price; never mint a duplicate. Before creating a Price, find_matching_metered_price lists the product's active prices and reuses one matching all of (unit_amount, currency, billing_scheme="per_unit", recurring.usage_type="metered", recurring.meter). create_metered_price runs only when no match exists. So a new (or re-provisioned) rate card at an amount the product already serves reuses that existing price — it does not add a second Price. (Stripe Price objects are immutable and can't be deduped after the fact, so this check is what keeps the product clean across A↔B↔A amount oscillation.)
R5 — Amount change → reuse/create price, modify the existing sub-item. Keep the product and SubscriptionItem; resolve the price per R4; then SubscriptionItem.modify(price=resolved, proration_behavior="none") and rewrite the row's stripe_price_id. No new SubscriptionItem, no orphan. (See §D for the open-cycle billing effect.)
R6 — Currency change → hard fail. Currency swaps are out of scope: raise currency_swap_unsupported before any Stripe round-trip and route to ops.
R7 — Noop only when fully aligned. Skip every Stripe write only when the row's unit_amount_cents + currency match and all three Stripe IDs (stripe_product_id, stripe_price_id, stripe_subscription_item_id) are populated and Stripe agrees — the noop path probes the live SubscriptionItem's price and falls through to realign if it has drifted out of band. A row with a matching amount but a null Stripe ID also falls through so the missing piece is created/attached.
R8 — Unresolvable Stripe drift → hard fail. A live item on the target meter that doesn't match the entry fails with RATE_CARD_STRIPE_DRIFT (SKU) or flat_meter_price_mismatch (flat) for manual reconciliation. The flat path needs no reuse-on-drift logic because drift on an attached item is already a hard fail.
R9 — Preflight gates success. After writes land, re-run preflight_*_for_provision for the target mode before reporting success; flip_billing_mode runs it before flipping billing_mode, so an operator can't strand an org in an unbillable state. The cached subscription snapshot is busted both before the writes (don't read stale) and after (preflight sees Stripe's new state). Billable = {"active", "past_due"}, so repricing during a dunning window is allowed.
Decision matrix (R4–R8 at a glance)¶
| Existing state | Requested amount | Action | Rule |
|---|---|---|---|
| No entry / no attached item | (any) | Create meter + product + price + attach SubscriptionItem; insert row | R1–R4 |
| Amount + currency match, all three Stripe IDs populated, Stripe agrees | matches | Noop — no Stripe writes | R7 |
| Amount + currency match, but a Stripe ID is null or the live item drifted | matches | Fall through; create/attach only what's missing or realign | R7 |
| Amount differs | differs | Reuse product; reuse-or-create price; modify the existing sub-item | R4, R5 |
| Currency differs | differs | Hard fail currency_swap_unsupported |
R6 |
| Live item on the meter doesn't match the entry | (any) | Hard fail RATE_CARD_STRIPE_DRIFT / flat_meter_price_mismatch |
R8 |
Before this contract the provisioner leaned solely on idempotency-key replays and left an orphan SubscriptionItem plus a duplicate Price on every amount change; R4 + R5 eliminate both.
Metadata contract (products)¶
| Metadata key | Canonical product | Deprecated product |
|---|---|---|
meter_event_name |
= the meter (stamped at create) |
preserved if present |
canonical |
"true", or absent on freshly minted |
"false" |
Duplicates can still appear (rarely)¶
Stripe's product Search is eventually consistent (~a minute to index). The meter-keyed product idempotency key (see §C) collapses truly concurrent first-creates, but a create landing just after another — past the idempotency window, before the index catches up — can still slip through as a duplicate. This is not fully self-healing: R3 stops duplicates from compounding (the oldest stays canonical), but any stray duplicate product/price is reconciled out of band. (A one-time remediation that folded historical duplicates was removed once its backfill completed — see ENG-3216 in git history / the carved-off project/brandon-billing-migration-scripts.)
Reference¶
A. Endpoints & layering¶
The provisioner is three small endpoints, each with one job; composing them is the operator's job:
| Route | Method | Responsibility |
|---|---|---|
POST /v1/billing/<org>/rate_cards |
provision_rate_card_entry (in a loop) |
Idempotently land 1..N SKU rate-card entries (meter + product + price + SubscriptionItem + DB row). |
POST /v1/billing/<org>/flat_meter |
provision_org_flat_meter |
Idempotently land one flat-meter dimension (sent_mailer / bfcm_send), optionally sync mailer_price. |
POST /v1/billing/<org>/billing_mode |
flip_billing_mode |
Flip OrganizationConfiguration.billing_mode, gated on a preflight pass for the target mode. |
- Batch is the route's job.
/rate_cardstakes a 1..N array and owns the per-entry try/except, all-or-nothing semantics, and response assembly — there is nobatch_*method and no separate single-entry route. GET /rate_cardsembeds preflight. Each active row carriespreflight: {passed, failures, warnings}so the webapp renders drift (e.g.RATE_CARD_STRIPE_DRIFT) without a second round-trip; superseded rows serializepreflight: null.- The flip is its own route (no
set_billing_modeparam on the provisioners) so per-route legality rules don't leak into the methods and batch-then-flip stays an explicit two-step.
Layering follows the repo standard (root CLAUDE.md):
routes/billing_routes.py parse JSON → call method(s) → catch BillingProvisionError → JSON + 200/422
└── methods/billing_rate_card.py
├── provision_rate_card_entry / provision_org_flat_meter / flip_billing_mode (public)
└── _provision_stripe_meter_chain (private; meter→product→price→item)
├──▶ core/models/billing_rate_card.py (DB writes)
├──▶ core/models/organizations.py (mode + mailer_price writes)
├──▶ methods/billing_preflight.py (preflight_*_for_provision)
└──▶ core/integrations/stripe/client.py (StripeClient — all Stripe I/O)
Routes own HTTP shape only; methods own orchestration and the result dataclasses (ProvisionResult, OrgFlatProvisionResult) plus the flip result dict that is the wire contract; StripeClient owns every outbound Stripe call (methods touch the stripe SDK only to catch stripe.error.StripeError).
B. Errors across the layer boundary¶
Each provisioner method raises BillingProvisionError with stage, detail, stripe_request_id, and a partial dict. The route maps it:
- Single-entry routes (
/flat_meter,/billing_mode) → 422{"error": {"code": stage, "message": detail, "details": {...partial}}}. - Batch route (
/rate_cards) → per-entryitems[]of{billing_key, status: "ok"|"failed", stage, message, …}; 200 when all succeed, 422 when any fails. The finalbilling_modeflip runs only when the batch passes its all-entry preflight gate.
Stages: stripe_meter, stripe_product, stripe_price, stripe_subscription_item, stripe_customer, stripe_subscription, lookup, input, currency_swap_unsupported, preflight. Stripe errors are caught narrowly as stripe.error.StripeError so programmer errors still surface as 500s.
Partial state is visible. When the chain raises mid-way (e.g. stripe_price after meter + product landed), partial carries idempotency_keys and whichever of meter_id / product_id / price_id already landed — surfaced under error.details (single) or idempotency_keys + partial_stripe_ids on the failing item (batch). A failed batch entry's already-created artifacts are inert until billing_mode flips to SkuSpecificMeter, so the operator can fix and re-run without disturbing live billing.
C. Idempotency & retry¶
Most Stripe writes carry a deterministic idempotency_key scoped to (org, billing_key) or (org, meter_event_name); the product create is the exception — keyed on the meter alone (R1) because the product is shared across orgs:
product:meter:<meter_event_name>— product create. Keyed only on the meter, so concurrent first-creates across orgs collapse to one shared product.ratecard:<org>:<key>:price:<fingerprint>— price createratecard:<org>:<key>:subitem:<fingerprint>— first attachratecard:<org>:<key>:subitem_modify:<fingerprint>— price swap on an existing itemflatmeter:<org>:<meter>:…— same shape for the flat meter
<fingerprint> is a 12-char sha256 of the canonical-JSON call params, so any param change rotates the key and avoids Stripe's "same key, different params" 400. Two creates can't rely on keys alone: Meter.create doesn't accept idempotency keys (Stripe limitation) so the client does a find-by-event-name first; the product resolver likewise does a find-by-meter Search before create (R1–R3).
Batch amortizes preflight. provision_rate_card_entries runs all Stripe writes, busts the snapshot once, then runs preflight per entry — the first rebuilds the Redis snapshot from Stripe, the rest hit the warm cache (zero Stripe calls). Single-entry callers keep the writes → bust → preflight shape.
Re-running is safe: Stripe replays return the original object, and the DB row is upserted only after every Stripe ID is in hand, so a partial Stripe failure never leaves a half-written row.
D. Mid-cycle price-swap semantics¶
On an amount change (R5) the sub-item is repriced via SubscriptionItem.modify(price=new, proration_behavior="none"). Because the Billing Meters API records usage at the (customer, meter) level — not per SubscriptionItem — Stripe bills the open period's entire meter usage (including events emitted before the swap) at whichever Price the item references at invoice finalization. There is no Stripe mechanism to snapshot accrued usage at swap time for old-price re-pricing.
This is a deliberate, accepted tradeoff. The provisioner is an operator tool for corrections and migration ramp; uniform re-pricing of the open cycle is the intended outcome. A customer-facing change needing clean attribution should not use the provisioner — it needs a finalization-aware path (delete the item with clear_usage=False, proration_behavior="none", then attach a fresh item to the new Price; finalizes accrued usage at the old price at the cost of a new stripe_subscription_item_id). proration_behavior="none" is explicit: the other values govern fixed-quantity items and have no effect on metered usage.
E. Sandbox / test-mode & deliberate omissions¶
There is no dry_run flag and no STRIPE_SANDBOX_MODE switch: every route call performs real Stripe + DB writes against whichever account STRIPE_API_KEY points at (idempotency replay makes repeats safe). Test-mode usage is per-call — pass api_key=config.STRIPE_TEST_API_KEY into StripeClient(...). app/scripts/seed_stripe_sandbox_org.py does exactly this to create a test-mode Customer + TestClock + anchor Subscription and print the IDs to paste into the org's billing_* config; routes use the default client, so exercising them against a test-mode org requires the deployment's STRIPE_API_KEY to itself be a test key (don't try this in prod).
Also deliberately absent: auto-filing Linear tickets from inside the provisioner (wrong layer — a route may file from BillingProvisionError if wanted) and a "split SubscriptionItem on price change" path (see §D).