Billing

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

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 50,000 5,000 email $0.0003 ($0.30/1k), SMS $0.0056
enterprise custom ∞ ∞ —

Pro's reprice (from the 2026-11 period)

Pro stays $39/month; from usage period 2026-11 (PRO_REPRICE_FIRST_PERIOD) it includes 50,000 emails and 5,000 SMS segments, and its overage is metered about 25% below Pay as you go ($0.30 vs $0.40 per 1,000 emails, $0.0056 vs $0.0075 per segment). Every period before that keeps the terms it was sold under — 10,000 / 1,000 at Pay as you go's rates (PRO_LEGACY_TERMS).

That split is enforced, not just documented. PLANS holds only today's terms; everything that measures or costs a period asks usageTermsFor(plan, period) with that period's key — report-overage and settlement for a closed month, the plan-allowance alerts, the spend cap, GET /v1/usage and the billing page's usage bars for the open one. So a month that closed before the reprice is billed above 10,000 emails even when the cron runs afterwards, and is never re-priced to the bigger allowance.

The rate reaches Stripe through its own prices. A Stripe price is a per-unit rate, and a meter bills each event through whatever price reads it on the day the event lands — so changing the price on the shared meter would have re-priced the closed month the cron meters on the 1st. Instead each rate has its own price and its own meter — a rate line:

Line Who bills on it Meter env Price env
standard Pay as you go; Pro before 2026-11 STRIPE_{EMAIL,SMS}_OVERAGE_METER_EVENT STRIPE_{EMAIL,SMS}_OVERAGE_PRICE_ID
pro Pro from 2026-11 STRIPE_PRO_{EMAIL,SMS}_OVERAGE_METER_EVENT STRIPE_PRO_{EMAIL,SMS}_OVERAGE_PRICE_ID

report-overage meters a period on the line its priced plan carried then, so October's Pro overage reported on 1 November still lands on the standard meter at the old rate, while November's lands on the Pro meter. A Pro subscription started before the reprice is opened (and repaired) with both lines' prices — a metered item with no usage invoices nothing, and the two read different meters, so nothing bills twice. A repriced Pro month whose Pro meter or price is unset is never metered on the standard line instead (that would bill Pay as you go's rate): it is left unclaimed and re-tryable, and either gap fails the run — an unset meter as skipped_no_meter, an unset Pro price beside a priced standard line as skipped_unbillable / line_price_unset (half- configured, so never mistaken for the quiet flat-rate skip). Each line's meter event name is also checked against the event_name of the meter its price reads; a mismatch is refused as meter_event_mismatch rather than billed at the other line's rate.

Which is cheaper. Pro is not cheaper merely because you fill its allowance: 50,000 emails cost $25 on Pay as you go against Pro's $39. On email alone Pro becomes cheaper above 190,000 emails a month; on SMS alone above 4,533 segments; mixed traffic crosses over sooner. /pricing computes these from the rate table (proBreakEven in plan-terms.ts).

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, so they stay bounded on a self-serve tier. The two exceptions are phone numbers and dedicated IPs, where Pay as you go holds fewer than Pro (10 vs 25 numbers, 2 vs 5 IPs) — see resource charges.

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 per rate line, not per tier — Pay as you go shares the standard line with pre-reprice Pro (see above). 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, but checkout.session.completed writes the tier off the session's subscription, read live — the metadata goes stale the moment the customer switches that subscription between Pro and PAYG.

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.

Resource charges

Usage is not the only cost. A leased number bills at AWS every month whether it sends or not, a 10DLC campaign carries a monthly carrier fee, and a dedicated IP is billed per IP per month. These are charged on Pro and Pay as you go, on top of usage, from src/lib/billing/resource-prices.ts:

Resource Price Counted
Phone number, US/CA long code or 10DLC $1.50 / month phone_numbers, kind = 'number', type = 'longcode'
Phone number, toll-free $3.00 / month type = 'tollfree'
Phone number, any other country $1.50 / month (only US/CA are provisionable today)
10DLC brand registration $10.00 once the month the brand was filed (created_at)
10DLC campaign $15.00 / month campaigns.kind = '10dlc', not failed
Dedicated IP $35.00 / month per IP ip_pools.ip_count (the quantity requested), summed, clamped to the plan's dedicatedIps cap

Dedicated IPs are billed on what the customer asked for. Pools are created in SES's MANAGED scaling mode, which owns the IP count: AWS reports 0 for the 24–48h a pool provisions and may scale past the request later. ip_count is set at create and the ip-pool-warmup cron no longer rewrites it (it logs cron.ip_pool_warmup.ip_count_drift when AWS's count differs). The billed quantity is further clamped to the period plan's dedicatedIps cap across the org's pools, oldest first, so AWS's scaling can never bill past what the plan sells. Every writer applies the same org-wide clamp: a pool deleted mid-month is priced beside the pools still held (so an older pool keeps its place in the queue), and the IPs a released pool already put on the ledger for a period count against the cap the survivors are clamped to — by the cron, a later release and churn settlement alike. A downgraded org deleting one of its pools is therefore billed at most the cap for the month, not the cap once per writer. The released pool's IP count is read back from its ledger line as cents ÷ today's IP price (the ledger stores no quantity), and every status counts — a failed line is re-fired by the retry cron rather than dropped. So a price change mid-period, or a released-pool line Stripe keeps refusing, leaves the survivors clamped lower than what was actually invoiced: the error only ever under-bills.

No proration. A resource held at any point in a calendar month (UTC) is billed for that whole month: one leased on the 31st costs the month, and so does one released on the 1st. created_at is the only timestamp these rows carry and a released row is hard-deleted, so there is nothing finer to prorate from.

Never billed: Free (no subscription to bill — it is capped at 1 number, 0 dedicated IPs, 1 10DLC brand and 1 10DLC campaign instead), Enterprise (a contract), a comped owner, a test-key resource (simulated test-reg-… provider id), an alphanumeric sender ID (not a number), a short code (not on the price list — not provisionable through the API), a toll-free campaign (toll-free verification carries no monthly fee), and a registration that never reached the registry. Eligibility is per period, by planForPeriod — the same rule overage uses.

Caps. Absolute, per plan, enforced at create with the usual 402 PLAN_RESOURCE_LIMIT (checkResourceLimit, kinds phoneNumbers, dedicatedIps, brands and campaigns), counting live resources only:

Plan Phone numbers Dedicated IPs 10DLC brands 10DLC campaigns
free 1 0 1 1
payg 10 2 ∞ ∞
pro 25 5 ∞ ∞
enterprise ∞ ∞ ∞ ∞

Brands and campaigns are capped on Free only: a paid plan is billed each registration, so its count is bounded by the bill (and the hourly provisioning limiter), while Free is never billed and was otherwise unbounded. One of each is enough to send US long-code SMS end to end — the feature stays open on Free, only its volume is capped. Both caps are per org, not per tenant.

Free creation budget. Numbers, brands and campaigns are hard-deleted on release, so a held-count cap alone let a Free org lease, release and re-lease (or file, delete and re-file) indefinitely, each a real provider charge Sendoka absorbs. Free therefore also allows 3 live number leases, 3 live brand filings and 5 live 10DLC campaign filings per rolling 30 days (campaigns get two more because a rejected one can only be re-filed, never corrected in place), released ones included (FREE_CREATION_BUDGET in src/lib/api/resource-limit.ts, counted from phone_number.provisioned / brand.created / campaign.created audit rows, written before the create answers, the reconcile cron's toll-free lease included — test-key simulations, alphanumeric sender IDs and toll-free verifications excluded). Over it the create answers the same 402 PLAN_RESOURCE_LIMIT: Free plan allows 3 new phone numbers per 30 days, released ones included (3/3 used). Upgrade for more. Paid plans are billed per resource and have no budget. brands counts every brand row whose registration id is not a test key's test-reg-… — including a brand filed only to back a toll-free verification, which is the same billed filing — except a failed row with no registration id at all (the filing errored and the reconcile cron gave up: never billed, not PATCHable). campaigns counts 10DLC rows the same way, minus every failed one, which billing skips too (toll-free verifications are neither billed nor capped). POST /v1/brands, POST /v1/campaigns (live keys, 10DLC only) and their dashboard twins check them ahead of the provisioning limiter.

These caps, like phoneNumbers, are read-then-write: the count is taken, then the billed (non-idempotent) registry call is made, then the row is inserted. Two concurrent creates from one Free org can both see the slot free and both file. The hourly provisioning limiter (10/hr/org) is the only other bound, so the worst case is a handful of unbilled filings an hour, which report-overage then lists under resources.over_cap_free. A per-org lock would have to span the provider call and every early return on four routes; it is not taken.

POST /v1/phone-numbers (live keys; a test key leases nothing and is not held to it) and the dashboard create check phoneNumbers; IP-pool create checks dedicatedIps against the whole ip_count; assigning a domain to a pool is refused while the org is over its IP cap (a downgrade), and unassign and pool delete stay open so it can get back under. IP_POOL_MAX_IPS_PER_ORG is now an optional operator kill-switch that can only lower a finite plan cap; unset adds none there. Enterprise and comped owners (dedicatedIps: ∞, never billed for IPs) keep the old ceiling of 2 while it is unset; setting it raises or lowers theirs.

Downgrades keep what they hold — reported, not billed. Caps bind at create, and nothing releases a resource when an org drops to Free. A Pro org that leases 25 numbers and 5 IPs and then cancels keeps them on Free, where nothing is charged. report-overage lists every org whose closed period resolved to Free while it held more numbers, IPs, 10DLC campaigns or brands (filed that month) than Free allows under resources.over_cap_free (and cron.overage.free_over_cap_holdings, with the monthly cost going unbilled), so ops can release them or bill by hand. It does not fail the run. Releasing or auto-billing them is a pending product decision.

How it reaches Stripe. Invoice items with an explicit amount in USD — no Stripe Price to create. Three writers, one ledger:

  1. report-overage (1st–5th) bills the closed month for every resource still held, as pending invoice items on the org's subscription (the same one pickTarget chooses for the metered prices); they ride its next invoice. Orgs with no overage are included. No subscription → nothing claimed (a churned customer was billed by settlement); two live subscriptions → unbillable, retried and loud.
  2. Release — every DELETE of a number, campaign, brand or IP pool (v1 and dashboard) bills that row for the current month, and the previous one if the 1st's run has not yet, before the row is deleted. Without this a resource created and released inside one month was free. Best effort: logged (billing.resources.release_unbilled), never blocks the release. One deliberate carve-out: discardLeasedNumber (src/lib/api/tollfree-legacy.ts) releases and deletes a toll-free number leased inside a request that then failed — the lease that lost the bind race, one whose bind threw, or one whose campaign create never committed — without billing it. The customer never saw that number (no response named it, no webhook fired) and the request answered with the winner's number or an error, so a charge would be for something they could not have used. Sendoka absorbs the provider's cost for those seconds.
  3. Settlement (cancel, org delete) puts the current month's lines, and any unbilled previous-month ones, on its own invoice — see below.

Each line is an overage_reports row with channel = resource:{kind}:{id} and value = cents, claimed with the same INSERT … ON CONFLICT … WHERE status = 'failed' as overage, so no writer bills a line twice. reported rows carry the invoice item (ii_…) or invoice (in_…) id. A definite Stripe refusal releases the row to failed; an unknown outcome leaves it pending for a human, like overage. Every report-overage firing retries failed resource rows from the ledger row alone (retryFailedResourceLines — kind and id from the channel, cents from value), which is the only way a line refused at release time is ever billed: its resource row is gone. Resource rows are listed apart from overage in the run's response (resources.unresolved, not unresolved_prior), so a backlog of them cannot crowd overage out of that list.

Ending subscriptions. A subscription set to cancel at period end (cancel_at_period_end or a dated cancel_at — what the portal's cancel flow does) raises no further invoice, so a pending item attached to it would never be collected. Writers 1 and 2 therefore put lines for such a customer on a standalone invoice of their own (draft, pending_invoice_items_behavior: "exclude", finalized), not on the subscription. Items attached before the cancel are swept on every customer.subscription.deleted (sweepOrphanedResourceItems): re-created on one finalized invoice, then the stranded originals deleted and the ledger row pointed at the new invoice. The sweep runs whether or not the customer still holds another live subscription — it only touches items whose subscription is no longer live — so items left on a subscription that died beside a second one are collected too.

Wall clock. The cron checks its deadline before every claim; lines it does not reach are resources.deferred, untouched, and the run reports complete: false (500). A page whose resource pass throws is caught and counted (resources.page_errors, 500) — overage metering for later pages carries on.

Rollout. RESOURCE_BILLING_FIRST_PERIOD (YYYY-MM) is the first month charged. Unset, nothing is billed — the price list must not reach back over months in which it was not published. The cron reports resources.off: "epoch_unset" (a warning, not a 500).

Not covered by the spend cap. spend_cap_cents bounds metered usage; resource charges are bounded by the caps above instead.

Residual gap. The orphan sweep runs once per customer.subscription.deleted and lists one page (100) of the customer's pending items. A sweep that failed (logged billing.resources.orphan_*) is not retried automatically; reconcile from overage_reports (resource:%, ii_…) against Stripe's pending items.

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 overage rate, both read for the current period through usageTermsFor() — so a Pro month before the reprice is costed on the old allowance and rates (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 — except in the month a Free org upgrades, which keeps Free's allowance exactly as report-overage does (currentAllowancePlan() in src/lib/api/plan-history.ts), so the cap, its alerts and the billing page's "spent" figure all match the invoice. 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 and, once a cap is set, the org's org_plan_events rows, 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. Plan-allowance rows carry the allowance in the ledger channel (email:50000; the Free allowance keeps the bare email / sms), so an org that changes plan mid-month is alerted at 80% and 100% of its new allowance even after crossing the old one. 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.

Upgrade flow

  1. User clicks Upgrade → POST /api/internal/billing.
  2. Server finds or creates a Stripe customer (ensureOrgStripeCustomer: idempotency key org-customer-{orgId}, metadata.orgId on the customer) and persists stripe_customer_id only while it is still null, so two overlapping clicks cannot leave the org on a customer other than the one its Checkout was opened on.
  3. Creates Checkout Session with the tier's flat price (STRIPE_PRO_PRICE_ID / STRIPE_PAYG_PRICE_ID) — plus the metered overage prices of every rate line the tier bills through this period and next, when set: STRIPE_{EMAIL,SMS}_OVERAGE_PRICE_ID for Pay as you go (and for Pro before its reprice), STRIPE_PRO_{EMAIL,SMS}_OVERAGE_PRICE_ID for Pro. That 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 — falling back to metadata.orgId (session, subscription, else customer) only when no org stores the customer and the named org holds none or the same one (see Stripe → Metadata):

Event Effect
checkout.session.completed plan_status = the tier the session's subscription holds now (its flat price via planForSubscriptions, not metadata.plan), only while that subscription is still active, trialing or past_due (read live, so a late re-delivery cannot restore a canceled tier or undo a Pro/PAYG switch); an unpriceable subscription writes nothing
customer.subscription.created Re-derive plan_status from the customer's live subscriptions, and trace a resume into an already-settled period (the remainder is billed as a report-overage top-up)
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, in after(), settle the final usage when no live subscription remains, then sweep the dead subscription's orphaned resource items (always)
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.

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.

Resource charges ride the same invoice. The current month's resource lines, and any of the previous month's the cron has not billed, are claimed under the same ledger rule and added as line items — on org delete the resource rows are hard-deleted right after, so this is the last writer that can see them. They are priced in USD, so with overage prices in another currency they are left unclaimed and billing.settlement.resource_currency_conflict is logged. With no overage owed at all, the invoice is raised for the resources alone, in USD.

At what rate. The unit rate is read off the same Stripe price the monthly meter bills through — the period's rate line: STRIPE_{EMAIL,SMS}_OVERAGE_PRICE_ID for Pay as you go and pre-reprice Pro, STRIPE_PRO_{EMAIL,SMS}_OVERAGE_PRICE_ID for Pro from 2026-11 — whose unit_amount_decimal is in cents and is fractional (email is 0.04¢ a send on the standard line). A leaving Pro customer settling October and November together gets each month on its own terms: October above 10,000 at the standard price, November above 50,000 at the Pro price. 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. The exception is a price unset on the period's line while another line prices the same channel (line_price_unset): half-configured, so it is logged as an error — the tail is still forfeited.
  • 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). A customer left on a paid tier with no subscription is the worse outcome, so the settlement is not left to one attempt: the webhook records a retry row before it settles and clears it on success, and an hourly job retries any settlement that failed or was cut off, with backoff, giving up after 10 attempts. 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.

Resubscribing inside a settled month. A settled row's value is the cut-off — the units of the period it accounted for, invoiced or written off — and the row is never re-opened to failed (the cron would re-meter the whole month, including the part already invoiced). Instead, both writers bill what lies above the cut-off, once (src/lib/billing/settlement-ledger.ts):

  • report-overage, on the 1st, meters the remainder as a top-up under overage-topup:{org}:{period}:{channel}, taking the row back from reported only from the exact settled state it read. The row then holds the period's whole overage and is final. A customer who never came back (no subscription) is left alone quietly — settlement's invoice was their final bill. A definitely rejected top-up goes back to the settled state, never to failed.
  • A second settlement in the same period (cancel, resubscribe, cancel again) invoices the units above the cut-off and moves it to the new invoice.

customer.subscription.created into a settled month logs billing.settlement.resumed_after_settlement at info as a trace for support; nothing has to be reconciled by hand.

Deleting an organization

DELETE /api/internal/org (slug confirm plus the account step-up, see security) 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, invoice_now: true. 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. invoice_now is for the overage report-overage already metered: on a subscription that renews mid-month, last month's meter events sit as pending usage on the current Stripe period, which settlement skips as reported and a plain cancel never invoices.
  4. Capture the AWS resources the org's rows name (collectOrgProviderResources in src/lib/api/org-provider-release.ts), then deleteOrgCascade.
  5. Release them, best effort: leased numbers, then campaign and brand registrations, then SES identities, then dedicated IP pools. Never a simulated (test-reg-) id, never a platform sender (isSandboxSenderDomain, so the parent sendoka.com identity is never touched), and never a domain or number another org's row still names. Failures are logged as org.delete.provider_release_failed with the handle to clean up by hand, and recorded as provider_release_failed on the audit row; they never fail the deletion. IP pools are deleted in deleteSesIpPool's strict mode, so a rejected DeleteDedicatedIpPool lands there too rather than being reported as released (already-gone counts as deleted). A pool never sent to AWS — no credentials, or SES_IP_POOL_DRY_RUN — is provider_release_skipped.
  6. Clear the Stripe customer's email, name, phone, description, address and shipping (unless another org still bills through the same customer). Cleared, not deleted: a deleted customer loses its payment methods, and the settlement and invoice_now invoices still have to collect. Recorded as stripe_customer_anonymized.
  7. Write the org.deleted audit row.

Any Stripe failure in 1-3 throws OrgBillingUnreachableError and steps 4-7 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.

The cascade and the audit row (steps 4 and 7) 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.

GDPR account erasure with delete_owned_orgs (DELETE /api/internal/user) goes through deleteOrgWithBilling too. The stranded-signup reaper (src/lib/auth/signup-reap.ts) calls deleteOrgCascadeReleasingProviders — the cascade plus steps 4-5 — because its orgs never finished signing up and therefore never held a stripe_customer_id, but can still have added a domain.

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.

Both are Server Actions in src/app/overview/settings/billing/actions.ts, and a Server Action is a public POST endpoint whatever the page rendered. Each one calls requireOwnerSession() itself and re-reads stripe_customer_id for the session's active org at call time, so a replay from a demoted or removed owner, or from another org, bounces back to the billing page without minting a portal. Never close over render-time org data in a new billing action.

API version

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