Self-Managed Billing Investigation¶
Status: Investigation memo — no implementation. Companion to
prd.md. Owner: Brandon Shin Last Updated: 2026-05-04
This memo investigates moving metering and invoice generation out of Stripe's metered billing product and running them ourselves. It does not propose action — it scopes the trade-offs so the team can decide whether the in-flight migration's M2 onward should continue as-designed, pause, or re-aim.
Context¶
PaperRun bills mailer sends through Stripe's metered billing product: every postcard send fires a stripe.billing.MeterEvent, and each org runs a Stripe Subscription with one metered SubscriptionItem per template size (a6, us_4x6, etc.). Threshold billing (billing_thresholds.amount_gte) auto-finalizes invoices at $1k / $2.5k tiers, and Stripe handles charge retries automatically. There is an in-flight migration (project/brandon-billing-migration) that is hardening this Stripe-metered model with a DB-first per-SKU rate card, reconciliation, and activation gates.
This memo investigates what would break and what we would have to build if we moved metering and invoice generation out of Stripe's billing product and ran them ourselves. It does not assume we leave Stripe entirely — the most useful framing is a soft fork (keep Stripe Customer + Charge + Invoice as a payment rail, replace metered Subscriptions + Meters with our own usage ledger that creates invoices on our cadence).
The driver: multi-SKU, multi-price proliferation¶
This is the load-bearing reason to investigate the move. Stripe metered billing assumes a small, stable set of meters with a small number of Prices attached. We don't have that. We have a growing matrix of (template_size × currency × per-org override), and every cell is a Stripe object that has to be provisioned, kept in sync, and reconciled.
Concretely, in the current model:
- Stripe Prices are immutable. Adjusting a customer's per-mailer rate means creating a new
Price, updating theSubscriptionItem.priceto point at it, and trusting period-boundary semantics. There's no "edit price effective tomorrow" — it's create-and-swap. - Per-org custom pricing fans out into per-org Prices. Any time a single customer has a non-standard rate, that's a fresh
Priceobject cloned for them — and a freshSubscriptionItemon their Subscription pointing at it. ~10–15% of mailer volume runs through orgs with custom pricing today, so this isn't a rare-edge-case cost — it's a meaningful, recurring slice of revenue whose pricing state has to be provisioned, kept in sync, and reconciled in Stripe per (org, SKU). Thebilling_rate_card_entriestable (app/core/models/billing_rate_card.py:60-82) storesstripe_price_id,stripe_product_id,stripe_subscription_item_idper(organization, billing_key)precisely because this fan-out is real and has to be tracked. - New SKUs require touching every active org. Adding a new template size means: create a global Stripe Meter, create a Product, create at least one Price (per currency), then walk every active org's Subscription and append a SubscriptionItem. This work is what M2 (designed but unbuilt) exists to automate. The entire activation-gate + readiness-cache machinery is there because per-org Stripe state has to catch up to the DB before a campaign can launch.
- Mid-period price changes have unintuitive semantics. Stripe applies the new Price to usage going forward, but proration / period-end behavior depends on subscription configuration — easy to get wrong, hard to test in production safely.
- Cross-SKU pricing constructs don't fit at all. A bundled volume tier ("first 10k mailers per month across all sizes at $0.45, then $0.40") cannot be expressed in Stripe's per-meter Price model. A cross-SKU threshold ("trip an invoice when combined unbilled value across all SKUs hits $5k") is similarly outside the metered-billing primitives —
billing_thresholds.amount_gteis per-subscription, not configurable per-SKU bundle. - Adding currencies multiplies the matrix.
Price.currency_optionslets a single Price hold per-currencyunit_amount, but the moment any org needs a non-default rate in any currency, the org gets its own Price object — and the per-currency conditioning has to be reasoned about per-org.
Self-managed metering + invoicing collapses this matrix into rows in billing_rate_card_entries. A new SKU is one (or N, for currency) DB inserts. A custom price for one org is one row. A price change is a new row with a later active_at — and because the M1 design (D11) already mandates append-only with [active_at, inactive_at) lifespans, the pattern slots in cleanly. The ledger snapshots unit_amount_cents at write time, so mid-period changes are deterministic without proration semantics. Cross-SKU constructs become SQL.
Put differently: M2's entire reason for existing is to keep Stripe's per-(org, SKU) state in sync with our DB. If we own invoicing, M2 mostly evaporates — readiness becomes "does an active rate card entry exist?", which is already in the database we just wrote to.
Scope: what "self-managed metering + invoicing" means¶
There is a spectrum. The investigation focuses on (B) because it matches the framing in the original question ("handling metered events from our end and issuing invoices"), but the differences are flagged where they matter.
| Option | What we keep in Stripe | What we own |
|---|---|---|
| A. Stay (status quo) | Customer, Subscription, Meter, MeterEvent, Invoice, Charge, dunning | Send-path resolver only |
| B. Soft fork (this memo) | Customer, PaymentMethod, Invoice, InvoiceItem, Charge, dunning | Usage ledger, period close, threshold trip, invoice generation, line-item idempotency |
| C. Hard fork | (nothing) | Card vault (PCI scope), processor integration, full dunning + retry logic, invoice PDF |
(C) is out of scope — moving off Stripe-the-payment-rail is an unrelated, much larger decision. Flagged explicitly so the discussion stays grounded.
Current architecture (what we have today)¶
Cited from the codebase to ground the analysis:
Send path
app/celery/mailer.py:117-150— every successful physical send callsbilling.update_billing_meter(...)app/methods/billing.py:34-150— resolves org's billing rate card row, picks meter event name, firesapp/core/integrations/stripe/client.py:212-277—stripe.billing.MeterEvent.create(event_name, payload, identifier=f"mailer-{recipient_id}")with rate-limit retryapp/core/integrations/stripe/client.py:279-306—MeterEventAdjustment.create(type="cancel")reverses meter events on shipping failure (app/celery/mailer.py:142-150)
Subscription model
- One Stripe Subscription per org; one metered SubscriptionItem per template size, mapped via
billing_rate_card_entries.stripe_subscription_item_id(app/core/models/billing_rate_card.py:60-82) app/core/integrations/stripe/meters.py:31-41— canonical template-size → meter-name map
Threshold billing
app/scripts/stripe_thresholds.py:22-26— operator script appliesbilling_thresholds.amount_gteof $2.5k (large customers from CSV) or $1k (default)app/core/integrations/stripe/client.py:311-326—apply_thresholds()/remove_threshold()wrappers- Stripe auto-finalizes and charges the invoice when accumulated metered usage trips the threshold
Customer-facing surface
app/routes/billing_routes.py:23-33→app/methods/billing.py:205-310— read-only/v1/billing/<org_id>exposes upcoming invoice + past invoices, each withhosted_invoice_url(Stripe-hosted)- No in-app card-update form, no Customer Portal session link, no Setup Intent flow
Dunning
- Single webhook:
app/routes/webhooks/stripe_webhook_routes.py:12-48—invoice.payment_failedonly; only acts whennext_payment_attemptis falsy (Stripe gave up retrying) app/methods/billing.py:312-344—handle_payment_delinquency()posts to Slack#customersfor CSM. No org email, no campaign pause, no DB state flag.- Stripe smart-retries run silently in the background; we have no visibility into in-flight retry attempts
Not present anywhere
- Stripe Tax (
automatic_tax/tax_behaviornot configured) - Refund or CreditNote code paths
- Dispute / chargeback webhook handlers
- Org
billing_status/ pause-on-nonpayment state - Invoice/receipt email templates (Stripe sends defaults)
What we lose by leaving Stripe metered billing¶
Each item below is a Stripe-side capability we currently rely on (or get for free).
1. Automatic usage aggregation into invoice line items¶
Stripe today: meter events accumulate against a metered SubscriptionItem; at period end Stripe rolls them into one invoice line item with the right unit price and quantity. Lost: we now own period-close: aggregate the ledger, group by (org × billing_key × period), call stripe.InvoiceItem.create() per group, then stripe.Invoice.create() + Invoice.finalize_invoice().
2. Threshold billing¶
Stripe today: billing_thresholds.amount_gte triggers mid-cycle invoice + charge automatically. This is a real cash-flow control — large senders don't accumulate $50k of unbilled usage. Lost: we have to compute running totals ourselves, decide when a threshold is tripped, finalize a partial invoice, and ensure idempotency so a worker restart doesn't double-bill. This is non-trivial (see "What we'd need to build" §B).
3. Meter-event idempotency¶
Stripe today: MeterEvent.create(identifier="mailer-{recipient_id}") rejects duplicates server-side; M0/M1 work has standardized on campaign_recipient_id as the canonical idempotency key. Lost on the meter side, not on the invoice side: we'd shift idempotency to our usage ledger (PK on (organization_id, recipient_id, meter_event_name) with status). InvoiceItem.create() accepts an idempotency key in HTTP headers, so the Stripe call stays safe — but the bookkeeping moves into our DB.
4. MeterEventAdjustment reversal path¶
Stripe today: when a mailer fails between billing and ship (app/celery/mailer.py:142-150), we call MeterEventAdjustment.create(type="cancel") and Stripe scrubs the event from that period's invoice. Lost: ledger-side reversal is easy if the period hasn't closed; if it has closed we'd need a credit note (stripe.CreditNote.create) or a negative InvoiceItem on the next period. New code path.
5. Meter event summaries / usage reporting¶
Stripe today: Meter.list_event_summaries() returns per-period aggregates per customer (used in get_billing_response). Lost: trivially replaced with a SQL query against our ledger. No real loss.
6. Stripe Tax (latent)¶
Stripe today: not currently enabled, but Stripe metered billing is the natural integration point if we ever turn it on. Lost: if/when tax matters, we'd have to call Stripe Tax's standalone API ourselves and stamp tax rates onto each InvoiceItem, or compute tax outside Stripe entirely. (Note: this is also a Stripe gap if we stay — flagged here so the trade-off is visible.)
7. The in-flight migration's foundational machinery¶
Stripe today: M2–M5 (provisioner, activation gate, per-SKU emission, reconciliation worker, kill switch) are all designed around Stripe meters being the system of record per period. Lost: the M2 readiness cache and activation gate (which today verify a Stripe Price exists before letting a campaign launch) become inapplicable. The M3 reconciliation worker, which was going to compare logs.CustomerMeterBilled ↔ Stripe meter events ↔ invoiced totals, would need to be redesigned around our ledger as the spine instead of the audit witness. The M1 rate card table and append-only D11 design are still useful — they survive the migration. See "Conflict with in-flight migration" below.
What we keep automatically (do not need to rebuild under Option B)¶
Worth being explicit about, because the surface is smaller than it looks:
- Card vault & PCI scope — Stripe still owns the PaymentMethod
- Charge retries / smart retries — these run on
Invoice, not onSubscription. As long as we create an Invoice andauto_advance=True, Stripe still retries failed charges and emitsinvoice.payment_failedwebhooks - Hosted invoice page —
Invoiceobjects still gethosted_invoice_url; the existing in-app links keep working - Default invoice / receipt emails — Stripe still sends these on finalize
- Stripe Customer Portal — usable as-is (and it's actually easier without metered subscriptions, which the portal renders awkwardly)
The point: Option B keeps Stripe doing what it's good at (charging cards, retrying, surfacing receipts) and only moves the parts where Stripe's abstractions don't fit our model.
What we'd have to build (Option B)¶
A. Usage ledger¶
A DB table — billing_usage_events or similar — with:
(organization_id, billing_key, recipient_id)natural idempotency keyunit_amount_cents,currency,quantity,meter_event_namesnapshotted at write time (so price changes don't retroactively re-cost)period_idforeign key (computed from org's billing cycle anchor)stateenum:recorded → invoiced → paid(andreversed,credited)stripe_invoice_item_idonce invoiced
The M1 billing_rate_card_entries already gives us the (org × billing_key) → unit_amount mapping — this ledger would consume it at write time, not lookup at period close (so price changes mid-period are honored cleanly).
B. Period close + threshold engine¶
Two triggers:
- Period boundary — Celery beat job per org at
billing_cycle_anchor. Aggregates ledger rows wherestate=recorded, callsInvoiceItem.create()per (billing_key, currency), thenInvoice.create(auto_advance=True), transitions ledger rows toinvoiced. - Threshold trip — every time we record a new event, recompute the org's running uninvoiced total. If
>= threshold, run the same close logic mid-cycle.
Idempotency is the hard part: a worker that crashes between InvoiceItem.create and the ledger update must not double-bill. Pattern: write a billing_close_attempts row first with the period_id + idempotency_key, use that key for both InvoiceItem and Invoice calls (Stripe's Idempotency-Key HTTP header), commit ledger transitions only after Stripe confirms.
C. Reversal + credit pathway¶
Replacement for MeterEventAdjustment.create(type="cancel"):
- If period is open → mark ledger row
reversed, exclude from next close. No Stripe call. - If period is closed → create negative
InvoiceItemfor next period or issueCreditNoteagainst the prior invoice (policy decision; the second is cleaner from an accounting standpoint but more code).
D. Invoice currency & price snapshotting¶
Today's rate card stores currency per row but the metered Subscription holds the active Price. With our own invoicing we have to be explicit: which currency does each InvoiceItem use, what does Stripe do if the org's Customer default currency disagrees, etc. M1's rate card already snapshots this; we just have to honor it at close time.
E. Reconciliation worker (replaces M3's)¶
New job: every N hours, compare billing_usage_events ↔ stripe.Invoice.line_items. Drift on either side pages oncall. The shape is similar to M3's plan, but the spine flips (our ledger is now authoritative; Stripe is verification).
F. Backfill / cutover tooling¶
For each org being migrated:
- Freeze new meter events on the Stripe side
- Drain in-flight billing period: let Stripe close the current metered subscription's period normally, OR void it and re-bill the same usage as
InvoiceItems — pick one, document loudly - Switch the send path to the ledger
- Detach the metered SubscriptionItems from the org's Subscription (or leave the Subscription in place but with no metered items — needed if we keep platform fees there)
This is the highest-risk piece. M0's reverse-meter-on-failed-ship plus M1's shadow mode give us a head start, but the cutover for a live customer requires careful coordination.
G. Threshold-tier configuration UI/store¶
Today thresholds live in Stripe (set by app/scripts/stripe_thresholds.py). They'd move into our DB (organizations.configuration is the obvious home, but a billing_thresholds table might be cleaner if multiple tiers per org are wanted).
Gaps that exist today regardless of which option we pick¶
These are already missing and are not caused by the migration. Worth flagging because they're often conflated with "Stripe-managed dunning":
- In-app credit-card update form. No
SetupIntentor Customer Portal session route in the repo. Today this happens out-of-band (operator hands customer a hosted Stripe link or updates via dashboard). - Pause-on-nonpayment. No org state gates sending when a payment fails.
app/methods/campaigns/status_handlers.pyonly auto-pauses on performance/segment criteria. Org admins can keep sending past-due. - Customer-facing payment-failed email. Nothing in
app/core/integrations/postmark.pyis wired for billing events. Stripe sends defaults; we don't supplement. - Refund / dispute workflow. No
stripe.Refundcalls anywhere. Manual via dashboard. - Tax handling. Stripe Tax not enabled; no tax IDs collected.
If the goal is "give customers a better billing experience," the bigger lever is filling these gaps under the existing Stripe metered model — not switching to self-managed metering.
Conflict with in-flight migration¶
Per the active billing-migration project (project/brandon-billing-migration):
| Migration milestone | Survives a move to self-managed | Notes |
|---|---|---|
M0: safety fixes (stripe_customer_id guard, reverse meter on failed ship, counters) |
Yes | Equivalent invariants apply to a ledger model |
M1.1: billing_rate_card_entries table |
Yes | Becomes the price book the ledger reads at write time |
M1.2: BillingMode + resolver |
Partial | Resolver still useful; BillingMode enum gets a third value (self_managed) |
M1.3: send-path shadow mode + CustomerMeterBilled log |
Yes | Logfire telemetry stays useful |
M1.4: divergence visibility (inspect_preflight --show-items, inspect_billing_mode) |
Partial | Stripe-side inspectors still useful; new ledger-side inspectors needed |
| M1.S: rate card seeder CLI | Yes | One-time bootstrap |
| M2: provisioner + activation gate + readiness cache | No | Tied to Stripe Price/Meter existence per (org, key). Replaced by ledger-side readiness check (rate card entry exists + active) |
| M3: per-SKU emission flip + reconciliation worker | Reshape | Reconciliation flips spine: ledger authoritative, Stripe verifies. Emission becomes ledger insert + period-close worker |
| M4: rollout waves, kill switch | Reshape | Kill switch has different shape (route to Stripe meter vs ledger) |
| M5: legacy cleanup | Reshape | Different "legacy" |
Net: ~M0–M1 (everything merged or merging now) is preserved. M2 onward (mostly unbuilt) gets re-shaped, not scrapped — the concepts (DB-first, append-only rate card, three-way reconciliation, controlled rollout) all map cleanly. The cutover work in §F above is essentially a re-scoped M3+M4.
Engineering cost estimates¶
Caveats up front: These are rough order-of-magnitude estimates calibrated against the visible cadence of the in-flight migration (M0 = ~2 PRs over 1-2 weeks, M1 = 5 stacked PRs over ~3-4 weeks). They assume one engineer with familiarity with the billing code; a second pair of eyes from finance/CSM is needed throughout regardless of option. Treat the numbers as lower-bound ranges, not commitments. Risk is qualitative — labelled L/M/H based on revenue exposure and reversibility.
Option A — Stay (finish in-flight migration + fill gaps)¶
Continue the planned M0–M5 path. M0 is done; M1 is merging now.
| Workstream | Estimate | Notes |
|---|---|---|
| M2: provisioner CLI + readiness cache + activation gate | 4–6 weeks | Largest unbuilt milestone. Per-(org, SKU) Stripe state management. |
| M3: per-SKU emission flip + 3-way reconciliation worker | 3–4 weeks | Reconciliation is the meaty piece (logs.CustomerMeterBilled ↔ Stripe MeterEvents ↔ invoiced totals). |
| M4: wave-based rollout + kill switch | 2–3 weeks | Mostly process + small code. |
M5: legacy cleanup (mailer_price, legacy meters, billing_mode) |
1–2 weeks | Pure deletion. |
| Customer-experience gaps (in-app card update, pause-on-nonpayment, payment-failed email) | 3–4 weeks | Additive; survives any future rail change. |
| Total | ~13–19 weeks (3.5–5 months) |
Risk: L–M. Architecture is designed; PR cadence so far has been steady. Main risk is M2's per-org provisioning correctness across the existing Stripe corpus. Doesn't unblock cross-SKU pricing constructs or eliminate per-org Stripe Price proliferation.
Option B — Soft fork (self-managed metering + invoicing; keep Stripe Charge/Invoice)¶
Keep M0–M1 (already merged or merging — sunk cost). Pause M2. Replace M2–M3 design with ledger-based equivalents.
| Workstream | Estimate | Notes |
|---|---|---|
| §A Usage ledger table + write-path swap (MeterEvent.create → ledger insert) | 2–3 weeks | M1.3's resolver already does the lookup; swap the emit. M0's idempotency invariants apply. |
| §B Period-close worker + threshold-trip engine + Invoice generation | 3–5 weeks | The meatiest piece. Idempotent across worker restart; transactional across DB + Stripe API; per-org billing_cycle_anchor honoring. |
| §C Reversal + credit-note pathway | 1–2 weeks | Open-period: trivial. Closed-period: CreditNote logic + finance policy alignment. |
| §D Currency snapshotting on InvoiceItem | 0.5 week | Rate card already supports per-row currency. |
| §E Reconciliation worker (re-shaped from M3) | 2–3 weeks | Spine flips: ledger authoritative, Stripe verifies. |
| §F Backfill + per-org cutover tooling | 2–3 weeks | Plus operational time per migrated customer. |
| §G Threshold-tier configuration store | 1 week | Move from stripe_thresholds.py operator script to DB. |
| Customer-experience gaps (same as Option A) | 3–4 weeks | Additive. |
| Total | ~14–21 weeks (3.5–5.5 months) |
Risk: M–H. Headline cost is comparable to Option A, but risk is concentrated in the cutover (§F) and period-close idempotency (§B). Live-revenue mistakes here have higher blast radius than M2/M3 mistakes (which can be caught by reconciliation before invoice). Mitigation: M4-style wave rollout, run ledger in shadow mode against Stripe meters for ≥1 full billing period before any cutover, double-bill detection in the reconciliation worker.
Avoided cost: the ~4–6 weeks of M2 provisioner work and the ~3–4 weeks of M3 reconciliation-as-currently-designed don't carry forward; ~7–10 weeks of M2/M3 work either gets re-shaped or skipped. So the marginal cost over "stay" is much smaller than the headline — roughly 4–8 weeks more, in exchange for eliminating per-org Stripe Price proliferation as an ongoing operational cost.
Option C — Hard fork (leave Stripe entirely)¶
For completeness only — this memo recommends against unless there's a separate driving reason (Stripe pricing, geographic constraint, regulatory).
| Workstream | Estimate | Notes |
|---|---|---|
| New processor integration (Adyen / Braintree / etc.) + PCI scope | 6–12 weeks | Wide variance. PCI compliance review, vendor selection, integration. |
| Card vault / SetupIntent equivalent + customer card-update UI | 3–4 weeks | |
| Hosted invoice page + PDF generation | 3–4 weeks | Replace hosted_invoice_url |
| Customer-facing receipt + payment-failed emails | 2–3 weeks | Replace Stripe defaults |
| Smart-dunning engine (retry schedule, exponential backoff, customer notifications) | 4–6 weeks | Replace Stripe's automatic retry behavior |
| Refund / dispute workflow | 3–4 weeks | |
| Tax integration (Avalara / TaxJar) | 4–6 weeks | Plus tax-ID collection UI |
| Fraud / 3DS / SCA handling | 2–4 weeks | |
| Everything in Option B | 14–21 weeks | |
| Per-customer card re-collection migration | 6–12 weeks elapsed | Customer-facing churn risk; not pure engineering time |
| Total | ~50–80 engineering weeks (~12–20 months solo, ~6–10 months for a 2-engineer team) |
Risk: H. Customer migration is the killer — every paying org has to re-enter card info, and historical churn data on similar migrations suggests 5–15% involuntary churn. PCI scope is a permanent operational tax. Don't do this without a separate forcing function.
Quick comparison¶
| Option A | Option B | Option C | |
|---|---|---|---|
| Engineering weeks | 13–19 | 14–21 | 50–80 |
| Marginal cost vs. Option A | — | +4–8 weeks (after credit for skipped M2/M3 work) | +35–60 weeks |
| Unblocks cross-SKU pricing constructs | No | Yes | Yes |
| Eliminates per-org Stripe Price proliferation | No | Yes | Yes |
| PCI scope change | No | No | Yes (taken on) |
| Customer migration risk | None | Per-org cutover (controllable) | High involuntary churn |
| Reversibility | High (no change to model) | Medium (could fall back to Stripe meters) | Low (one-way door) |
The marginal-cost framing is the punchline: Option B is cheap-ish over Option A because most of the M2/M3 work that's still ahead doesn't carry forward under self-managed. The decision isn't really "13 weeks vs 21 weeks" — it's "13 weeks of M2/M3 plus per-org Stripe Price provisioning forever vs ~18 weeks of ledger work and never maintaining that state in Stripe again."
Open questions for the team¶
- ~~How frequently are we creating per-org custom prices today?~~ Answered: ~10–15% of mailer volume runs through custom-priced orgs. That's enough to make the per-(org, SKU) Stripe-state-management cost a recurring operational concern, not a one-off. Follow-up worth asking: is the trajectory growing (more enterprise / cohort deals over time) — if so, the cost compounds.
- What pricing constructs are blocked today that we want? Concrete examples — bundled volume tiers across SKUs? Combined-volume thresholds? Tiered pricing inside a single SKU? Each of these is hard-to-impossible under Stripe metered and cheap under self-managed. The list of "things sales/finance has asked for and we've said no to" is the strongest possible signal.
- Multi-currency surface area: how many orgs run in non-USD, and do any need org-specific rates inside a non-default currency?
Price.currency_optionsmakes the simple case easy and the customized case painful; self-managed treats them uniformly. - Refund / reversal policy: today we silently
MeterEventAdjustment.cancel()failed sends. Self-managed forces an explicit policy — open-period reversal vs closed-period credit note. Finance + CSM input needed before designing §C. - Cutover risk tolerance: is a customer-by-customer migration acceptable, or does the cutover need to be coordinated? The former is much safer (per §F) and aligns with M4's wave-based rollout pattern.
- Does the in-flight migration pause / re-aim? M2–M5 design changes if the answer is "self-managed." Specifically: M2 (provisioner + activation gate) shrinks dramatically, M3's reconciliation flips spine. Continuing to ship M2 under the Stripe-metered assumption and then pivoting is more expensive than pausing M2 now until the direction is locked. M0–M1 keep landing either way.
Recommendation (preliminary, for discussion)¶
The SKU/price-proliferation pain is real, structural, and already affecting ~10–15% of revenue volume — it's the reason M2 has to exist in the current model, and the reason future pricing flexibility (cross-SKU bundles, combined thresholds, custom-tier enterprise deals) is gated by Stripe's primitives. So self-managed metering + invoicing is a credible direction, not a hypothetical, and the M1 work being merged right now (rate card table, append-only design, shadow telemetry) is exactly the foundation it would build on.
That said, two near-term moves are worth doing first regardless of the eventual call:
- Fill the customer-experience gaps under the current model (in-app card update, pause-on-nonpayment, payment-failed emails — the items in "Gaps that exist today regardless"). These are additive, customer-visible, and survive any subsequent rail switch.
- Pause M2 design work until this decision is made. M2's whole reason for existing is to keep per-(org, SKU) Stripe state in sync with the DB; that machinery becomes redundant under self-managed. Continuing to ship it, then pivoting, is the most expensive path.
The strongest argument for going ahead is this: every dollar spent on M2 is a dollar spent solving a problem that only exists because Stripe is the system of record for prices. Moving the system of record to our DB (which M1 has already done de facto) and then wiring invoice generation to read from it is structurally cheaper than building and operating the M2 sync engine indefinitely.