The Subscription Lifecycle Most Developers Get Wrong
Stripe subscriptions aren't just "create and forget."
Users cancel, upgrade, downgrade, fail to pay, reactivate.
Each state transition requires a webhook handler and a database update.
Here's the complete lifecycle implementation.
The States
active -- paying, all good
trialing -- on a free trial
past_due -- payment failed, retrying
canceled -- explicitly cancelled (may still have access until period end)
unpaid -- all retries exhausted
paused -- user paused (Stripe Customer Portal feature)
incomplete -- initial payment failed
Prisma Schema
model Subscription {
id String @id @default(cuid())
userId String @unique
user User @relation(fields: [userId], references: [id])
stripeSubscriptionId String? @unique
stripeCustomerId String? @unique
stripePriceId String?
plan String @default("free")
status String @default("active") // Stripe subscription status
currentPeriodStart DateTime?
currentPeriodEnd DateTime?
cancelAtPeriodEnd Boolean @default(false)
trialEnd DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
The Webhook Handler
// app/api/webhooks/stripe/route.ts
import Stripe from 'stripe'
import { db } from '@/lib/db'
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!)
export async function POST(req: Request) {
const body = await req.text()
const sig = req.headers.get('stripe-signature')!
let event: Stripe.Event
try {
event = stripe.webhooks.constructEvent(body, sig, process.env.STRIPE_WEBHOOK_SECRET!)
} catch {
return new Response('Invalid signature', { status: 400 })
}
const subscription = event.data.object as Stripe.Subscription
switch (event.type) {
case 'customer.subscription.created':
case 'customer.subscription.updated':
await syncSubscription(subscription)
break
case 'customer.subscription.deleted':
await handleCancellation(subscription)
break
case 'invoice.payment_failed':
await handlePaymentFailed(event.data.object as Stripe.Invoice)
break
case 'invoice.payment_succeeded':
await handlePaymentSucceeded(event.data.object as Stripe.Invoice)
break
}
return new Response('ok')
}
async function syncSubscription(sub: Stripe.Subscription) {
const plan = sub.items.data[0].price.nickname ?? 'pro'
await db.subscription.upsert({
where: { stripeSubscriptionId: sub.id },
create: {
stripeSubscriptionId: sub.id,
stripeCustomerId: sub.customer as string,
stripePriceId: sub.items.data[0].price.id,
plan,
status: sub.status,
currentPeriodStart: new Date(sub.current_period_start * 1000),
currentPeriodEnd: new Date(sub.current_period_end * 1000),
cancelAtPeriodEnd: sub.cancel_at_period_end,
trialEnd: sub.trial_end ? new Date(sub.trial_end * 1000) : null,
userId: await getUserIdFromCustomer(sub.customer as string),
},
update: {
plan,
status: sub.status,
currentPeriodStart: new Date(sub.current_period_start * 1000),
currentPeriodEnd: new Date(sub.current_period_end * 1000),
cancelAtPeriodEnd: sub.cancel_at_period_end,
}
})
}
async function handleCancellation(sub: Stripe.Subscription) {
await db.subscription.update({
where: { stripeSubscriptionId: sub.id },
data: { status: 'canceled', plan: 'free' }
})
// Optionally: send cancellation email, trigger offboarding
}
Checking Access in Middleware
export async function hasActiveSubscription(userId: string): Promise<boolean> {
const sub = await db.subscription.findUnique({ where: { userId } })
if (!sub) return false
const activeStatuses = ['active', 'trialing']
// past_due: still allow access during grace period
if (sub.status === 'past_due') {
// Give 7-day grace period
const gracePeriodEnd = new Date(sub.currentPeriodEnd!)
gracePeriodEnd.setDate(gracePeriodEnd.getDate() + 7)
return new Date() < gracePeriodEnd
}
// canceled but still in paid period
if (sub.status === 'canceled' && sub.cancelAtPeriodEnd) {
return sub.currentPeriodEnd! > new Date()
}
return activeStatuses.includes(sub.status)
}
This Is All Pre-Wired in the AI SaaS Starter Kit
Complete Stripe subscription lifecycle: webhook handlers for all events, subscription syncing, access checking, Customer Portal integration.
$99 one-time at whoffagents.com
Top comments (0)