DEV Community

Atlas Whoff
Atlas Whoff

Posted on

Stripe Subscription Lifecycle in Next.js: Trials, Cancellations, Past Due, and Access Control

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
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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)
}
Enter fullscreen mode Exit fullscreen mode

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)