Skip to content

Subscription Lifecycle Management

This guide covers the in-app subscription management system: upgrading, downgrading, canceling, and reactivating subscriptions, plus automatic billing sync triggered by podcast lifecycle events.

Prerequisite

For the initial checkout and webhook processing flow, see the Subscription Flow guide. This guide covers what happens after a user has an active subscription.

Architecture Overview

Billing Model Guard

All plan management features check the billing_model column before executing:

Billing ModelDescriptionPlan ChangesBilling Sync
subscriptionStandard Stripe recurringAllowedActive
ltdLifetime/3-year launch dealBlocked (400)Skipped
enterprise_manualSales-managed billingBlocked (400)Skipped
typescript
// In change-plan endpoint
if (profile.billing_model !== 'subscription') {
	return c.json({ error: 'Plan changes are not available for your billing type' }, 400);
}

// In stripe-sync utility
export function shouldSyncToStripe(ctx: StripeSyncContext): boolean {
	if (ctx.billingModel === 'enterprise_manual') return false;
	if (ctx.billingModel === 'ltd') return false;
	// ...
}

The UI also adapts: LTD/enterprise users see "Contact support" instead of plan change buttons.

Subscription Resolution

Before any plan change, the system must resolve the user's Stripe subscription ID. The resolveStripeSubscription() utility handles this with a cache-then-lookup strategy:

This handles:

  • Subscriptions created before the stripe_subscription_id column existed
  • Missed or delayed webhook events
  • Race conditions in webhook event ordering

Location: src/api/utils/stripe-sync.ts

Plan Change Flow

Upgrade (e.g., Starter -> Professional)

Key behaviors:

  • always_invoice: Charges proration immediately (not deferred to next invoice)
  • error_if_incomplete: Stripe throws if card declines, returning 402 to the user
  • Immediate DB write: Tier is updated in DB right away, not waiting for webhook

Downgrade (e.g., Professional -> Starter)

Before switching the price, the endpoint enforces podcast limits:

Podcast Slot Counting

The countOccupiedSlots() function determines how many podcast slots a user occupies:

StatusHosting TypeOccupied?
activeAnyYes
pausedAnyYes
pending_deletionexternalNo (freed immediately)
pending_deletionpodcasterplusYes, until deletion_scheduled_at passes

Location: src/lib/utils/subscription.ts

Cancel to Free

Cancellation uses cancel_at_period_end rather than immediate cancellation:

  1. Endpoint sets cancel_at_period_end: true on Stripe subscription
  2. Writes subscription_cancel_at_period_end: true to DB immediately
  3. User keeps access until subscription_current_period_end
  4. When period ends, Stripe sends customer.subscription.deleted webhook
  5. Webhook sets subscription_tier: 'free', subscription_status: 'canceled'

Reactivation

When a subscription is pending cancellation (cancel_at_period_end: true), the user can reverse it:

  1. User clicks "Reactivate" in the SubscriptionStatusBanner
  2. Endpoint verifies subscription_cancel_at_period_end === true
  3. Sets cancel_at_period_end: false on Stripe subscription
  4. Writes subscription_cancel_at_period_end: false to DB immediately

Auto-Reactivation

If a user creates a new podcast while their subscription is set to cancel, uncancelSubscription() in stripe-sync.ts is called as a side effect to automatically reverse the cancellation.

Billing Sync (Podcast Lifecycle Events)

Beyond explicit plan changes, certain podcast lifecycle events trigger automatic Stripe updates via the stripe-sync.ts utility:

Podcast EventStripe ActionFunction
Delete last podcastcancel_at_period_end: truecancelSubscriptionAtPeriodEnd()
Create podcast while cancelingcancel_at_period_end: falseuncancelSubscription()
Pause last active podcastpause_collection: { behavior: 'void' }pauseSubscriptionBilling()
Unpause any podcastClear pause_collectionresumeSubscriptionBilling()

All functions are safe side effects: they try-catch internally, log failures, and never throw. The main operation (podcast create/delete/pause) always succeeds regardless of Stripe sync outcome.

typescript
// Example: in podcast deletion handler
// Primary operation
const { error } = await supabase.from('podcasts').delete().eq('id', podcastId);
if (error) return c.json({ error: 'Failed to delete podcast' }, 500);

// Side effect: billing sync (never fails the main operation)
try {
	await cancelSubscriptionAtPeriodEnd(stripeSyncContext);
} catch (err) {
	console.error('Billing sync failed:', err);
}

return c.json({ success: true });

Dual-Write Strategy

Plan changes and reactivation use a dual-write pattern:

  1. Stripe first: Update the subscription in Stripe (source of truth for billing)
  2. Immediate DB write: Write the key field to user_profiles immediately (UI reflects change instantly)
  3. Webhook as backup: Stripe's webhook event fires later and re-syncs the same data

This ensures:

  • The UI updates immediately (no waiting for webhook round-trip)
  • Data is eventually consistent even if the immediate DB write fails
  • Webhook handles edge cases (manual changes in Stripe Dashboard, etc.)

UI Components

SubscriptionCard

Location: src/lib/components/subscription/SubscriptionCard.svelte

Rendered on the Settings page. Receives subscription data from layout:

svelte
<SubscriptionCard
	subscription={{
		tier: 'starter',
		status: 'active',
		stripeCustomerId: 'cus_xxx',
		cancelAtPeriodEnd: false,
		currentPeriodEnd: '2026-05-15T00:00:00Z',
		billingModel: 'subscription'
	}}
	podcasts={ownedPodcasts}
/>

Conditionally renders buttons based on current state:

  • Free tier: "Upgrade Plan" link to /pricing
  • Starter: "Upgrade to Professional" + "Downgrade to Free"
  • Professional: "Downgrade to Starter" + "Downgrade to Free"
  • Canceling: Hides downgrade buttons (already canceling)
  • LTD/Enterprise: "Contact support" text

SubscriptionStatusBanner

Location: src/lib/components/subscription/SubscriptionStatusBanner.svelte

Rendered in the app layout for non-normal subscription states:

StateBanner ColorMessageAction
cancel_at_period_end + activeAmber"Ends on {date}"Reactivate button
past_dueRed"Payment failed"Update Payment button

BillingWarning

Location: src/lib/components/subscription/BillingWarning.svelte

Alert shown in podcast delete/pause confirmation dialogs when the action would affect billing:

  • Delete last podcast: "Deleting your last podcast will cancel your subscription at the end of your current billing period"
  • Pause last podcast: "Pausing your last active podcast will pause your subscription billing"

Only shown when isLastPodcast && tier !== 'free'.

Database Schema

New columns added to user_profiles by migration 20260414093538_add_subscription_lifecycle_columns.sql:

ColumnTypeDefaultPurpose
stripe_subscription_idTEXTNULLStripe subscription ID for API operations
subscription_cancel_at_period_endBOOLEANfalseWhether subscription is set to cancel
subscription_current_period_endTIMESTAMPTZNULLBilling period end date
billing_modelTEXT'subscription'Billing type guard
ltd_expires_atTIMESTAMPTZNULLLTD expiration (NULL = perpetual)

See Stripe Integration - Database Schema for the full schema.

Test Coverage

Test FileCoverage
src/api/routes/stripe/__tests__/change-plan.test.tsPlan change endpoint: upgrades, downgrades, cancellation, reactivation, podcast limits, card decline, billing model guard
src/api/utils/__tests__/stripe-sync.test.tsSubscription resolution, billing sync functions, skip logic
src/lib/utils/__tests__/subscription.test.tsTier limits, slot counting, paid tier detection
src/api/routes/__tests__/subscription-flow.integration.test.tsEnd-to-end checkout -> webhook -> profile update

Internal documentation - Not for public distribution