Billing

Stripe Checkout + Billing Portal. Plan state mirrored to organizations.plan_status.

Files

  • src/lib/billing/stripe.ts — client + createCustomer, createBillingPortalSession, createCancelPortalSession.
  • src/lib/billing/plans.ts — plan metadata.
  • src/lib/billing/comped.ts — owners granted the uncapped tier in code.
  • src/lib/api/org-plan.tsresolveOrgPlan() / resolveOrgEntitlement(), the entitlement every gate reads (60s cache).
  • src/lib/api/usage-limit.ts — plan-cap gate plus meteredCostCents() / checkSpendCap(), the spend cap's arithmetic and enforcement.
  • src/lib/api/usage-alerts.ts — 80%/100% usage.alert webhooks + owner mail, deduped through the usage_alerts ledger.
  • src/lib/billing/settlement.tssettleFinalUsage(), the final invoice a leaving customer gets.
  • src/lib/api/org-delete.tsdeleteOrgWithBilling(): settle, cancel, then cascade.
  • src/app/api/internal/billing/route.ts — checkout session creation.
  • src/app/api/webhooks/stripe/route.ts — inbound Stripe events.
  • src/app/overview/settings/billing/page.tsx — billing UI.
  • src/components/overview/upgrade-button.tsx — triggers upgrade.

Plans

From src/lib/billing/plans.ts:

Plan Price Emails/mo SMS/mo Overage
free $0 3,000 500 hard limit
payg $5 + usage 0 included 0 included email $0.0004 ($0.40/1k), SMS $0.0075
pro $39 10,000 1,000 email $0.0004 ($0.40/1k), SMS $0.0075
enterprise custom

enterprise is no longer sold. Pay as you go is the self-serve tier above Pro. The id remains valid — existing contracts sit on it, and a comped owner resolves to it — it is simply not offered.

Pay as you go

A small platform fee plus per-unit metering with no included allowance. The two channel limits are 0, not Infinity, and the distinction is load-bearing: report-overage bills max(0, used - limit) and skips an org whose overage is zero, so Infinity would silently drop every PAYG org out of the billed set.

Zero is an allowance, never a cap. Only free is hard-capped (evaluateUsageLimit), so a PAYG send is always allowed and always billed.

Those 0s stay internal to PLANS, enforcement and the overage cron — GET /v1/usage never ships one. A public limits value is a cap, and PAYG has none, so both channels come back as null: plan: "payg" with limits: { "email": null, "sms": null }. Shipping the raw sentinel read as a breached quota to every client that gates on used < limit — the SDKs, the remaining-budget recipe, any used/limit bar — on the one tier where no send is ever refused. plan is what says why there is no cap: metered from the first unit here, unmetered on enterprise.

Resource caps are Pro's, deliberately not Enterprise's Infinity: usage is what this tier uncaps, and usage is metered — IP pools and team seats cost real money that no counter here bills for, so they stay bounded on a self-serve tier.

Which tier a subscription is comes from its flat price (STRIPE_PAYG_PRICE_ID vs STRIPE_PRO_PRICE_ID, resolved by src/lib/billing/plan-price.ts). The metered prices are shared by both tiers — what differs is how much the cron reports, not the rate. A subscription whose price matches neither resolves as Pro: that is the behaviour from before this tier existed, and it under-bills rather than over-bills. Checkout stamps the tier into the session metadata so checkout.session.completed never has to race the subscription becoming active.

Default on signup: free.

SMS is metered per segment. The SMS/mo figure is a segment allowance, not a message count — a long SMS spans multiple 140-byte segments and is counted against the cap (and Pro overage) per segment. See usage-limits.md.

Spend cap

An owner-set monthly ceiling on metered cost, in USD cents: organizations.spend_cap_cents. null (the default) is no cap; 0 is a valid kill switch that refuses every metered send.

What it measures. Month-to-date metered cost, computed exactly the way report-overage bills the month: max(0, used − allowance) per channel at the plan's PLANS overage rate (meteredCostCents() in src/lib/api/usage-limit.ts). On Pro that is overage only — the included allowance is free and stays free; on Pay as you go every unit is metered from the first. Free and Enterprise carry no overage rates, so their metered cost is always $0 and a cap never binds there.

Enforcement. Inside the existing limit-check helpers (enforceUsageLimit, checkBatchUsage), so every v1 send path — single, batch, audience blast — is gated with no route wiring. The one exception is the dashboard blast (/api/internal/audiences/{id}/send), which calls checkUsageLimit / evaluateBatchUsage directly instead of going through checkBatchUsage and so wires the cap by hand — the same carve-out its per-tenant cap already needed. A live send whose units would push month-to-date metered cost strictly past the cap is refused with 402 SPEND_CAP_EXCEEDED (standard error envelope, type: "rate_limit_error"); batches are measured by their whole unit span (SMS in segments). The send that lands exactly on the cap is the last one allowed, and a send with no metered cost — a Pro org inside its included allowance — is never refused, because it cannot move the number the cap protects. Test-environment sends are exempt like every other usage gate. Cost of the gate: the plan-cap query reads both channels' usage rows off the same index (at most one extra row), plus one primary-key SELECT on organizations for the cap value, paid only on metered tiers.

At fire time. The enqueue gate reads the period counters, which a row sitting in scheduled does not advance — so a ramp or a blast accepted under the cap would otherwise fire and meter straight past it, and a cap set to 0 mid-ramp would stop nothing. The send-scheduled cron therefore seeds a per-org spend budget for every batch it claims (the same evaluateSpendCap arithmetic, over a projected copy of the counters that every reservation in the batch advances) and defers any row that would cross it: back to scheduled, scheduled_at advanced from the row's own slot by whole 60-minute intervals, a scheduled status event on the first deferral only, no message.failed. Deferred rather than canceled because the cap is a ceiling the owner can raise; 0 pauses every metered row an hour at a time until it moves. The first deferral in a batch fires the same 100% spend_cap alert an enqueue-time refusal does — a ramp accepted before the cap started binding never crossed a counter, so this is the only notification the owner gets. See scheduled-sends.md.

The dashboard resend (/api/internal/messages/{id}/resend) runs the same enforceUsageLimit gate as the v1 routes, in the units its clone will meter, so it answers 402 with the spend-cap message too (unwrapped into the internal { error } envelope, with the code alongside).

Setting it. Owners only, on /overview/settings/billing (the card also shows month-to-date metered spend) or via PATCH /api/internal/billing with {"spend_cap_cents": <int ≥ 0 | null>} — the field must be explicitly present (an empty body is a 422, never an accidental un-capping), null removes the cap, and the write is audited as org.spend_cap_updated. Enforcement reads the column per send, so changes take effect immediately.

Usage alerts (80% / 100%)

Crossing 80% or 100% of (a) the plan allowance per channel or (b) the spend cap emits one usage.alert webhook and one email to the org owner. Evaluation runs after every successful live usage increment, and a refusal fires the 100% alert too — the increment that would have crossed the line never happens, so an org whose counter can only overshoot (a spend cap a batch can only leap past) would otherwise never hear that its sends are now refused.

A refusal only announces when the line is genuinely reached: the channel's counter sits at or past its allowance, or one more unit on that channel would be refused by the cap as well (SpendCapDecision.capReached). A send refused just for being too big for what remains is not a crossing — a 100/3,000 org refused a 5,000-item batch, or a $50 cap with 10¢ spent refusing a 200k blast. Announcing those would mail a self-contradictory figure ("at 100% … has used 100 of its 3,000") and take the ledger key below, silencing the real crossing for the rest of the period.

Dedup is exclusively the usage_alerts ledger: emitters INSERT … ON CONFLICT DO NOTHING against the unique (org, period, channel, kind, threshold) key and only the caller whose row was actually inserted announces, so an alert fires at most once per key per period — concurrent sends racing past a threshold cannot double-fire, and moving the cap does not re-fire thresholds already recorded for the period. Alert failures are logged (usage.alert_* events) and never fail the send they rode in on.

Plan-allowance alerts need a finite non-zero allowance (so none on PAYG's zero-allowance tiers or Enterprise); spend-cap alerts need a positive cap on a metered tier. For kind: "spend_cap", used/limit in the event are integer cents.

Comped accounts

Some orgs hold the uncapped entitlement without a Stripe subscription — the accounts that operate the platform itself. The grant lives in src/lib/billing/comped.ts (a baked-in list plus COMPED_OWNER_EMAILS) and is keyed on the org owner's email address.

resolveOrgPlan() (src/lib/api/org-plan.ts) applies it: a comped owner's org resolves to enterprise whatever plan_status says. That is the single function every gate in the codebase asks, so one flag covers all of them — monthly send caps (usage-limit.ts, batch.ts), resource caps (resource-limit.ts: domains, webhook endpoints, tenants, IP pools, API keys, team seats) and the rate-limit tier (rate-limit.ts, 6,000 req/min).

Three properties are deliberate:

  • plan_status is untouched. It stays the record of what was bought, so the billing card, the Stripe webhook, org_plan_events and the overage cron's plan history all keep reading the truth. A comp is an entitlement, not a sale.
  • Owner, not member. Quotas are counted per org, so "this org is comped" is the only statement a limiter can act on. Every member and API key of a comped owner's org is uncapped; being an invited member of somebody else's org grants nothing.
  • Never billed. report-overage skips comped orgs before pricing — see Cron jobs → report-overage.

Matching is exact against the normalized (trimmed, lowercased) users.email. Plus-addresses and dotted-Gmail variants do not match: they are separate rows in users, and folding them would widen the grant to any address that merely resembles a listed one.

The dashboard reports the entitlement: /overview/settings/billing shows the comped tier with a Comped badge, unlimited usage bars and no Upgrade button, while the Stripe controls (Manage billing / Cancel subscription) stay driven by plan_status so a comped org that also holds a real subscription can still manage it. GET /v1/usage likewise answers plan: "enterprise" with null limits — where null means "no cap", not "comped": metered Pay as you go reports null limits too, and plan is what tells the two apart.

Verifying a comp. npx tsx --env-file=.env scripts/verify-comped-accounts.ts resolves every listed address against the live database and prints what each owned org is actually entitled to, what it has used, which gates still apply (warmup ramps, per-tenant caps) and who else holds a seat on it. Read-only; exits non-zero when a listed address has an account whose orgs do not resolve uncapped. --json for a machine-readable dump.

Adding or removing a comp is not instant — an COMPED_OWNER_EMAILS change needs a redeploy to reach a running deployment, and resolveOrgPlan then memoizes the entitlement per org in a 60s TtlMap that each warm Fluid instance holds its own copy of. Removing an address is not a kill switch.

Upgrade flow

  1. User clicks Upgrade → POST /api/internal/billing.
  2. Server finds or creates a Stripe customer, persists stripe_customer_id on the org.
  3. Creates Checkout Session with STRIPE_PRO_PRICE_ID — plus STRIPE_EMAIL_OVERAGE_PRICE_ID / STRIPE_SMS_OVERAGE_PRICE_ID when set, which is what makes the meters below actually bill (a meter invoices only through a price on the subscription; the flat price alone leaves every meter event accumulating against the customer for nothing). Metered prices carry no quantity — the reported usage is the quantity. Then:
    • success_url: /overview/settings/billing?upgraded=true
    • cancel_url: /overview/settings/billing
  4. Returns { url } — client redirects.

Inbound webhook — /api/webhooks/stripe

Signature-verified via stripe.webhooks.constructEvent(body, signature, STRIPE_WEBHOOK_SECRET).

Five events, matched to the org by stripe_customer_id:

Event Effect
checkout.session.completed plan_status = the tier in the session's metadata.plan (payg, else pro)
customer.subscription.created Re-derive plan_status from the customer's live subscriptions, and alarm if the period was already settled
customer.subscription.updated Same — this is how a move between Pro and Pay as you go is announced
customer.subscription.deleted Re-derive — free when nothing billable is left — and settle the final usage in after()
invoice.payment_failed Logs an error (TODO: dunning email via own API)

The three subscription events share one handler and never trust the event's own status snapshot: Stripe does not guarantee ordering, so the tier is re-derived from a fresh subscriptions.list. That is what stops a delayed updated (active) delivered after deleted from restoring a paid plan on a canceled org, permanently.

Every write goes through recordPlanChange, so organizations.plan_status and the org_plan_events history move together — the history is what lets report-overage and settlement price a closed month by the plan actually in effect then. enterprise is preserved: it is set out-of-band by contract and the webhook only knows pro / payg / free.

A comp is never written to plan_status, so a comped org can read free in this table forever and that is correct — the column records the subscription, and the webhook keeps owning it.

Final usage settlement

report-overage bills a closed month on the 1st, by metering it to a price on the customer's live subscription. Both halves stop working the moment that subscription ends: the cron answers no_subscription, claims no ledger row, and everything sent between the 1st and the cancel is never billed. Left alone, every Pay-as-you-go cancel got its final month free, and an org deleted from the dashboard took its unbilled month with it.

settleFinalUsage({ orgId, customerId, reason }) (src/lib/billing/settlement.ts) bills that tail through a one-off invoice, which needs no subscription.

What it prices. The current period always, and the previous one when the ledger holds no reported row for it (a cancel on the 2nd can beat the 1st-through-5th cron, or arrive after it answered no_subscription). The allowance comes from planForPeriod — the plan actually in effect then, not the free the downgrade is about to write.

At what rate. The unit rate is read off the same Stripe price the monthly meter bills through — STRIPE_EMAIL_OVERAGE_PRICE_ID / STRIPE_SMS_OVERAGE_PRICE_ID, whose unit_amount_decimal is in cents and is fractional (email is 0.04¢ a send). Not PLANS[...].overage, which plans.ts states is a display value. Two things follow, both deliberate:

  • A channel with no configured price is skipped and left unclaimed, exactly as report-overage leaves it. Unset is the supported flat-rate deployment that bills no overage at all; invoicing it here — on the way out, and retroactively for two months — would charge a customer for something they had never once been billed monthly.
  • A tiered price, or one carrying transform_quantity, is refused (price_not_per_unit, logged) rather than approximated. The approximation would be an over-bill, and Stripe prices are immutable, so a rate change mints a new price and repoints the env var while the plan matrix stays put.

The invoice is raised in the price's own currency, checked against the currency Stripe has pinned on the customer. It does not read the billing card's month-to-date figure, which is still the spend cap's meteredCostCents estimate off the plan matrix; where a deployment's Stripe rate and its plan matrix disagree, the invoice follows Stripe.

The floor. Below Stripe's minimum charge — 50 cents — nothing is invoiced. A finalized invoice under it does not collect a small amount, it fails to collect: the payment is rejected as amount_too_small, the invoice sits open, dunning starts over a few cents, and the ledger rows are already terminal so nothing revisits it. A Pro org a few hundred emails past its allowance is the common churn case, so the tail is written off (waivedCents) and the period is still closed.

Why it cannot double-bill. It claims overage_reports rows with the same INSERT … ON CONFLICT (org_id, period, channel) DO UPDATE … WHERE status = 'failed' statement the cron uses. That shared ledger is the entire guard: a period settled here reads reported to the cron on the 1st, and a period the cron already metered is skipped here. A row stuck in pending — an earlier writer that died before Stripe answered — is never re-fired by either.

Outcomes.

Situation What happens
Overage owed Draft invoice → one line item per (period, channel) → finalized with auto_advance: true; the ledger rows close as reported carrying the in_… id
Nothing owed, or under 50¢ No invoice. The rows still close as reported — the period is settled, and the cron must not revisit it for a customer who has left
Free plan, comped owner, unrecorded upgrade Nothing claimed, nothing billed (planForPeriod refuses)
No overage price for the channel Nothing claimed, nothing billed — the flat-rate deployment, same answer the cron gives
Stripe is unreachable Claims are released to failed (re-claimable) and the error is thrown. A stranded draft cannot charge on its own — auto_advance is off until finalize — and is deleted; when Stripe's state cannot be read back the rows are left pending for a human
Stripe rejects — a 400 or a 403 blocked is set, the claims are released, billing.settlement.blocked is logged and nothing is thrown. A currency Stripe will never accept for this customer, a deleted customer, or a restricted key without write on Invoices answers identically on every retry; thrown, that makes the organization permanently undeletable

Who calls it. Two paths, and they are safe to both fire for one cancellation because the second finds every row closed:

  • customer.subscription.deleted, in after() so no Stripe round-trip sits in front of the webhook's 200 (the route sets maxDuration = 300 for the same reason report-overage does). Best effort: a customer left on a paid tier with no subscription is the worse outcome. Running it after the downgrade is safe because recordPlanChange writes the flip and the pro → free history row in one statement, so planForPeriod still holds the highest entitlement of the period and prices the month at pro.
  • deleteOrgWithBilling, before the cascade — see below, where it is not best effort.

One gap, deliberately left open. A customer who resubscribes inside a month settlement has already closed is billed for the rest of it by nobody: the rows read reported to the cron on the 1st. Re-opening them would make the cron re-derive and meter the whole month, including the part already on a finalized invoice — an over-bill in place of an under-bill. So customer.subscription.created raises billing.settlement.resumed_after_settlement for an operator to reconcile, and the real fix (recording the settlement cut-off in the ledger and having the cron bill the remainder) is tracked in docs/operations/_open-gaps.md.

Deleting an organization

DELETE /api/internal/org runs deleteOrgWithBilling(orgId, actor), in this order:

  1. listBillableSubscriptions for the org's stripe_customer_id.
  2. settleFinalUsage — once per org, not per subscription: usage is counted per org, and closing the ledger makes the webhook each cancel fires a no-op.
  3. Cancel every one of them with prorate: false. The unused remainder of a flat month is not refunded — the customer chose to leave mid-cycle — and the usage half has just been invoiced separately.
  4. deleteOrgCascade.
  5. Write the org.deleted audit row.

Any Stripe failure in 1-3 throws OrgBillingUnreachableError and steps 4-5 never run: the route answers 502 ORG_BILLING_UNREACHABLE and the org is still there. A Stripe rejection is the exception — see blocked above: the tail is forfeited with an org.delete.settlement_forfeited alarm and the deletion proceeds, because a condition that answers the same on every retry would otherwise leave the owner with an organization they can never delete and a subscription still running. An org that exists with a live subscription is recoverable; an org that is gone with a live subscription is a customer being charged with no dashboard and no portal link to stop it. The one exception is a customer or subscription Stripe reports as resource_missing — already gone is the outcome being asked for, and it must not block the deletion forever.

Steps 4 and 5 are in that order deliberately. The route used to write org.deleted first, and delete from audit_logs where org_id = … took it along with everything else — the only trace of a deleted org was its absence. The row now lands after the cascade, filed under the actor's longest-standing surviving org that they own, because audit_logs.org_id is a NOT NULL FK to an organization that no longer exists. Owned, not merely joined: the metadata carries the deleted org's Stripe customer id, its canceled subscription ids, its invoice ids and what it was charged, and filing that into an org where the actor is a plain member would put one customer's billing identifiers in a different tenant's audit log and export. An actor who owns nothing else — or an erasure path, whose memberships are already gone — gets the org.deleted log line instead. It carries the same fields, at warn.

The response reports subscriptions_canceled, invoices and amount_charged_cents; the danger-zone confirm text warns about the charge before it happens and the success toast names the amount, so it is not a surprise line on a card statement.

Two callers still take the raw cascade. GDPR account erasure with delete_owned_orgs (DELETE /api/internal/user) calls deleteOrgCascade directly, so an owned org with a live subscription is still deleted out from under it; it wants deleteOrgWithBilling with { userId } as the actor. The stranded-signup reaper (src/lib/auth/signup-reap.ts) also calls the cascade, but only for orgs that never finished signing up and therefore never held a stripe_customer_id — nothing to settle there.

Billing portal

Two entry points on /overview/settings/billing:

  • Manage billing in Stripe — standard createBillingPortalSession.
  • Cancel subscription (paid plans only) — createCancelPortalSession opens the portal with flow_data.subscription_cancel pre-focused on the org's active subscription. Falls back to the standard portal if no active subscription is found.

API version

stripe.apiVersion: "2026-03-25.dahlia" — pinned in stripe.ts.