skills/borghei/claude-skills/stripe-integration-expert

stripe-integration-expert

SKILL.md

Stripe Integration Expert

Tier: POWERFUL Category: Engineering Tags: Stripe, payments, subscriptions, billing, webhooks, SCA, usage-based billing

Overview

Build production-grade Stripe integrations for SaaS products: subscriptions with trials and proration, one-time payments, usage-based billing, Checkout sessions, idempotent webhook handlers, Customer Portal, invoicing, and dunning. Covers Next.js App Router, Express, and Django patterns with emphasis on real-world edge cases that documentation does not warn you about.


Subscription Lifecycle State Machine

Understand this before writing any code. Every billing edge case maps to a state transition.

                    ┌────────────────────────────────────────┐
                    │                                        │
 ┌──────────┐   paid    ┌────────┐   cancel    ┌──────────────┐   period_end   ┌──────────┐
 │ TRIALING │──────────▶│ ACTIVE │────────────▶│ CANCEL_PENDING│──────────────▶│ CANCELED │
 └──────────┘           └────────┘             └──────────────┘               └──────────┘
      │                     │                                                      ▲
      │                     │  upgrade                                             │
      │                     ▼                                                  reactivate
      │                ┌──────────┐  period_end  ┌────────┐                        │
      │                │UPGRADING │─────────────▶│ ACTIVE │                        │
      │                └──────────┘  (new plan)  └────────┘                        │
      │                                                                            │
      │  trial_end      ┌──────────┐  3x fail   ┌──────────┐                      │
      └─(no payment)───▶│ PAST_DUE │───────────▶│ CANCELED │──────────────────────┘
                        └──────────┘             └──────────┘
                        payment_success
                        ┌────────┐
                        │ ACTIVE │
                        └────────┘

DB status values: trialing | active | past_due | canceled | cancel_pending | paused | unpaid


Stripe Client Setup

// lib/stripe.ts
import Stripe from "stripe";

if (!process.env.STRIPE_SECRET_KEY) {
  throw new Error("STRIPE_SECRET_KEY is required");
}

export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY, {
  apiVersion: "2024-12-18.acacia",  // Pin to specific version
  typescript: true,
  appInfo: {
    name: "your-app-name",
    version: "1.0.0",
    url: "https://yourapp.com",
  },
});

// Centralized plan configuration
export const PLANS = {
  starter: {
    monthly: process.env.STRIPE_STARTER_MONTHLY_PRICE!,
    yearly: process.env.STRIPE_STARTER_YEARLY_PRICE!,
    limits: { projects: 5, events: 10_000 },
  },
  pro: {
    monthly: process.env.STRIPE_PRO_MONTHLY_PRICE!,
    yearly: process.env.STRIPE_PRO_YEARLY_PRICE!,
    limits: { projects: -1, events: 1_000_000 },  // -1 = unlimited
  },
  enterprise: {
    monthly: process.env.STRIPE_ENTERPRISE_MONTHLY_PRICE!,
    yearly: process.env.STRIPE_ENTERPRISE_YEARLY_PRICE!,
    limits: { projects: -1, events: -1 },
  },
} as const;

export type PlanName = keyof typeof PLANS;
export type BillingInterval = "monthly" | "yearly";

Checkout Session

// app/api/billing/checkout/route.ts
import { NextResponse } from "next/server";
import { stripe, PLANS, type PlanName, type BillingInterval } from "@/lib/stripe";
import { getAuthUser } from "@/lib/auth";
import { db } from "@/lib/db";

export async function POST(req: Request) {
  const user = await getAuthUser();
  if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });

  const { plan, interval = "monthly" } = (await req.json()) as {
    plan: PlanName;
    interval: BillingInterval;
  };

  if (!PLANS[plan]) {
    return NextResponse.json({ error: "Invalid plan" }, { status: 400 });
  }

  const priceId = PLANS[plan][interval];

  // Get or create Stripe customer (idempotent)
  let customerId = user.stripeCustomerId;
  if (!customerId) {
    const customer = await stripe.customers.create({
      email: user.email,
      name: user.name || undefined,
      metadata: { userId: user.id, source: "checkout" },
    });
    customerId = customer.id;
    await db.user.update({
      where: { id: user.id },
      data: { stripeCustomerId: customerId },
    });
  }

  const session = await stripe.checkout.sessions.create({
    customer: customerId,
    mode: "subscription",
    payment_method_types: ["card"],
    line_items: [{ price: priceId, quantity: 1 }],
    allow_promotion_codes: true,
    tax_id_collection: { enabled: true },
    subscription_data: {
      trial_period_days: user.hasHadTrial ? undefined : 14,
      metadata: { userId: user.id, plan },
    },
    success_url: `${process.env.APP_URL}/dashboard?checkout=success&session_id={CHECKOUT_SESSION_ID}`,
    cancel_url: `${process.env.APP_URL}/pricing`,
    metadata: { userId: user.id },
  });

  return NextResponse.json({ url: session.url });
}

Subscription Management

Upgrade (Immediate, Prorated)

export async function upgradeSubscription(subscriptionId: string, newPriceId: string) {
  const subscription = await stripe.subscriptions.retrieve(subscriptionId);
  const currentItem = subscription.items.data[0];

  return stripe.subscriptions.update(subscriptionId, {
    items: [{ id: currentItem.id, price: newPriceId }],
    proration_behavior: "always_invoice",  // Charge difference immediately
    billing_cycle_anchor: "unchanged",      // Keep same billing date
  });
}

Downgrade (End of Period, No Proration)

export async function downgradeSubscription(subscriptionId: string, newPriceId: string) {
  const subscription = await stripe.subscriptions.retrieve(subscriptionId);
  const currentItem = subscription.items.data[0];

  // Schedule change for end of current period
  return stripe.subscriptions.update(subscriptionId, {
    items: [{ id: currentItem.id, price: newPriceId }],
    proration_behavior: "none",            // No refund
    billing_cycle_anchor: "unchanged",
  });
}

Preview Proration (Show Before Confirming)

export async function previewProration(subscriptionId: string, newPriceId: string) {
  const subscription = await stripe.subscriptions.retrieve(subscriptionId);

  const invoice = await stripe.invoices.createPreview({
    customer: subscription.customer as string,
    subscription: subscriptionId,
    subscription_details: {
      items: [{ id: subscription.items.data[0].id, price: newPriceId }],
      proration_date: Math.floor(Date.now() / 1000),
    },
  });

  return {
    amountDue: invoice.amount_due,            // In cents
    credit: invoice.total < 0 ? Math.abs(invoice.total) : 0,
    lineItems: invoice.lines.data.map(line => ({
      description: line.description,
      amount: line.amount,
    })),
  };
}

Cancel (At Period End)

export async function cancelSubscription(subscriptionId: string) {
  // Cancel at period end -- user keeps access until their paid period expires
  return stripe.subscriptions.update(subscriptionId, {
    cancel_at_period_end: true,
  });
}

export async function reactivateSubscription(subscriptionId: string) {
  // Undo pending cancellation
  return stripe.subscriptions.update(subscriptionId, {
    cancel_at_period_end: false,
  });
}

Webhook Handler (Idempotent)

This is the most critical code in your billing system. Get this right.

// app/api/webhooks/stripe/route.ts
import { NextResponse } from "next/server";
import { headers } from "next/headers";
import { stripe } from "@/lib/stripe";
import { db } from "@/lib/db";
import type Stripe from "stripe";

// Idempotency: track processed events to handle Stripe retries
async function isProcessed(eventId: string): Promise<boolean> {
  return !!(await db.stripeEvent.findUnique({ where: { id: eventId } }));
}

async function markProcessed(eventId: string, type: string) {
  await db.stripeEvent.create({
    data: { id: eventId, type, processedAt: new Date() },
  });
}

export async function POST(req: Request) {
  const body = await req.text();
  const signature = headers().get("stripe-signature");

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

  // Step 1: Verify webhook signature
  let event: Stripe.Event;
  try {
    event = stripe.webhooks.constructEvent(
      body, signature, process.env.STRIPE_WEBHOOK_SECRET!
    );
  } catch (err) {
    console.error("Webhook signature verification failed:", err);
    return NextResponse.json({ error: "Invalid signature" }, { status: 400 });
  }

  // Step 2: Idempotency check
  if (await isProcessed(event.id)) {
    return NextResponse.json({ received: true, deduplicated: true });
  }

  // Step 3: Handle events
  try {
    switch (event.type) {
      case "checkout.session.completed":
        await handleCheckoutCompleted(event.data.object as Stripe.Checkout.Session);
        break;
      case "customer.subscription.created":
      case "customer.subscription.updated":
        await handleSubscriptionChange(event.data.object as Stripe.Subscription);
        break;
      case "customer.subscription.deleted":
        await handleSubscriptionDeleted(event.data.object as Stripe.Subscription);
        break;
      case "invoice.payment_succeeded":
        await handlePaymentSucceeded(event.data.object as Stripe.Invoice);
        break;
      case "invoice.payment_failed":
        await handlePaymentFailed(event.data.object as Stripe.Invoice);
        break;
      case "customer.subscription.trial_will_end":
        await handleTrialEnding(event.data.object as Stripe.Subscription);
        break;
      default:
        // Log unhandled events for monitoring
        console.log(`Unhandled webhook: ${event.type}`);
    }

    await markProcessed(event.id, event.type);
    return NextResponse.json({ received: true });
  } catch (err) {
    console.error(`Webhook processing failed [${event.type}]:`, err);
    // Return 500 so Stripe retries. Do NOT mark as processed.
    return NextResponse.json({ error: "Processing failed" }, { status: 500 });
  }
}

// --- Handler implementations ---

async function handleCheckoutCompleted(session: Stripe.Checkout.Session) {
  if (session.mode !== "subscription") return;

  const userId = session.metadata?.userId;
  if (!userId) throw new Error("Missing userId in checkout metadata");

  // Always re-fetch from Stripe API -- event data may be stale
  const subscription = await stripe.subscriptions.retrieve(
    session.subscription as string
  );

  await db.user.update({
    where: { id: userId },
    data: {
      stripeCustomerId: session.customer as string,
      stripeSubscriptionId: subscription.id,
      stripePriceId: subscription.items.data[0].price.id,
      stripeCurrentPeriodEnd: new Date(subscription.current_period_end * 1000),
      subscriptionStatus: subscription.status,
      hasHadTrial: true,
    },
  });
}

async function handleSubscriptionChange(subscription: Stripe.Subscription) {
  // Find user by subscription ID first, fall back to customer ID
  const user = await db.user.findFirst({
    where: {
      OR: [
        { stripeSubscriptionId: subscription.id },
        { stripeCustomerId: subscription.customer as string },
      ],
    },
  });
  if (!user) {
    console.warn(`No user for subscription ${subscription.id}`);
    return;  // Don't throw -- this may be a subscription we don't manage
  }

  await db.user.update({
    where: { id: user.id },
    data: {
      stripeSubscriptionId: subscription.id,
      stripePriceId: subscription.items.data[0].price.id,
      stripeCurrentPeriodEnd: new Date(subscription.current_period_end * 1000),
      subscriptionStatus: subscription.status,
      cancelAtPeriodEnd: subscription.cancel_at_period_end,
    },
  });
}

async function handleSubscriptionDeleted(subscription: Stripe.Subscription) {
  await db.user.updateMany({
    where: { stripeSubscriptionId: subscription.id },
    data: {
      subscriptionStatus: "canceled",
      stripePriceId: null,
      stripeCurrentPeriodEnd: null,
      cancelAtPeriodEnd: false,
    },
  });
}

async function handlePaymentSucceeded(invoice: Stripe.Invoice) {
  if (!invoice.subscription) return;

  await db.user.updateMany({
    where: { stripeSubscriptionId: invoice.subscription as string },
    data: {
      subscriptionStatus: "active",
      stripeCurrentPeriodEnd: new Date(invoice.period_end * 1000),
    },
  });
}

async function handlePaymentFailed(invoice: Stripe.Invoice) {
  if (!invoice.subscription) return;

  await db.user.updateMany({
    where: { stripeSubscriptionId: invoice.subscription as string },
    data: { subscriptionStatus: "past_due" },
  });

  // Dunning: send appropriate email based on attempt count
  const attemptCount = invoice.attempt_count || 1;
  if (attemptCount === 1) {
    // First failure: gentle reminder
    await sendDunningEmail(invoice.customer_email!, "first_failure");
  } else if (attemptCount === 2) {
    // Second failure: more urgent
    await sendDunningEmail(invoice.customer_email!, "second_failure");
  } else if (attemptCount >= 3) {
    // Final failure: last chance before cancellation
    await sendDunningEmail(invoice.customer_email!, "final_notice");
  }
}

async function handleTrialEnding(subscription: Stripe.Subscription) {
  // Stripe sends this 3 days before trial ends
  const user = await db.user.findFirst({
    where: { stripeSubscriptionId: subscription.id },
  });
  if (user?.email) {
    await sendTrialEndingEmail(user.email, subscription.trial_end!);
  }
}

Usage-Based Billing

// Report metered usage
export async function reportUsage(
  subscriptionItemId: string,
  quantity: number,
  idempotencyKey?: string,
) {
  return stripe.subscriptionItems.createUsageRecord(
    subscriptionItemId,
    {
      quantity,
      timestamp: Math.floor(Date.now() / 1000),
      action: "increment",  // or "set" for absolute values
    },
    {
      idempotencyKey,  // Prevent double-counting on retries
    }
  );
}

// Middleware: track API usage per request
export async function trackApiUsage(userId: string) {
  const user = await db.user.findUnique({ where: { id: userId } });
  if (!user?.stripeSubscriptionId) return;

  const subscription = await stripe.subscriptions.retrieve(user.stripeSubscriptionId);
  const meteredItem = subscription.items.data.find(
    (item) => item.price.recurring?.usage_type === "metered"
  );

  if (meteredItem) {
    await reportUsage(meteredItem.id, 1, `${userId}-${Date.now()}`);
  }
}

Customer Portal

// app/api/billing/portal/route.ts
export async function POST() {
  const user = await getAuthUser();
  if (!user?.stripeCustomerId) {
    return NextResponse.json({ error: "No billing account" }, { status: 400 });
  }

  const session = await stripe.billingPortal.sessions.create({
    customer: user.stripeCustomerId,
    return_url: `${process.env.APP_URL}/settings/billing`,
  });

  return NextResponse.json({ url: session.url });
}

Portal configuration (must be done in Stripe Dashboard > Billing > Customer Portal):

  • Enable: Update subscription, cancel subscription, update payment method
  • Set cancellation flow: show pause option, require reason
  • Configure plan change options: which plans can switch to which

Feature Gating

// lib/subscription.ts
import { PLANS, type PlanName } from "./stripe";

export function isSubscriptionActive(user: {
  subscriptionStatus: string | null;
  stripeCurrentPeriodEnd: Date | null;
}): boolean {
  if (!user.subscriptionStatus) return false;

  // Active or trialing = full access
  if (["active", "trialing"].includes(user.subscriptionStatus)) return true;

  // Past due: grace period until period end
  if (user.subscriptionStatus === "past_due" && user.stripeCurrentPeriodEnd) {
    return user.stripeCurrentPeriodEnd > new Date();
  }

  // Cancel pending: access until period end
  if (user.subscriptionStatus === "cancel_pending" && user.stripeCurrentPeriodEnd) {
    return user.stripeCurrentPeriodEnd > new Date();
  }

  return false;
}

export function getUserPlan(stripePriceId: string | null): PlanName | "free" {
  if (!stripePriceId) return "free";

  for (const [plan, config] of Object.entries(PLANS)) {
    if (config.monthly === stripePriceId || config.yearly === stripePriceId) {
      return plan as PlanName;
    }
  }

  return "free";
}

export function canAccess(user: { stripePriceId: string | null }, feature: string): boolean {
  const plan = getUserPlan(user.stripePriceId);
  const limits = plan === "free" ? { projects: 1, events: 1000 } : PLANS[plan].limits;

  // Feature-specific checks
  switch (feature) {
    case "unlimited_projects": return limits.projects === -1;
    case "api_access": return plan !== "free" && plan !== "starter";
    default: return plan !== "free";
  }
}

SCA (Strong Customer Authentication) Compliance

Required for European customers under PSD2.

// Checkout Sessions handle SCA automatically (3D Secure)
// For existing subscriptions, handle authentication_required:

async function handlePaymentRequiresAction(invoice: Stripe.Invoice) {
  if (invoice.payment_intent) {
    const pi = await stripe.paymentIntents.retrieve(invoice.payment_intent as string);
    if (pi.status === "requires_action") {
      // Send email with link to complete authentication
      await sendAuthenticationEmail(
        invoice.customer_email!,
        pi.next_action?.redirect_to_url?.url || `${process.env.APP_URL}/billing/authenticate`
      );
    }
  }
}

Testing with Stripe CLI

# Install and authenticate
brew install stripe/stripe-cli/stripe
stripe login

# Forward webhooks to local server
stripe listen --forward-to localhost:3000/api/webhooks/stripe

# Trigger specific events
stripe trigger checkout.session.completed
stripe trigger customer.subscription.updated
stripe trigger invoice.payment_failed
stripe trigger customer.subscription.trial_will_end

# Test card numbers
# Success:               4242 4242 4242 4242
# Requires 3D Secure:    4000 0025 0000 3155
# Declined:              4000 0000 0000 0002
# Insufficient funds:    4000 0000 0000 9995
# Expired card:          4000 0000 0000 0069

# View recent events
stripe events list --limit 10

# Inspect a specific event
stripe events retrieve evt_xxx

Database Schema (Prisma)

model User {
  id                      String    @id @default(cuid())
  email                   String    @unique
  name                    String?

  // Stripe fields
  stripeCustomerId        String?   @unique
  stripeSubscriptionId    String?   @unique
  stripePriceId           String?
  stripeCurrentPeriodEnd  DateTime?
  subscriptionStatus      String?   // trialing, active, past_due, canceled, cancel_pending
  cancelAtPeriodEnd       Boolean   @default(false)
  hasHadTrial             Boolean   @default(false)
}

model StripeEvent {
  id          String   @id          // Stripe event ID (evt_xxx)
  type        String                // Event type
  processedAt DateTime @default(now())

  @@index([type])
}

Common Pitfalls

Pitfall Consequence Prevention
Trusting webhook event data Stale data, race conditions Always re-fetch from Stripe API in handlers
No idempotency on webhooks Double-charges, duplicate records Track processed event IDs in database
Missing metadata on checkout Cannot link subscription to user Always pass userId in metadata
Proration surprises Users charged unexpected amounts Always preview proration before upgrade
Not handling past_due Users lose access without warning Implement dunning emails on payment failure
Skipping trial abuse prevention Users create multiple accounts for free trials Store hasHadTrial: true, check on checkout
Customer Portal not configured Portal returns blank page Enable features in Stripe Dashboard first
Webhook endpoint not idempotent Stripe retries cause duplicate processing Idempotency table with event ID dedup
Not pinning API version Breaking changes on Stripe updates Pin apiVersion in client constructor
Ignoring trial_will_end event Users surprised when trial ends Send reminder email 3 days before

Related Skills

Skill Use When
ab-test-setup Testing pricing page variants and checkout flows
analytics-tracking Tracking checkout and subscription conversion events
email-template-builder Building dunning and billing notification emails
api-design-reviewer Reviewing your billing API endpoints
Weekly Installs
6
GitHub Stars
38
First Seen
6 days ago
Installed on
opencode6
gemini-cli6
github-copilot6
amp6
cline6
codex6