Skip to content

Billing

How a campaign send turns into a Stripe meter event in the codebase as it stands today. The migration narrative (why per-SKU exists, milestone state, cohort rollout) lives in projects/billing/; this doc is the runtime contract as implemented.

Operator references: billing/migration-rules.md (canonical price table), billing/provisioning-behavior.md (provisioner behavior & dry-run plan).


1. The pipeline

Every billed send walks the same three-step pipeline. The preflight is the single source of truth for what gets billed; the meter call forwards the resolved identifiers to Stripe; the post-bill failure path observes downstream errors without reversing real billing events.

flowchart LR
    A[Campaign activation /<br/>proofer / mailer send] --> P[preflight_campaign_billing<br/>app/methods/billing_preflight.py]
    P -->|outcome.passed=False| BLOCK[Block: 422 on activation,<br/>recipient → error state on send]
    P -->|outcome.passed=True| M[update_billing_meter<br/>app/methods/billing.py]
    M -->|Stripe MeterEvent.create| S[(Stripe)]
    M --> A1[Logfire audit logs:<br/>Customer meter billed + Mailer sent]
    A1 -->|downstream raises| PBF[record_post_bill_failure<br/>logfire counter + Slack]

    classDef block fill:#fdd,stroke:#c00,color:#600;
    classDef ok fill:#dff5e1,stroke:#2f9e44,color:#1b4d2b;
    class BLOCK block
    class S,A1 ok

2. Call sites

There is one production entry point — preflight_campaign_billing(campaign) — invoked from four places:

Layer File Purpose
Route app/routes/campaign_routes.py:413 Activation gate. A failing preflight returns 422 and the campaign stays in its prior state.
Celery app/celery/proofer.py:54 Proofing gate. Failure transitions the recipient to error state and is the right place to auto-pause the active campaign.
Celery app/celery/mailer.py:89 Pre-send batch gate per recipient.
Celery app/celery/mailer.py:476 Per-recipient send inside the batch loop, right before update_billing_meter.

The one-time billing-migration operator scripts (inspect_billing_mode.py, provision_meter.py) that called the same function for triage were carved off to project/brandon-billing-migration-scripts (ENG-3035) once the migration completed; the runtime dispatch lives in the methods layer. There is no separate billing_gateway seam.

3. Mode dispatch

OrganizationConfiguration.billing_mode (app/core/models/organizations.py, enum BillingMode) is the routing key. The preflight reads it on every call and dispatches to one of two evaluators. There is no waterfall and no implicit promotion — a SkuSpecificMeter org with a missing rate-card row is a hard block, not a fallback to flat billing.

BillingMode
├── OrgFlatMeter      → _evaluate_org_flat       (flat sent_mailer / bfcm_send item on the subscription)
└── SkuSpecificMeter  → _evaluate_sku_specific   (billing_rate_card_entries row per (org, billing_key))

Both evaluators share two upfront gates and the snapshot read:

  1. organization.configuration.stripe_customer_id must be set → else NO_STRIPE_CUSTOMER.
  2. A SubscriptionSnapshot must exist with at least one billable item → else NO_ACTIVE_SUBSCRIPTION.

4. The SubscriptionSnapshot

Live Stripe state is read through a Pydantic slice cached in Redis:

Field Value
Key billing:preflight:sub:{organization_id}
TTL 1800 seconds (30 min)
Built by _build_subscription_snapshot — lists active subscriptions and their items via StripeClient, resolves each item's meter_event_name via stripe.billing.Meter.retrieve (cached per snapshot build)
Busted by bust_subscription_snapshot(organization_id) — called by the M2.6 provisioner after attaching a new SubscriptionItem so post-attach preflights see fresh state

Net cost: one Stripe round-trip per org per ~30 min, regardless of send volume.

The snapshot is a flat pool of SubscriptionItemSnapshot rows across every active subscription on the customer (multiple active subscriptions are uncommon but supported). Two items sharing the same meter_event_name is a revenue-leakage scenario — find_item_by_meter_event_name logs billing.preflight.duplicate_meter_event_name and returns the first match.

5. OrgFlatMeter evaluator

Resolves against the legacy flat meter item.

flowchart TD
    F0[_evaluate_org_flat] --> F1{is BFCM?}
    F1 -- yes --> F2[require bfcm_send item]
    F1 -- no --> F3[require sent_mailer item]
    F2 --> F4{item attached?}
    F3 --> F4
    F4 -- no --> BLOCK[NO_FLAT_METER_ITEM_ATTACHED]
    F4 -- yes --> F5{unit_amount present?}
    F5 -- no --> BLOCK2[FLAT_METER_ITEM_MISSING_UNIT_AMOUNT]
    F5 -- yes --> F6{currency present?}
    F6 -- no --> BLOCK3[FLAT_METER_ITEM_MISSING_CURRENCY]
    F6 -- yes --> F7{non-BFCM:<br/>unit_amount == mailer_price*100?}
    F7 -- no --> BLOCK4[FLAT_METER_PRICE_DRIFT]
    F7 -- yes --> F8[Canonical drift check]
    F8 --> OK[passed=True<br/>emit at Stripe unit_amount]
    F8 -. diagnostic .-> DIAG[FLAT_METER_CANONICAL_DRIFT_*]

    classDef block fill:#fdd,stroke:#c00,color:#600;
    classDef ok fill:#dff5e1,stroke:#2f9e44,color:#1b4d2b;
    classDef diag fill:#eef,stroke:#669,color:#226;
    class BLOCK,BLOCK2,BLOCK3,BLOCK4 block
    class OK ok
    class DIAG diag

Notes:

  • The meter event name is bfcm_send for BFCM campaigns (resolved via campaign.settings.flow_trigger == FlowTrigger.Bfcm), sent_mailer otherwise.
  • The price-match invariant (unit_amount == mailer_price * 100) is enforced for non-BFCM only — BFCM has no canonical org-side price.
  • Canonical drift is diagnostic-only. After a passing price match, _org_flat_canonical_drift_diagnostics compares the Stripe unit_amount against TEMPLATE_SIZE_BILLING_DEFAULTS (app/core/integrations/stripe/meters.py). Drift on a pinned key (A6_NL) emits FLAT_METER_CANONICAL_DRIFT_PINNED; other keys emit FLAT_METER_CANONICAL_DRIFT. Neither blocks the send — they appear in the inspector but not on the warning stream.

6. SkuSpecificMeter evaluator

Resolves against billing_rate_card_entries. The active row for (organization_id, billing_key) is the contract — any divergence between the row and the live snapshot hard-blocks.

flowchart TD
    S0[_evaluate_sku_specific] --> S1{active rate card row<br/>for org, billing_key?}
    S1 -- no --> BLOCK[NO_RATE_CARD_ENTRY]
    S1 -- yes --> S2{row.stripe_subscription_item_id<br/>present in snapshot?}
    S2 -- no --> DRIFT[RATE_CARD_STRIPE_DRIFT]
    S2 -- yes --> S3{snapshot price_id<br/>== row.stripe_price_id?}
    S3 -- no --> DRIFT
    S3 -- yes --> S4{snapshot meter_event_name<br/>== row.stripe_meter_event_name?}
    S4 -- no --> DRIFT
    S4 -- yes --> S5{snapshot unit_amount<br/>== row.unit_amount_cents?}
    S5 -- no --> WARN[+ PER_SKU_PRICE_DRIFT warning]
    S5 -- yes --> OK[passed=True<br/>emit at row.unit_amount_cents]
    WARN --> OK

    classDef block fill:#fdd,stroke:#c00,color:#600;
    classDef ok fill:#dff5e1,stroke:#2f9e44,color:#1b4d2b;
    classDef warn fill:#fff4d6,stroke:#d99a00,color:#5c3d00;
    class BLOCK,DRIFT block
    class OK ok
    class WARN warn

The row's unit_amount_cents is what gets billed (DB is canonical — operators reconcile via the provisioner, never by patching the DB). Stripe unit_amount divergence surfaces as a warning, not a block.

7. The billing key

The "billing key" is the pricing axis. Resolved by _resolve_billing_key:

  • BFCM campaigns → bfcm_send (regardless of template size).
  • Every other campaign → campaign.template_size.value (a6, four_by_six, etc.).

CampaignTemplateSize is mapped to its canonical Stripe meter in TEMPLATE_SIZE_TO_BILLING_METER (app/core/integrations/stripe/meters.py). Unmapped sizes fall back to the legacy flat sent_mailer meter via get_billing_meter_for_template_size for continuity during the migration.

8. Reason codes

The fixed set the preflight emits (PreflightFailureCode, PreflightWarningCode, PreflightDiagnosticCode):

Code Class Mode Meaning
NO_STRIPE_CUSTOMER failure both Org has no stripe_customer_id.
NO_ACTIVE_SUBSCRIPTION failure both No active subscription with billable items.
NO_FLAT_METER_ITEM_ATTACHED failure OrgFlat Required flat item (sent_mailer, or bfcm_send for BFCM) not on subscription.
FLAT_METER_ITEM_MISSING_UNIT_AMOUNT failure OrgFlat Flat item's Price has no unit_amount (e.g., tier-priced).
FLAT_METER_ITEM_MISSING_CURRENCY failure OrgFlat Flat item's Price has no currency.
FLAT_METER_PRICE_DRIFT failure OrgFlat Stripe unit_amountmailer_price * 100 (non-BFCM only).
NO_RATE_CARD_ENTRY failure SkuSpecific No active rate card row for (org, billing_key).
RATE_CARD_STRIPE_DRIFT failure SkuSpecific Rate card row's stripe_subscription_item_id not present in live snapshot, or mismatched price/meter event name.
PER_SKU_PRICE_DRIFT warning SkuSpecific Live unit_amount ≠ rate card unit_amount_cents. Bills at DB value and emits warning.
FLAT_METER_CANONICAL_DRIFT diagnostic OrgFlat Inspector-only: mailer_price below canonical default.
FLAT_METER_CANONICAL_DRIFT_PINNED diagnostic OrgFlat Inspector-only: pinned key (A6_NL) diverges from canonical.

Every preflight call emits a billing.preflight Logfire breadcrumb tagged with route, passed, JSON-encoded failure_codes and warning_codes arrays — dashboards switch on the canonical strings.

9. PreflightOutcome

The dataclass returned to callers (app/methods/billing_preflight.py):

Field Notes
passed False blocks send/activation.
route BillingMode.OrgFlatMeter, BillingMode.SkuSpecificMeter, or None for blocked outcomes that never selected an evaluator.
rate_card_entry_id Set on SkuSpecific passes. The customer_meter_billed analytics payload FKs to it so the audit trail points at the exact pricing in effect.
stripe_subscription_item_id Resolved at preflight so the send path doesn't re-resolve.
stripe_meter_event_name The meter event_name update_billing_meter will fire.
unit_amount_cents, currency Resolved emit price. SkuSpecific reads from the rate card row; OrgFlat reads from Stripe (equal to mailer_price * 100 by invariant when passed).
failures, warnings, diagnostics Lists of canonical-coded structs. Diagnostics never reach the breadcrumb.

10. Emitting the meter event

update_billing_meter (app/methods/billing.py) forwards the resolved identifiers to Stripe. It performs no routing of its own — the preflight already decided.

Behavior:

  • Signature. update_billing_meter(organization, recipient_id, outcome) — the resolved PreflightOutcome carries the meter event name, unit amount, and currency, so this method only translates intent into a Stripe call.
  • Belt-and-suspenders customer guard. Re-checks stripe_customer_id (the preflight already required it). A miss increments billing.meter_rejected_no_customer, fires a Slack alert via SlackNotifier.notify_billing_safety_event, and returns False.
  • Idempotency. The Stripe identifier is mailer-{recipient_id} (stripe_client.increment_billing_meter) — campaign_recipient_id is the billing idempotency key. Stripe meters use last_during_period aggregation, so the value is always 1 per recipient.
  • Failure path. A Stripe-side increment failure logs billing.meter_increment_failed and returns False. The caller chooses whether to record a post-bill failure.

11. Post-bill failure observability

After a successful meter event, the mailer also emits structured Logfire audit logs ("Customer meter billed", carrying the build_customer_meter_billed_payload fields, and "Mailer sent"). If anything raises after billed=True (chiefly a payload-build error), the mailer calls record_post_bill_failure:

  • Increments the billing.post_bill_failure Logfire counter, tagged with organization_id + meter.
  • Emits a billing.post_bill_failure log event with recipient_id and cause.
  • Fires a POST_BILL_FAILURE Slack notification.

The bill is not reversed — the customer was billed for a real send and the physical mailer is in flight. Reversing would mask a real send; ops triage via the counter and the Slack alert instead.

12. Activation vs send path divergence

The same preflight gates both, but failure UX is different:

Where On failure
campaign_routes.py:413 (activation) Bare 422 reject. The campaign stays Prospect/Draft. No auto-pause — there's no Active campaign to pause.
proofer.py:54 and mailer.py:89,476 (send) Recipient transitions to error state with BILLING_NOT_READY and the first failure code as the cause. The proofer is also the canonical place to auto-pause an Active campaign that has drifted, so CAMPAIGN_AUTO_PAUSED reaches CSM.

13. /v1/billing/<org>/costs

get_costs_response in app/methods/billing.py is the in-product cost surface. It is mode-aware:

  • SkuSpecificMeter orgs: reads active rate card rows via list_billing_rate_card_entries and returns a rate_card: {billing_key: unit_cost} map plus the max as mailer_unit_cost (back-compat scalar). A SkuSpecific org with no active rows logs billing.costs_sku_specific_no_active_rows and falls back to LEGACY_MAILER_UNIT_COST_USD = 0.65 so the dashboard never 500s.
  • OrgFlatMeter orgs: returns mailer_price as mailer_unit_cost. A missing mailer_price logs billing.costs_no_mailer_price and falls back to the same legacy constant.

All values are converted to the org's preferred_currency via ExchangeRates.convert_currency.

14. Where things live

Concern File
Preflight evaluator + dispatch app/methods/billing_preflight.py
Meter emission + post-bill obs. app/methods/billing.py
Meter / template-size catalog app/core/integrations/stripe/meters.py
Rate card model (append-only) app/core/models/billing_rate_card.py
Rate card analytics payload app/methods/billing_rate_card.py
Billing API surface app/routes/billing_routes.py
Activation gate call site app/routes/campaign_routes.py:413
Send path call sites app/celery/proofer.py:54, app/celery/mailer.py:89, app/celery/mailer.py:476
Provisioning (rate-card → Stripe) app/methods/billing_rate_card.py (_provision_stripe_meter_chain)
One-time migration/triage CLIs carved off to project/brandon-billing-migration-scripts (ENG-3035)