Claude Code for Stripe Subscriptions: Recurring Billing Patterns — Claude Skills 360 Blog
Blog / Backend / Claude Code for Stripe Subscriptions: Recurring Billing Patterns
Backend

Claude Code for Stripe Subscriptions: Recurring Billing Patterns

Published: May 15, 2027
Read time: 7 min read
By: Claude Skills 360

Stripe recurring billing uses Subscriptions and Prices — stripe.subscriptions.create({ customer, items: [{ price: priceId }] }) creates a subscription. stripe.billingPortal.sessions.create({ customer, return_url }) generates a self-serve customer portal link for upgrades, downgrades, and cancellation. trial_period_days adds a free trial. proration_behavior: "create_prorations" calculates credits and charges when changing plans. subscription.cancel_at_period_end = true cancels gracefully at the billing cycle end. The customer.subscription.updated webhook handles plan changes. customer.subscription.deleted revokes access. invoice.payment_succeeded confirms successful payments. invoice.payment_failed triggers dunning. Metered billing: stripe.subscriptionItems.createUsageRecord(siId, { quantity, timestamp }) reports usage. Subscription items let one subscription have multiple prices (e.g., base + per-seat). Claude Code generates Stripe SaaS billing, trial flows, customer portals, and plan upgrade/downgrade logic.

CLAUDE.md for Stripe Subscriptions

## Stripe Subscriptions Stack
- Version: stripe >= 16.2
- Customer: const customer = await stripe.customers.create({ email, metadata: { userId } })
- Subscribe: const sub = await stripe.subscriptions.create({ customer: customerId, items: [{ price: priceId }], payment_behavior: "default_incomplete", expand: ["latest_invoice.payment_intent"] })
- Portal: const session = await stripe.billingPortal.sessions.create({ customer: customerId, return_url: "${APP_URL}/settings/billing" })
- Cancel: await stripe.subscriptions.update(subId, { cancel_at_period_end: true })
- Webhook: constructEventAsync(body, sig, secret) → handle "customer.subscription.updated" | ".deleted" | "invoice.payment_succeeded"

Subscription Service

// lib/billing/stripe.ts — Stripe subscription helpers
import Stripe from "stripe"
import { db } from "@/lib/db"
import { users, subscriptions } from "@/lib/db/schema"
import { eq } from "drizzle-orm"

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

export type PlanId = "free" | "pro" | "enterprise"

// Price ID mapping — store in env or DB
const PRICE_IDS: Record<Exclude<PlanId, "free">, { monthly: string; yearly: string }> = {
  pro: {
    monthly: process.env.STRIPE_PRO_MONTHLY_PRICE!,
    yearly: process.env.STRIPE_PRO_YEARLY_PRICE!,
  },
  enterprise: {
    monthly: process.env.STRIPE_ENTERPRISE_MONTHLY_PRICE!,
    yearly: process.env.STRIPE_ENTERPRISE_YEARLY_PRICE!,
  },
}

// Get or create Stripe customer for a user
export async function getOrCreateStripeCustomer(userId: string): Promise<string> {
  const user = await db.query.users.findFirst({
    where: eq(users.id, userId),
    columns: { id: true, email: true, name: true, stripeCustomerId: true },
  })

  if (!user) throw new Error("User not found")
  if (user.stripeCustomerId) return user.stripeCustomerId

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

  await db.update(users)
    .set({ stripeCustomerId: customer.id })
    .where(eq(users.id, userId))

  return customer.id
}

// Create checkout for new subscription
export async function createSubscriptionCheckout(params: {
  userId: string
  plan: Exclude<PlanId, "free">
  interval: "monthly" | "yearly"
  trialDays?: number
  successUrl: string
  cancelUrl: string
}): Promise<string> {
  const customerId = await getOrCreateStripeCustomer(params.userId)
  const priceId = PRICE_IDS[params.plan][params.interval]

  const session = await stripe.checkout.sessions.create({
    customer: customerId,
    mode: "subscription",
    payment_method_types: ["card"],
    line_items: [{ price: priceId, quantity: 1 }],
    success_url: params.successUrl,
    cancel_url: params.cancelUrl,
    allow_promotion_codes: true,
    subscription_data: {
      trial_period_days: params.trialDays,
      metadata: { userId: params.userId, plan: params.plan },
    },
    metadata: { userId: params.userId },
  })

  return session.url!
}

// Create billing portal session
export async function createPortalSession(userId: string, returnUrl: string): Promise<string> {
  const customerId = await getOrCreateStripeCustomer(userId)

  const session = await stripe.billingPortal.sessions.create({
    customer: customerId,
    return_url: returnUrl,
  })

  return session.url
}

// Upgrade/downgrade subscription plan
export async function changeSubscriptionPlan(
  subscriptionId: string,
  newPlan: Exclude<PlanId, "free">,
  interval: "monthly" | "yearly",
): Promise<void> {
  const subscription = await stripe.subscriptions.retrieve(subscriptionId)
  const currentItemId = subscription.items.data[0].id
  const newPriceId = PRICE_IDS[newPlan][interval]

  await stripe.subscriptions.update(subscriptionId, {
    items: [{ id: currentItemId, price: newPriceId }],
    proration_behavior: "create_prorations",
  })
}

// Cancel subscription at period end
export async function cancelSubscription(subscriptionId: string, immediate = false): Promise<void> {
  if (immediate) {
    await stripe.subscriptions.cancel(subscriptionId)
  } else {
    await stripe.subscriptions.update(subscriptionId, {
      cancel_at_period_end: true,
    })
  }
}

// Reactivate a subscription that was set to cancel
export async function reactivateSubscription(subscriptionId: string): Promise<void> {
  await stripe.subscriptions.update(subscriptionId, {
    cancel_at_period_end: false,
  })
}

Webhook Handler

// app/api/webhooks/stripe/route.ts — subscription lifecycle webhooks
import { NextRequest, NextResponse } from "next/server"
import Stripe from "stripe"
import { db } from "@/lib/db"
import { users, subscriptions } from "@/lib/db/schema"
import { eq } from "drizzle-orm"

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

const PLAN_BY_PRICE: Record<string, "pro" | "enterprise"> = {
  [process.env.STRIPE_PRO_MONTHLY_PRICE!]: "pro",
  [process.env.STRIPE_PRO_YEARLY_PRICE!]: "pro",
  [process.env.STRIPE_ENTERPRISE_MONTHLY_PRICE!]: "enterprise",
  [process.env.STRIPE_ENTERPRISE_YEARLY_PRICE!]: "enterprise",
}

async function upsertSubscription(stripeSubscription: Stripe.Subscription) {
  const customerId = typeof stripeSubscription.customer === "string"
    ? stripeSubscription.customer
    : stripeSubscription.customer.id

  const user = await db.query.users.findFirst({
    where: eq(users.stripeCustomerId, customerId),
  })

  if (!user) {
    console.error("[Stripe webhook] No user for customer", customerId)
    return
  }

  const priceId = stripeSubscription.items.data[0]?.price.id
  const plan = PLAN_BY_PRICE[priceId] ?? "free"

  await db.insert(subscriptions).values({
    userId: user.id,
    stripeSubscriptionId: stripeSubscription.id,
    stripeCustomerId: customerId,
    status: stripeSubscription.status,
    plan,
    currentPeriodStart: new Date(stripeSubscription.current_period_start * 1000),
    currentPeriodEnd: new Date(stripeSubscription.current_period_end * 1000),
    cancelAtPeriodEnd: stripeSubscription.cancel_at_period_end,
    canceledAt: stripeSubscription.canceled_at ? new Date(stripeSubscription.canceled_at * 1000) : null,
    trialEnd: stripeSubscription.trial_end ? new Date(stripeSubscription.trial_end * 1000) : null,
  }).onConflictDoUpdate({
    target: subscriptions.stripeSubscriptionId,
    set: {
      status: stripeSubscription.status,
      plan,
      currentPeriodStart: new Date(stripeSubscription.current_period_start * 1000),
      currentPeriodEnd: new Date(stripeSubscription.current_period_end * 1000),
      cancelAtPeriodEnd: stripeSubscription.cancel_at_period_end,
      canceledAt: stripeSubscription.canceled_at ? new Date(stripeSubscription.canceled_at * 1000) : null,
      trialEnd: stripeSubscription.trial_end ? new Date(stripeSubscription.trial_end * 1000) : null,
    },
  })

  // Update user's plan
  const activePlans: Stripe.Subscription.Status[] = ["active", "trialing", "past_due"]
  await db.update(users)
    .set({ plan: activePlans.includes(stripeSubscription.status) ? plan : "free" })
    .where(eq(users.id, user.id))
}

export async function POST(request: NextRequest) {
  const body = await request.text()
  const sig = request.headers.get("stripe-signature")

  if (!sig) return NextResponse.json({ error: "No signature" }, { status: 400 })

  let event: Stripe.Event
  try {
    event = await stripe.webhooks.constructEventAsync(body, sig, process.env.STRIPE_WEBHOOK_SECRET!)
  } catch (err) {
    return NextResponse.json({ error: "Invalid signature" }, { status: 400 })
  }

  // Idempotency — skip if already processed
  const processed = await db.query.stripeEvents.findFirst({
    where: eq(stripeEvents.eventId, event.id),
  })
  if (processed) return NextResponse.json({ ok: true })

  try {
    switch (event.type) {
      case "customer.subscription.created":
      case "customer.subscription.updated":
        await upsertSubscription(event.data.object as Stripe.Subscription)
        break

      case "customer.subscription.deleted":
        await upsertSubscription(event.data.object as Stripe.Subscription)
        break

      case "invoice.payment_succeeded": {
        const invoice = event.data.object as Stripe.Invoice
        if (invoice.billing_reason === "subscription_create") {
          // Welcome email, grant access, etc.
          console.log("[Stripe] New subscriber:", invoice.customer)
        }
        break
      }

      case "invoice.payment_failed": {
        const invoice = event.data.object as Stripe.Invoice
        // Dunning — send email, downgrade after N failures
        console.warn("[Stripe] Payment failed:", invoice.customer)
        break
      }
    }

    // Mark as processed
    await db.insert(stripeEvents).values({ eventId: event.id }).onConflictDoNothing()
    return NextResponse.json({ ok: true })
  } catch (err) {
    console.error("[Stripe webhook error]", err)
    return NextResponse.json({ error: "Handler error" }, { status: 500 })
  }
}

Billing Status Hook

// hooks/useBillingStatus.ts — subscription state for UI
"use client"
import useSWR from "swr"

type BillingStatus = {
  plan: "free" | "pro" | "enterprise"
  status: "active" | "trialing" | "past_due" | "canceled" | "none"
  currentPeriodEnd: string | null
  cancelAtPeriodEnd: boolean
  trialEnd: string | null
}

export function useBillingStatus() {
  const { data, isLoading } = useSWR<BillingStatus>("/api/billing/status")

  return {
    plan: data?.plan ?? "free",
    status: data?.status ?? "none",
    isPro: data?.plan === "pro" || data?.plan === "enterprise",
    isTrialing: data?.status === "trialing",
    isPastDue: data?.status === "past_due",
    willCancel: data?.cancelAtPeriodEnd ?? false,
    periodEnd: data?.currentPeriodEnd ? new Date(data.currentPeriodEnd) : null,
    trialEnd: data?.trialEnd ? new Date(data.trialEnd) : null,
    isLoading,
  }
}

For the Paddle alternative when a Merchant of Record model that handles VAT/GST compliance, European taxes, chargebacks, and global payment methods without building tax logic is preferred — Paddle trades some flexibility for handling taxes and compliance automatically, similar to Lemon Squeezy, see the Paddle guide. For the Stripe Checkout with one-time payments alternative when only a single purchase rather than recurring billing is needed — mode: "payment" in Checkout handles one-time charges without subscription management overhead, see the Stripe Checkout guide. The Claude Skills 360 bundle includes Stripe subscription skill sets covering trials, portals, and webhooks. Start with the free tier to try SaaS billing generation.

Keep Reading

Backend

Claude Code for Bun: Fast JavaScript Runtime and Toolkit

Build with Bun and Claude Code — Bun.serve for HTTP servers, Bun.file for fast file I/O, Bun.$ for shell commands, Bun.sql for SQLite and PostgreSQL, Bun.build for bundling, bun:test for testing, Bun.hash for hashing, bun.lock for deterministic installs, bun run for package.json scripts, hot reloading with --hot, bun init for project scaffolding, and compatibility with Node.js modules.

6 min read Jun 13, 2027
Backend

Claude Code for Express.js Advanced: Patterns for Production APIs

Advanced Express.js patterns with Claude Code — typed request handlers with RequestHandler generics, async error handling middleware, Zod validation middleware factory, rate limiting with express-rate-limit and Redis store, helmet security middleware, compression, dependency injection with tsyringe, file upload with multer and S3, pagination utilities, JWT middleware, and structured logging with pino.

6 min read Jun 8, 2027
Backend

Claude Code for KeystoneJS: Node.js CMS and App Framework

Build full-stack apps with KeystoneJS and Claude Code — config with lists, fields.text and fields.relationship for schema definition, access control with isAuthenticated and isAdmin functions, hooks with beforeOperation and afterOperation, GraphQL API auto-generation from schema, AdminUI for content management, session with statelessSessions, Prisma adapter for database, file storage with images and files fields, and custom REST endpoints.

6 min read Jun 7, 2027

Put these ideas into practice

Claude Skills 360 gives you production-ready skills for everything in this article — and 2,350+ more. Start free or go all-in.

Back to Blog

Get 360 skills free