Skip to content

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:

  1. on_auth_user_confirmed_billing (AFTER UPDATE OF email_confirmed_at on auth.users, on the NULL to timestamp flip) fires bootstrap_billing_account_for_user(); its twin on_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, and reap_unconfirmed_users() (lifecycle-manager daily lane) deletes stale unconfirmed rows after seven days. The function creates a billing_accounts row with plan_key = 'free', subscription_status = 'inactive', adds the user as owner in billing_account_members, and calls seed_account_entitlements(). Idempotent: a user who already owns an account is skipped.

  2. seed_account_entitlements(account) inserts one account_entitlements row 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 by plan_key. Materializing the baseline into granted would 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_quota self-rolls the window on first use).
    • source = 'manual': the local baseline; the Stripe reconciliation only compares source = '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_entitlements per account (e.g. 20260625120000 §1), otherwise the new key's C8 gate would fail closed for old accounts.

  3. set_podcast_billing_account() (BEFORE INSERT on podcasts) stamps podcasts.billing_account_id from the creator's sole owner account; 0 or >1 owner accounts stays NULL rather 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 stale source='stripe' rows back to the baseline, upsert active keys as granted = 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-order updated cannot resurrect a canceled state.
  • Deferred grants: an unpaid subscription checkout session defers the tier grant to customer.subscription.updated (which grants on active); writing the tier from an unpaid session would be fail-open.
  • Add-on packs: a mode = 'payment' session with metadata.add_on_type upserts an add_on_credit_grants row keyed by stripe_checkout_session_id (replay-safe), with the validity window from ADDON_PACKS in src/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_at record a scheduled self-serve downgrade (written by change-plan on 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 run reconcileContentOverage() 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 (clear podcasts.overage_suspended_at only, 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):

  1. a $0/mo recurring checkout price (the subscription container),
  2. the $399 one-time Checkout line item,
  3. 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), migration 20260630120000, re-defined in 20260710140000): called by the webhook only on invoice.payment_succeeded with billing_reason = 'subscription_create' (A13: a subscription.* event arriving before payment never grants the tier; the subscription mirror writes plan_key = NULL for enterprise subs). Sets plan_key = 'enterprise' and writes each negotiated cap.
  • Negotiated caps are absolute overrides: Enterprise catalog bases are NULL (unlimited) except advance_booking_days (365) and webhook_sends_per_month (100,000), and additive deltas are ignored when the base is NULL (effective_cap short-circuits). So a finite per-deal cap is written to account_entitlements.quota_limit, the value entitlement_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) and quota_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, on customer.subscription.deleted or a cancel-shaped updated): sets plan_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 in 20260630120000 clobbered it; 20260710140000 is 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 a podcasts_per_account grandfather: where an account's occupied podcast count exceeded the catalog default (only Free is finite), the backfill wrote account_entitlements.quota_limit = occupied so 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') >= occupied for 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:

  1. Podcasts — occupied slots vs the target podcasts_per_account cap (existing, forceDeletePending affordance unchanged).
  2. Staff seats — distinct-member count vs the target staff_seats_per_account cap; the 409 carries the member roster so the dialog can checklist who to remove.
  3. External hosting (→Free only) — external hosting is paid-only (2026-07-17 ruling): if the target plan lacks the external_hosting feature and any occupying podcast has hosting_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.

Internal documentation - Not for public distribution