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:
- Subscriptions are stateful - Unlike one-time payments, subscriptions have lifecycle: trialing ā active ā past_due ā canceled
- Always handle webhooks - Never trust client-side subscription status, rely on webhooks
- Test payment failures - Use Stripe test cards to simulate failed recurring charges
- Understand prorations - When users upgrade mid-cycle, Stripe calculates prorated charges automatically
- 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:
- What pricing tiers do I want to offer? (Free, Starter, Pro, Enterprise?)
- Monthly, annual, or both? (Annual typically gets 20% discount)
- Do I want free trials? If so, how many days? (14 days is standard)
- Should I require payment method upfront or only after trial? (Upfront reduces trial abuse)
- 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
- Payment Flow - One-time payments, checkout basics
- Payment Webhooks - Advanced webhook patterns, retry logic
- Invoice Generation - PDF invoices, tax compliance
- User Authentication - Account management integration
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.