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:
organization.configuration.stripe_customer_idmust be set → elseNO_STRIPE_CUSTOMER.- A
SubscriptionSnapshotmust exist with at least one billable item → elseNO_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_sendfor BFCM campaigns (resolved viacampaign.settings.flow_trigger == FlowTrigger.Bfcm),sent_mailerotherwise. - 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_diagnosticscompares the Stripeunit_amountagainstTEMPLATE_SIZE_BILLING_DEFAULTS(app/core/integrations/stripe/meters.py). Drift on a pinned key (A6_NL) emitsFLAT_METER_CANONICAL_DRIFT_PINNED; other keys emitFLAT_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_amount ≠ mailer_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 resolvedPreflightOutcomecarries 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 incrementsbilling.meter_rejected_no_customer, fires a Slack alert viaSlackNotifier.notify_billing_safety_event, and returnsFalse. - Idempotency. The Stripe identifier is
mailer-{recipient_id}(stripe_client.increment_billing_meter) —campaign_recipient_idis the billing idempotency key. Stripe meters uselast_during_periodaggregation, so the value is always 1 per recipient. - Failure path. A Stripe-side increment failure logs
billing.meter_increment_failedand returnsFalse. 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_failureLogfire counter, tagged withorganization_id+meter. - Emits a
billing.post_bill_failurelog event withrecipient_idandcause. - Fires a
POST_BILL_FAILURESlack 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:
SkuSpecificMeterorgs: reads active rate card rows vialist_billing_rate_card_entriesand returns arate_card: {billing_key: unit_cost}map plus the max asmailer_unit_cost(back-compat scalar). A SkuSpecific org with no active rows logsbilling.costs_sku_specific_no_active_rowsand falls back toLEGACY_MAILER_UNIT_COST_USD = 0.65so the dashboard never 500s.OrgFlatMeterorgs: returnsmailer_priceasmailer_unit_cost. A missingmailer_pricelogsbilling.costs_no_mailer_priceand 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) |