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 Model | Description | Plan Changes | Billing Sync |
|---|---|---|---|
subscription | Standard Stripe recurring | Allowed | Active |
ltd | Lifetime/3-year launch deal | Blocked (400) | Skipped |
enterprise_manual | Sales-managed billing | Blocked (400) | Skipped |
// 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_idcolumn 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:
| Status | Hosting Type | Occupied? |
|---|---|---|
active | Any | Yes |
paused | Any | Yes |
pending_deletion | external | No (freed immediately) |
pending_deletion | podcasterplus | Yes, until deletion_scheduled_at passes |
Location: src/lib/utils/subscription.ts
Cancel to Free
Cancellation uses cancel_at_period_end rather than immediate cancellation:
- Endpoint sets
cancel_at_period_end: trueon Stripe subscription - Writes
subscription_cancel_at_period_end: trueto DB immediately - User keeps access until
subscription_current_period_end - When period ends, Stripe sends
customer.subscription.deletedwebhook - 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:
- User clicks "Reactivate" in the
SubscriptionStatusBanner - Endpoint verifies
subscription_cancel_at_period_end === true - Sets
cancel_at_period_end: falseon Stripe subscription - Writes
subscription_cancel_at_period_end: falseto 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 Event | Stripe Action | Function |
|---|---|---|
| Delete last podcast | cancel_at_period_end: true | cancelSubscriptionAtPeriodEnd() |
| Create podcast while canceling | cancel_at_period_end: false | uncancelSubscription() |
| Pause last active podcast | pause_collection: { behavior: 'void' } | pauseSubscriptionBilling() |
| Unpause any podcast | Clear pause_collection | resumeSubscriptionBilling() |
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.
// 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:
- Stripe first: Update the subscription in Stripe (source of truth for billing)
- Immediate DB write: Write the key field to
user_profilesimmediately (UI reflects change instantly) - 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:
<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:
| State | Banner Color | Message | Action |
|---|---|---|---|
cancel_at_period_end + active | Amber | "Ends on {date}" | Reactivate button |
past_due | Red | "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:
| Column | Type | Default | Purpose |
|---|---|---|---|
stripe_subscription_id | TEXT | NULL | Stripe subscription ID for API operations |
subscription_cancel_at_period_end | BOOLEAN | false | Whether subscription is set to cancel |
subscription_current_period_end | TIMESTAMPTZ | NULL | Billing period end date |
billing_model | TEXT | 'subscription' | Billing type guard |
ltd_expires_at | TIMESTAMPTZ | NULL | LTD expiration (NULL = perpetual) |
See Stripe Integration - Database Schema for the full schema.
Test Coverage
| Test File | Coverage |
|---|---|
src/api/routes/stripe/__tests__/change-plan.test.ts | Plan change endpoint: upgrades, downgrades, cancellation, reactivation, podcast limits, card decline, billing model guard |
src/api/utils/__tests__/stripe-sync.test.ts | Subscription resolution, billing sync functions, skip logic |
src/lib/utils/__tests__/subscription.test.ts | Tier limits, slot counting, paid tier detection |
src/api/routes/__tests__/subscription-flow.integration.test.ts | End-to-end checkout -> webhook -> profile update |
Related Documentation
- Subscription Flow - Initial checkout and webhook processing
- Stripe API Routes - All endpoint specs
- Stripe Integration - Full service documentation