Provisioning
How a billing account comes to exist, and every path by which its plan and entitlements change. The one iron rule: plans are granted only by verified money events. Signup metadata never grants anything.
Free bootstrap (every confirmed signup)
Migration 20260608170000_gate2_stripe_provisioning_functions.sql §5, re-timed by 20260906200000_provision_on_email_confirmation.sql:
on_auth_user_confirmed_billing(AFTER UPDATE OFemail_confirmed_atonauth.users, on the NULL to timestamp flip) firesbootstrap_billing_account_for_user(); its twinon_auth_user_created_billing(AFTER INSERT, only when the row is inserted already confirmed) covers admin-created and auto-confirmed users. An unconfirmed signup owns no account: until the email is proven nothing is provisioned, andreap_unconfirmed_users()(lifecycle-manager daily lane) deletes stale unconfirmed rows after seven days. The function creates abilling_accountsrow withplan_key = 'free',subscription_status = 'inactive', adds the user asownerinbilling_account_members, and callsseed_account_entitlements(). Idempotent: a user who already owns an account is skipped.seed_account_entitlements(account)inserts oneaccount_entitlementsrow per active catalog key (ON CONFLICT DO NOTHING), with deliberately inert values:- Features:
granted = FALSE. The row exists only to satisfy C8 row-existence; the grant decision is the live catalog baseline byplan_key. Materializing the baseline intograntedwould freeze paid features at seed time (a canceled account would keep passing paid gates). - Numeric keys:
quota_limit = NULL(no override; the cap resolves live from the catalog),quota_used = 0,period_* = NULL(consume_quotaself-rolls the window on first use). source = 'manual': the local baseline; the Stripe reconciliation only comparessource = 'stripe'rows, so the baseline never reads as drift.
Later catalog keys ride the same function: any migration that adds a key backfills existing accounts by re-running
seed_account_entitlementsper account (e.g.20260625120000§1), otherwise the new key's C8 gate would fail closed for old accounts.- Features:
set_podcast_billing_account()(BEFORE INSERT onpodcasts) stampspodcasts.billing_account_idfrom the creator's sole owner account; 0 or >1 owner accounts staysNULLrather than guessing.
Signup metadata is never trusted
Migration 20260610130000_gate2_detrust_signup_metadata_provisioning.sql (launch blocker A13): raw_user_meta_data is client-controlled, and both provisioning triggers once derived a paid plan from it, so a forged, unpaid signup could self-grant paid quotas while both mirrors agreed (reconciliation drift = 0 was blind to it). Since that migration, neither handle_new_user nor bootstrap_billing_account_for_user reads any entitlement-bearing metadata key (including stripe_customer_id). Every new account starts Free/inactive with no linked Stripe customer; a paid plan arrives only through the signature-verified webhook, which resolves the account by the trusted billing_account_id stamped at checkout. Treat all user_metadata entitlement fields as untrusted forever.
The Stripe webhook: the only plan writer
src/api/routes/webhooks/stripe.ts is the single grant path. Key behaviors:
- Idempotency:
processed_stripe_events(event id marker); the entitlement full-replace folds the marker into the same transaction (replace_stripe_feature_entitlements(account, active_keys, event_id),20260608170000§4: demote stalesource='stripe'rows back to the baseline, upsert active keys asgranted = TRUE, source = 'stripe', never DELETE a row C8 needs). - Ordering: stale
customer.subscription.*deliveries are rejected by comparing event creation times (F7), so an out-of-orderupdatedcannot resurrect a canceled state. - Deferred grants: an unpaid subscription checkout session defers the tier grant to
customer.subscription.updated(which grants onactive); writing the tier from an unpaid session would be fail-open. - Add-on packs: a
mode = 'payment'session withmetadata.add_on_typeupserts anadd_on_credit_grantsrow keyed bystripe_checkout_session_id(replay-safe), with the validity window fromADDON_PACKSinsrc/lib/billing/price-catalog.ts.charge.refunded/charge.dispute.*resolves the grant by payment intent or charge id and revokes it (status = 'revoked',revoked_at); the consumed portion stays consumed. - Pending-change mirror:
billing_accounts.pending_plan_key+pending_plan_change_atrecord a scheduled self-serve downgrade (written bychange-planon schedule/cancel creation) so the UI can show it during the grace window. The mirror (src/api/utils/billing-mirror.ts) clears both on every applied subscription flip, and the reactivate endpoint clears them when the user keeps their current plan. - Flip-time overage reconciler (downgrade-overages §6.2,
docs/planning/plans/2026-07-12-downgrade-overages.md): after a plan-affecting event's mirror succeeds, the subscription handlers runreconcileContentOverage()as a side effect in its own try/catch — it must never fail the webhook's primary mirror. It is derive-from-live and idempotent (webhook retries re-derive the same state): over the podcasts-count or storage caps → arm the 30-day escalation clock (conditional update, first-detection-wins) and atomically claim/send the due stage email; under both caps → disarm and restore any suspended feeds (clearpodcasts.overage_suspended_atonly, plus paired rss + public-api invalidations); a downgrade flip landing over only grandfathered limits sends the plan-transition email with no clock state. The lifecycle-manager's daily escalation sweep is the correctness backstop for any reconciler failure — see Lifecycle Manager.
Founders
Founders is the one tier mapping to three Stripe prices (resolveFoundersPriceIds() in price-catalog.ts, lookup keys founders_recurring / founders_onetime / founders_studio):
- a $0/mo recurring checkout price (the subscription container),
- the $399 one-time Checkout line item,
- the $49/mo grandfathered price attached as a subscription-schedule phase for after the 3-year term (deliberately not the $59 Studio list price).
Provisioning (shared by checkout.session.completed and the async-payment fallback in invoice.payment_succeeded) attaches the schedule, then calls the provision_founders_account(account) RPC (20260608170000 §3): sets plan_key = 'founders'and revokes any pre-launch founders_launch_bridge overrides in one transaction, so the Studio-equivalent base and the additive bridge can never stack. Entitlement-wise, Founders is Studio: the catalog has no founders key; entitlement_base_cap() and remapPlanKey() remap to studio. The launch bridge itself is granted per account by admin_bulk_override_account() (20260615140000 §3c, reason founders_launch_bridge, expires_at = NULL), which skips accounts already on plan_key = 'founders' or already bridged.
Enterprise
Sales-led: an admin creates a Stripe subscription with collection_method = 'send_invoice'; the customer pays the invoice.
- Grant (
provision_enterprise_account(account, custom_caps), migration20260630120000, re-defined in20260710140000): called by the webhook only oninvoice.payment_succeededwithbilling_reason = 'subscription_create'(A13: asubscription.*event arriving before payment never grants the tier; the subscription mirror writesplan_key = NULLfor enterprise subs). Setsplan_key = 'enterprise'and writes each negotiated cap. - Negotiated caps are absolute overrides: Enterprise catalog bases are
NULL(unlimited) exceptadvance_booking_days(365) andwebhook_sends_per_month(100,000), and additive deltas are ignored when the base isNULL(effective_capshort-circuits). So a finite per-deal cap is written toaccount_entitlements.quota_limit, the valueentitlement_base_cap()COALESCEs ahead of the catalog. Defense-in-depth: only numeric catalog keys with non-negative numeric JSON values are applied; anything else is silently ignored. - Provenance (
20260710140000):account_entitlements.quota_limit_from_enterprise(boolean) andquota_limit_pre_enterprise(the override the row held before Enterprise overwrote it). A provisioning re-fire preserves the originally saved pre-Enterprise value. - Deprovision (
deprovision_enterprise_account, oncustomer.subscription.deletedor a cancel-shapedupdated): setsplan_key = 'free',subscription_status = 'canceled', clears the subscription mirror, and resets only the Enterprise-flagged rows, restoring each row's pre-Enterprise override. A grandfathered override Enterprise never touched survives (the earlier blanket reset in20260630120000clobbered it;20260710140000is the correction).
Admin comps and manual adjustments
admin_grant_override() (20260615140000 §3b, service-role only, called from the platform admin surface behind requirePlatformAdmin()): inserts an account_entitlement_overrides row (granted and/or quota_delta, required reason, optional expires_at; NULL = permanent) plus the admin_action_log append in one transaction. Comps are additive and stackable; they never write account_entitlements.quota_limit. A granted: false override is inert (the feature union is additive, not a revoke). Overrides on a NULL-base (unlimited) key have no effect on caps.
Grandfathering (GATE-2 backfill)
20260608170500_gate2_migration_backfill.sql provisioned pre-Epic-13 accounts on the remote:
- One billing account per existing owner (or non-free subscriber), carrying Stripe state from
user_profiles, with the legacy tier remap (free → free,starter → creator,professional → studio,enterprise → enterprise). seed_account_entitlements()for each, then apodcasts_per_accountgrandfather: where an account's occupied podcast count exceeded the catalog default (only Free is finite), the backfill wroteaccount_entitlements.quota_limit = occupiedso the account stays legal without deleting podcasts. This is why deprovisioning must restore prior overrides (above).- A verification gate (RAISE on failure) asserted every podcast is linked, every non-free subscriber has a matching account, and
effective_cap('podcasts_per_account') >= occupiedfor every account.
Plan changes
Self-serve plan changes (src/api/routes/stripe/change-plan.ts / preview-change.ts) rank directions with planRank(planKey, interval) (annual outranks monthly within a plan). Checkout (src/api/routes/stripe/checkout.ts) accepts only creator | studio | production_house | founders and resolves prices by lookup_key through the price catalog, failing fast with PriceCatalogError rather than starting a broken session.
Voluntary downgrades run a closed list of exactly three blocking pre-flights (downgrade-overages §5; O5 keeps the list closed — everything else grandfathers with a forward-block). All three compute against the target plan with effective_cap_for_plan() (migration 20260611094845 §1, founders → studio remap, account-specific layers kept) and fail closed on read error:
- Podcasts — occupied slots vs the target
podcasts_per_accountcap (existing,forceDeletePendingaffordance unchanged). - Staff seats — distinct-member count vs the target
staff_seats_per_accountcap; the 409 carries the member roster so the dialog can checklist who to remove. - External hosting (→Free only) — external hosting is paid-only (2026-07-17 ruling): if the target plan lacks the
external_hostingfeature and any occupying podcast hashosting_type = 'external', the 409 lists them (convert to show.fm hosting, delete, or keep a paid plan). Paid→paid never trips this — every paid tier holds the feature.
Any blocker → 409 { code: 'downgrade_blocked', data: { blockers } } with per-resource detail (computeBlockingPreFlight() in src/api/utils/downgrade-impact.ts); PlanChangeDialog's blocked step renders it as a per-resource checklist with deep links. preview-change returns the same module's impact summary on downgrades (impact: { blocking, advisories, clamps, quotasPausing, featuresLost } — advisory items carry disposition keeps_working_blocked_growth, value caps clamps, over-quota months pauses_until_rollover) so the preview step shows what changes before the confirm; impact computation fails soft (impact: null → the dialog shows a "couldn't verify usage" caveat) because the change-plan pre-flight remains the guard. On success, change-plan persists the pending change to billing_accounts.pending_plan_key / pending_plan_change_at (cleared by reactivate and by the webhook flip — see the mirror bullet above). At the flip, enforcement is still forward-only: the webhook's flip-time overage reconciler and the lifecycle-manager sweep handle anything that slipped past the pre-flights during the grace window.
Related
- Entitlement Model for what the seeded rows mean at read time
- Stripe service, Subscription Flow, Subscription Lifecycle