All Features / Stripe Subscription Management
6-8 hours
advanced

Stripe Subscription Management

Recurring billing + plan changes + prorations + cancellations

paymentssaassubscriptions

Paste this into Claude Code to start implementing

šŸ”§Works With

This spec is compatible with:

The implementation prompt includes guidance for these tech stacks.

Overview

Complete subscription billing system with Stripe: plan selection, upgrades/downgrades with prorations, usage-based billing, trial periods, payment retries, and cancellation flows. Handles all subscription lifecycle events via webhooks.

Use Cases

  • SaaS products: Monthly/annual plans with feature tiers
  • Membership sites: Recurring access to content, community, courses
  • Service platforms: Monthly retainers for ongoing services
  • Usage-based billing: API credits, SMS messages, storage limits

When to Use This Pattern

Use this pattern when you need to:

  • Charge customers on a recurring basis (monthly, annual, custom intervals)
  • Offer multiple pricing tiers with different features
  • Handle plan upgrades and downgrades with fair billing
  • Implement free trials with automatic conversion to paid
  • Track usage for metered billing (API calls, seats, storage)
  • Manage failed payments with retry logic and dunning

Pro Tips

Critical concepts for subscription billing:

  1. Subscriptions are stateful - Unlike one-time payments, subscriptions have lifecycle: trialing → active → past_due → canceled
  2. Always handle webhooks - Never trust client-side subscription status, rely on webhooks
  3. Test payment failures - Use Stripe test cards to simulate failed recurring charges
  4. Understand prorations - When users upgrade mid-cycle, Stripe calculates prorated charges automatically
  5. Implement grace periods - Don't immediately revoke access on payment failure; retry 3-4 times first

Implementation Phases

Phase 1: Stripe Products and Prices Setup

Create products in Stripe Dashboard (or via API):

// Create product
const product = await stripe.products.create({
  name: 'Pro Plan',
  description: 'Full access to all features',
})

// Create monthly price
const monthlyPrice = await stripe.prices.create({
  product: product.id,
  unit_amount: 2900, // $29.00
  currency: 'usd',
  recurring: {
    interval: 'month',
  },
})

// Create annual price (20% discount)
const annualPrice = await stripe.prices.create({
  product: product.id,
  unit_amount: 27900, // $279.00 ($23.25/month)
  currency: 'usd',
  recurring: {
    interval: 'year',
  },
})

Recommended tier structure:

  • Free: $0 (no Stripe subscription needed)
  • Starter: $19/month
  • Pro: $49/month
  • Enterprise: Custom pricing (handle separately)

Phase 2: Database Schema

Track subscriptions and customer data:

model User {
  id                 String   @id @default(uuid())
  email              String   @unique
  stripeCustomerId   String?  @unique
  subscriptionId     String?  @unique
  subscriptionStatus String?  // active, trialing, past_due, canceled, incomplete
  planId             String?  // References Stripe price ID
  currentPeriodEnd   DateTime?
  cancelAtPeriodEnd  Boolean  @default(false)
  trialEndsAt        DateTime?
  createdAt          DateTime @default(now())
  updatedAt          DateTime @updatedAt

  subscription Subscription?
}

model Subscription {
  id                    String   @id @default(uuid())
  userId                String   @unique
  stripeSubscriptionId  String   @unique
  stripePriceId         String
  status                String   // active, trialing, past_due, canceled, incomplete, incomplete_expired
  currentPeriodStart    DateTime
  currentPeriodEnd      DateTime
  cancelAtPeriodEnd     Boolean  @default(false)
  canceledAt            DateTime?
  trialStart            DateTime?
  trialEnd              DateTime?
  metadata              Json?
  createdAt             DateTime @default(now())
  updatedAt             DateTime @updatedAt

  user User @relation(fields: [userId], references: [id], onDelete: Cascade)
}

Why separate Subscription model:

  • Audit trail of all subscription changes
  • Easier to query subscription-specific data
  • Can store multiple subscriptions per user (future: add-ons)

Phase 3: Create Subscription Flow

Server-side API endpoint (app/api/create-subscription/route.ts):

import Stripe from 'stripe'
import { NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
  apiVersion: '2024-11-20.acacia',
})

export async function POST(request: Request) {
  const session = await getServerSession()
  if (!session?.user?.email) {
    return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
  }

  try {
    const { priceId, trialDays = 0 } = await request.json()

    const user = await prisma.user.findUnique({
      where: { email: session.user.email },
    })

    if (!user) {
      return NextResponse.json({ error: 'User not found' }, { status: 404 })
    }

    // Create Stripe customer if doesn't exist
    let customerId = user.stripeCustomerId

    if (!customerId) {
      const customer = await stripe.customers.create({
        email: user.email,
        metadata: { userId: user.id },
      })

      customerId = customer.id

      await prisma.user.update({
        where: { id: user.id },
        data: { stripeCustomerId: customerId },
      })
    }

    // Create subscription
    const subscription = await stripe.subscriptions.create({
      customer: customerId,
      items: [{ price: priceId }],
      payment_behavior: 'default_incomplete',
      payment_settings: {
        save_default_payment_method: 'on_subscription',
      },
      expand: ['latest_invoice.payment_intent'],
      ...(trialDays > 0 && {
        trial_period_days: trialDays,
      }),
    })

    // Save subscription to database
    await prisma.subscription.create({
      data: {
        userId: user.id,
        stripeSubscriptionId: subscription.id,
        stripePriceId: priceId,
        status: subscription.status,
        currentPeriodStart: new Date(subscription.current_period_start * 1000),
        currentPeriodEnd: new Date(subscription.current_period_end * 1000),
        ...(subscription.trial_start && {
          trialStart: new Date(subscription.trial_start * 1000),
        }),
        ...(subscription.trial_end && {
          trialEnd: new Date(subscription.trial_end * 1000),
        }),
      },
    })

    const invoice = subscription.latest_invoice as Stripe.Invoice
    const paymentIntent = invoice.payment_intent as Stripe.PaymentIntent

    return NextResponse.json({
      subscriptionId: subscription.id,
      clientSecret: paymentIntent?.client_secret,
    })
  } catch (error) {
    console.error('Subscription creation failed:', error)
    return NextResponse.json(
      { error: 'Failed to create subscription' },
      { status: 500 }
    )
  }
}

Phase 4: Client-Side Subscription Form

Pricing page with plan selection:

'use client'

import { useState } from 'react'
import { loadStripe } from '@stripe/stripe-js'
import { Elements, PaymentElement, useStripe, useElements } from '@stripe/react-stripe-js'

const stripePromise = loadStripe(process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY!)

const PLANS = [
  {
    id: 'starter',
    name: 'Starter',
    priceId: 'price_starter_monthly',
    price: 19,
    features: ['10 projects', 'Basic support', '5GB storage'],
  },
  {
    id: 'pro',
    name: 'Pro',
    priceId: 'price_pro_monthly',
    price: 49,
    features: ['Unlimited projects', 'Priority support', '100GB storage', 'Advanced analytics'],
  },
]

function SubscriptionForm({ priceId }: { priceId: string }) {
  const stripe = useStripe()
  const elements = useElements()
  const [loading, setLoading] = useState(false)
  const [error, setError] = useState<string | null>(null)

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault()

    if (!stripe || !elements) return

    setLoading(true)
    setError(null)

    const { error: submitError } = await stripe.confirmPayment({
      elements,
      confirmParams: {
        return_url: `${window.location.origin}/subscription/success`,
      },
    })

    if (submitError) {
      setError(submitError.message || 'Payment failed')
      setLoading(false)
    }
  }

  return (
    <form onSubmit={handleSubmit} className="space-y-4">
      <PaymentElement />

      {error && (
        <div className="text-red-600 text-sm bg-red-50 p-3 rounded">
          {error}
        </div>
      )}

      <button
        type="submit"
        disabled={!stripe || loading}
        className="w-full bg-blue-600 hover:bg-blue-700 text-white py-3 rounded-lg font-semibold disabled:opacity-50"
      >
        {loading ? 'Processing...' : 'Subscribe'}
      </button>
    </form>
  )
}

export default function PricingPage() {
  const [selectedPlan, setSelectedPlan] = useState<string | null>(null)
  const [clientSecret, setClientSecret] = useState<string | null>(null)

  const handleSelectPlan = async (priceId: string) => {
    setSelectedPlan(priceId)

    // Create subscription
    const res = await fetch('/api/create-subscription', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ priceId, trialDays: 14 }),
    })

    const data = await res.json()
    setClientSecret(data.clientSecret)
  }

  if (clientSecret && selectedPlan) {
    return (
      <div className="max-w-md mx-auto p-6">
        <h2 className="text-2xl font-bold mb-6">Complete your subscription</h2>
        <Elements stripe={stripePromise} options={{ clientSecret }}>
          <SubscriptionForm priceId={selectedPlan} />
        </Elements>
      </div>
    )
  }

  return (
    <div className="max-w-6xl mx-auto p-6">
      <h1 className="text-4xl font-bold text-center mb-12">Choose your plan</h1>

      <div className="grid md:grid-cols-2 gap-8">
        {PLANS.map(plan => (
          <div key={plan.id} className="border rounded-lg p-8">
            <h3 className="text-2xl font-bold mb-2">{plan.name}</h3>
            <div className="text-4xl font-bold mb-6">
              ${plan.price}<span className="text-lg text-gray-600">/month</span>
            </div>

            <ul className="space-y-3 mb-8">
              {plan.features.map(feature => (
                <li key={feature} className="flex items-center gap-2">
                  <span className="text-green-600">āœ“</span>
                  {feature}
                </li>
              ))}
            </ul>

            <button
              onClick={() => handleSelectPlan(plan.priceId)}
              className="w-full bg-blue-600 hover:bg-blue-700 text-white py-3 rounded-lg font-semibold"
            >
              Start 14-day trial
            </button>
          </div>
        ))}
      </div>
    </div>
  )
}

Phase 5: Webhook Handler for Subscription Events

Handle all subscription lifecycle events:

import Stripe from 'stripe'
import { NextResponse } from 'next/server'
import { headers } from 'next/headers'

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
  apiVersion: '2024-11-20.acacia',
})

export async function POST(request: Request) {
  const body = await request.text()
  const signature = headers().get('stripe-signature')!

  let event: Stripe.Event

  try {
    event = stripe.webhooks.constructEvent(
      body,
      signature,
      process.env.STRIPE_WEBHOOK_SECRET!
    )
  } catch (err) {
    return NextResponse.json({ error: 'Invalid signature' }, { status: 400 })
  }

  const subscription = event.data.object as Stripe.Subscription

  switch (event.type) {
    case 'customer.subscription.created':
    case 'customer.subscription.updated':
      await prisma.subscription.upsert({
        where: { stripeSubscriptionId: subscription.id },
        update: {
          status: subscription.status,
          stripePriceId: subscription.items.data[0].price.id,
          currentPeriodStart: new Date(subscription.current_period_start * 1000),
          currentPeriodEnd: new Date(subscription.current_period_end * 1000),
          cancelAtPeriodEnd: subscription.cancel_at_period_end,
          ...(subscription.canceled_at && {
            canceledAt: new Date(subscription.canceled_at * 1000),
          }),
        },
        create: {
          stripeSubscriptionId: subscription.id,
          userId: subscription.metadata.userId,
          status: subscription.status,
          stripePriceId: subscription.items.data[0].price.id,
          currentPeriodStart: new Date(subscription.current_period_start * 1000),
          currentPeriodEnd: new Date(subscription.current_period_end * 1000),
        },
      })
      break

    case 'customer.subscription.deleted':
      await prisma.subscription.update({
        where: { stripeSubscriptionId: subscription.id },
        data: {
          status: 'canceled',
          canceledAt: new Date(),
        },
      })

      // Revoke user access
      await revokeUserAccess(subscription.metadata.userId)
      break

    case 'invoice.payment_succeeded':
      // Send receipt email
      const invoice = event.data.object as Stripe.Invoice
      await sendSubscriptionReceiptEmail(invoice)
      break

    case 'invoice.payment_failed':
      // Send payment failed email
      const failedInvoice = event.data.object as Stripe.Invoice
      await sendPaymentFailedEmail(failedInvoice)

      // Update subscription status
      await prisma.subscription.update({
        where: { stripeSubscriptionId: failedInvoice.subscription as string },
        data: { status: 'past_due' },
      })
      break
  }

  return NextResponse.json({ received: true })
}

Phase 6: Plan Change and Cancellation

Upgrade/downgrade subscription:

// app/api/change-subscription/route.ts
export async function POST(request: Request) {
  const session = await getServerSession()
  if (!session?.user?.email) {
    return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
  }

  const { newPriceId } = await request.json()

  const user = await prisma.user.findUnique({
    where: { email: session.user.email },
    include: { subscription: true },
  })

  if (!user?.subscription) {
    return NextResponse.json({ error: 'No subscription found' }, { status: 404 })
  }

  // Update subscription with prorations
  const updatedSubscription = await stripe.subscriptions.update(
    user.subscription.stripeSubscriptionId,
    {
      items: [
        {
          id: (await stripe.subscriptions.retrieve(
            user.subscription.stripeSubscriptionId
          )).items.data[0].id,
          price: newPriceId,
        },
      ],
      proration_behavior: 'create_prorations', // Fair billing
    }
  )

  // Database update will happen via webhook

  return NextResponse.json({ success: true })
}

// app/api/cancel-subscription/route.ts
export async function POST(request: Request) {
  const session = await getServerSession()
  if (!session?.user?.email) {
    return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
  }

  const { immediate = false } = await request.json()

  const user = await prisma.user.findUnique({
    where: { email: session.user.email },
    include: { subscription: true },
  })

  if (!user?.subscription) {
    return NextResponse.json({ error: 'No subscription found' }, { status: 404 })
  }

  if (immediate) {
    // Cancel immediately (revoke access now)
    await stripe.subscriptions.cancel(user.subscription.stripeSubscriptionId)
  } else {
    // Cancel at period end (access until end of billing cycle)
    await stripe.subscriptions.update(user.subscription.stripeSubscriptionId, {
      cancel_at_period_end: true,
    })
  }

  return NextResponse.json({ success: true })
}

Edge Cases to Handle

Critical Edge Cases

Payment failures during renewal:

  • Card declined, expired, or insufficient funds
  • Stripe automatically retries 3-4 times over 2 weeks
  • Status changes: active → past_due → unpaid → canceled
  • Send dunning emails: "Payment failed, please update card"
  • Implement grace period (7 days) before revoking access

Proration edge cases:

  • User upgrades from $19/month to $49/month mid-cycle
  • Stripe charges prorated difference immediately
  • User downgrades from $49/month to $19/month
  • Credit applied to next invoice (no immediate refund)
  • Show preview of proration before confirming change

Trial period abuse:

  • User creates multiple accounts to extend free trial
  • Solution: Require payment method upfront (no charge until trial ends)
  • Block disposable email addresses
  • Limit trials by IP address or payment method fingerprint

Subscription status race conditions:

  • User cancels subscription but webhook hasn't processed yet
  • UI still shows "Active" status
  • Solution: Show "Cancellation pending" state
  • Poll webhook delivery status
  • Implement eventual consistency in UI

Multiple subscriptions per user:

  • User accidentally creates second subscription
  • Prevent in UI: Check for existing active subscription
  • Handle in API: Return error if subscription exists
  • Offer subscription swap instead of duplicate

Coupon and discount handling:

  • Apply coupon at subscription creation: coupon: 'SAVE20'
  • Stripe handles percentage or fixed amount discounts
  • Track coupon usage in metadata
  • Set duration: once, forever, repeating (3 months, etc.)

Tax and compliance:

  • Stripe Tax automatically calculates VAT, GST, sales tax
  • Enable in Dashboard → Settings → Tax
  • Adds tax line items to invoices automatically
  • Requires business address and tax registration numbers

Tech Stack Recommendations

Minimum Viable Stack

  • Framework: Next.js with API routes
  • Payment Provider: Stripe (only option for subscriptions)
  • Database: PostgreSQL or MySQL (track subscription state)
  • Auth: NextAuth.js or Clerk
  • Email: Resend for billing emails

Production-Grade Stack

  • Framework: Next.js 15 with App Router
  • Subscriptions: Stripe Billing + Stripe Tax
  • Database: Prisma + PostgreSQL
  • Auth: Clerk (built-in Stripe integration) or NextAuth.js
  • Email: Resend + React Email for templates
  • Queue: Inngest for async webhook processing
  • Analytics: Stripe Dashboard + custom metrics dashboard

Full Implementation Prompt

Copy this prompt to use with Claude Code:


I need to implement a complete subscription billing system with Stripe. Before we start, help me think through the architecture and business logic.

First, let's review my setup:

  1. What pricing tiers do I want to offer? (Free, Starter, Pro, Enterprise?)
  2. Monthly, annual, or both? (Annual typically gets 20% discount)
  3. Do I want free trials? If so, how many days? (14 days is standard)
  4. Should I require payment method upfront or only after trial? (Upfront reduces trial abuse)
  5. What features should each tier unlock? (Show me current feature flags)

Then let's discuss these critical subscription flows:

  • How should upgrades work? (Immediate access + prorated charge)
  • How should downgrades work? (Take effect at period end or immediately?)
  • What happens when payment fails? (Grace period? How many retry attempts?)
  • Should users be able to reactivate canceled subscriptions? (Keep data for 30 days?)
  • How do I handle refunds? (Prorated refunds? No refunds policy?)

Then we'll implement in phases: Phase 1: Create Stripe products and prices in Dashboard Phase 2: Database schema for tracking subscriptions Phase 3: Subscription creation API endpoint Phase 4: Pricing page with plan selection Phase 5: Webhook handler for all subscription events Phase 6: Account management page (change plan, cancel, reactivate) Phase 7: Billing portal integration (let Stripe handle payment methods)

After implementation, let's test:

  • Successful subscription creation with trial
  • Payment method collection
  • Trial expiration and conversion to paid
  • Plan upgrade with prorations
  • Plan downgrade
  • Failed payment and retry logic
  • Cancellation (both immediate and at period end)
  • Webhook delivery and database sync

Sound good? Let's start by defining your pricing tiers and trial strategy.


Related Feature Specs

Success Criteria

You've successfully implemented this when:

āœ… Users can subscribe to monthly or annual plans āœ… Free trials work with automatic conversion to paid āœ… Plan upgrades and downgrades handle prorations correctly āœ… Failed payments trigger retry logic and dunning emails āœ… Webhooks keep database in sync with Stripe āœ… Cancellations work (both immediate and at period end) āœ… Users can reactivate canceled subscriptions āœ… Billing portal allows users to manage payment methods āœ… All subscription states handled: trialing, active, past_due, canceled

Common Mistakes to Avoid

āŒ Not handling webhook events (database out of sync) āŒ Immediately revoking access on payment failure (no grace period) āŒ Not showing proration preview before plan changes āŒ Allowing multiple active subscriptions per user āŒ Not testing payment failure scenarios āŒ Missing invoice.payment_failed webhook handler āŒ Not implementing dunning emails āŒ Forgetting to add subscription metadata (userId)

Implementation Checklist

Setup:

  • Create Stripe products for each plan
  • Create prices (monthly + annual) for each product
  • Store price IDs in environment variables or database
  • Enable Stripe Tax if needed
  • Set up customer portal in Stripe Dashboard

Database:

  • Create Subscription model with all fields
  • Link to User model via foreign key
  • Add indexes on stripeSubscriptionId, userId
  • Add subscription status enum

Backend:

  • Create subscription API endpoint
  • Add Stripe customer creation logic
  • Handle trial period configuration
  • Create plan change endpoint
  • Create cancellation endpoint
  • Add reactivation endpoint

Frontend:

  • Design pricing page with plan cards
  • Add trial callout (e.g., "Start 14-day free trial")
  • Build subscription checkout form
  • Create account/billing page
  • Show current plan and renewal date
  • Add "Change Plan" and "Cancel" buttons
  • Integrate Stripe Customer Portal

Webhooks:

  • Handle customer.subscription.created
  • Handle customer.subscription.updated
  • Handle customer.subscription.deleted
  • Handle invoice.payment_succeeded
  • Handle invoice.payment_failed
  • Handle customer.subscription.trial_will_end (3 days before)
  • Test webhook delivery with Stripe CLI

Email:

  • Trial started email
  • Trial ending soon email (3 days before)
  • Subscription activated email
  • Payment receipt email
  • Payment failed email
  • Subscription canceled email

Access Control:

  • Implement feature flags per plan
  • Check subscription status in middleware
  • Redirect to pricing page if no active subscription
  • Show upgrade prompts for premium features

Testing:

  • Test subscription creation with trial
  • Test immediate subscription (no trial)
  • Test plan upgrade (check prorations)
  • Test plan downgrade
  • Test payment failure with test card: 4000000000000341
  • Test cancellation (at period end)
  • Test immediate cancellation
  • Test reactivation
  • Test webhook delivery and retries

Last Updated: 2025-12-04 Difficulty: Advanced Estimated Time: 6-8 hours Prerequisites: Payment Flow spec, webhook handling, database relationships

Need help with subscription strategy? Book a consultation for pricing optimization and implementation guidance.

Need Implementation Help?

Get expert guidance on architecture, security, and best practices.

Book a Consultation
Stripe Subscription Management | Claude Code Implementation Guide | HashBuilds