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.ts—resolveOrgPlan()/resolveOrgEntitlement(), the entitlement every gate reads (60s cache).src/lib/api/usage-limit.ts— plan-cap gate plusmeteredCostCents()/checkSpendCap(), the spend cap's arithmetic and enforcement.src/lib/api/usage-alerts.ts— 80%/100%usage.alertwebhooks + owner mail, deduped through theusage_alertsledger.src/lib/billing/settlement.ts—settleFinalUsage(), the final invoice a leaving customer gets.src/lib/api/org-delete.ts—deleteOrgWithBilling(): 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 | ∞ | ∞ | — |
enterpriseis 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/mofigure 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_statusis untouched. It stays the record of what was bought, so the billing card, the Stripe webhook,org_plan_eventsand 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-overageskips 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
- User clicks Upgrade →
POST /api/internal/billing. - Server finds or creates a Stripe customer, persists
stripe_customer_idon the org. - Creates Checkout Session with
STRIPE_PRO_PRICE_ID— plusSTRIPE_EMAIL_OVERAGE_PRICE_ID/STRIPE_SMS_OVERAGE_PRICE_IDwhen 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 noquantity— the reported usage is the quantity. Then:success_url:/overview/settings/billing?upgraded=truecancel_url:/overview/settings/billing
- 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-overageleaves 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, inafter()so no Stripe round-trip sits in front of the webhook's 200 (the route setsmaxDuration = 300for the same reasonreport-overagedoes). Best effort: a customer left on a paid tier with no subscription is the worse outcome. Running it after the downgrade is safe becauserecordPlanChangewrites the flip and thepro → freehistory row in one statement, soplanForPeriodstill holds the highest entitlement of the period and prices the month atpro.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:
listBillableSubscriptionsfor the org'sstripe_customer_id.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.- 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. deleteOrgCascade.- Write the
org.deletedaudit 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) —
createCancelPortalSessionopens the portal withflow_data.subscription_cancelpre-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.