Skip to content

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_cards takes a 1..N array and owns the per-entry try/except, all-or-nothing semantics, and response assembly — there is no batch_* method and no separate single-entry route.
  • GET /rate_cards embeds preflight. Each active row carries preflight: {passed, failures, warnings} so the webapp renders drift (e.g. RATE_CARD_STRIPE_DRIFT) without a second round-trip; superseded rows serialize preflight: null.
  • The flip is its own route (no set_billing_mode param 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-entry items[] of {billing_key, status: "ok"|"failed", stage, message, …}; 200 when all succeed, 422 when any fails. The final billing_mode flip 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 create
  • ratecard:<org>:<key>:subitem:<fingerprint> — first attach
  • ratecard:<org>:<key>:subitem_modify:<fingerprint> — price swap on an existing item
  • flatmeter:<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).