- Add CSS components: .page-meta badges, .domain-header, .install-banner - Fix invisible tab navigation (explicit color for light/dark modes) - Rewrite generate-docs.py with design system templates - Domain indexes: centered headers with icons, install banners, grid cards - Skill pages: pill badges (domain, skill ID, source), install commands - Agent/command pages: type badges with domain icons - Regenerate all 210 pages (180 skills + 15 agents + 15 commands) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
488 lines
15 KiB
Markdown
488 lines
15 KiB
Markdown
---
|
|
title: "Stripe Integration Expert"
|
|
description: "Stripe Integration Expert - Claude Code skill from the Engineering - Core domain."
|
|
---
|
|
|
|
# Stripe Integration Expert
|
|
|
|
<div class="page-meta" markdown>
|
|
<span class="meta-badge">:material-code-braces: Engineering - Core</span>
|
|
<span class="meta-badge">:material-identifier: `stripe-integration-expert`</span>
|
|
<span class="meta-badge">:material-github: <a href="https://github.com/alirezarezvani/claude-skills/tree/main/engineering-team/stripe-integration-expert/SKILL.md">Source</a></span>
|
|
</div>
|
|
|
|
<div class="install-banner" markdown>
|
|
<span class="install-label">Install:</span> <code>claude /plugin install engineering-skills</code>
|
|
</div>
|
|
|
|
|
|
**Tier:** POWERFUL
|
|
**Category:** Engineering Team
|
|
**Domain:** Payments / Billing Infrastructure
|
|
|
|
---
|
|
|
|
## Overview
|
|
|
|
Implement production-grade Stripe integrations: subscriptions with trials and proration, one-time payments, usage-based billing, checkout sessions, idempotent webhook handlers, customer portal, and invoicing. Covers Next.js, Express, and Django patterns.
|
|
|
|
---
|
|
|
|
## Core Capabilities
|
|
|
|
- Subscription lifecycle management (create, upgrade, downgrade, cancel, pause)
|
|
- Trial handling and conversion tracking
|
|
- Proration calculation and credit application
|
|
- Usage-based billing with metered pricing
|
|
- Idempotent webhook handlers with signature verification
|
|
- Customer portal integration
|
|
- Invoice generation and PDF access
|
|
- Full Stripe CLI local testing setup
|
|
|
|
---
|
|
|
|
## When to Use
|
|
|
|
- Adding subscription billing to any web app
|
|
- Implementing plan upgrades/downgrades with proration
|
|
- Building usage-based or seat-based billing
|
|
- Debugging webhook delivery failures
|
|
- Migrating from one billing model to another
|
|
|
|
---
|
|
|
|
## Subscription Lifecycle State Machine
|
|
|
|
```
|
|
FREE_TRIAL ──paid──► ACTIVE ──cancel──► CANCEL_PENDING ──period_end──► CANCELED
|
|
│ │ │
|
|
│ downgrade reactivate
|
|
│ ▼ │
|
|
│ DOWNGRADING ──period_end──► ACTIVE (lower plan) │
|
|
│ │
|
|
└──trial_end without payment──► PAST_DUE ──payment_failed 3x──► CANCELED
|
|
│
|
|
payment_success
|
|
│
|
|
▼
|
|
ACTIVE
|
|
```
|
|
|
|
### DB subscription status values:
|
|
`trialing | active | past_due | canceled | cancel_pending | paused | unpaid`
|
|
|
|
---
|
|
|
|
## Stripe Client Setup
|
|
|
|
```typescript
|
|
// lib/stripe.ts
|
|
import Stripe from "stripe"
|
|
|
|
export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
|
|
apiVersion: "2024-04-10",
|
|
typescript: true,
|
|
appInfo: {
|
|
name: "myapp",
|
|
version: "1.0.0",
|
|
},
|
|
})
|
|
|
|
// Price IDs by plan (set in env)
|
|
export const PLANS = {
|
|
starter: {
|
|
monthly: process.env.STRIPE_STARTER_MONTHLY_PRICE_ID!,
|
|
yearly: process.env.STRIPE_STARTER_YEARLY_PRICE_ID!,
|
|
features: ["5 projects", "10k events"],
|
|
},
|
|
pro: {
|
|
monthly: process.env.STRIPE_PRO_MONTHLY_PRICE_ID!,
|
|
yearly: process.env.STRIPE_PRO_YEARLY_PRICE_ID!,
|
|
features: ["Unlimited projects", "1M events"],
|
|
},
|
|
} as const
|
|
```
|
|
|
|
---
|
|
|
|
## Checkout Session (Next.js App Router)
|
|
|
|
```typescript
|
|
// app/api/billing/checkout/route.ts
|
|
import { NextResponse } from "next/server"
|
|
import { stripe } 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 { priceId, interval = "monthly" } = await req.json()
|
|
|
|
// Get or create Stripe customer
|
|
let stripeCustomerId = user.stripeCustomerId
|
|
if (!stripeCustomerId) {
|
|
const customer = await stripe.customers.create({
|
|
email: user.email,
|
|
name: "username-undefined"
|
|
metadata: { userId: user.id },
|
|
})
|
|
stripeCustomerId = customer.id
|
|
await db.user.update({ where: { id: user.id }, data: { stripeCustomerId } })
|
|
}
|
|
|
|
const session = await stripe.checkout.sessions.create({
|
|
customer: stripeCustomerId,
|
|
mode: "subscription",
|
|
payment_method_types: ["card"],
|
|
line_items: [{ price: priceId, quantity: 1 }],
|
|
allow_promotion_codes: true,
|
|
subscription_data: {
|
|
trial_period_days: user.hasHadTrial ? undefined : 14,
|
|
metadata: { userId: user.id },
|
|
},
|
|
success_url: `${process.env.NEXT_PUBLIC_APP_URL}/dashboard?session_id={CHECKOUT_SESSION_ID}`,
|
|
cancel_url: `${process.env.NEXT_PUBLIC_APP_URL}/pricing`,
|
|
metadata: { userId: user.id },
|
|
})
|
|
|
|
return NextResponse.json({ url: session.url })
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Subscription Upgrade/Downgrade
|
|
|
|
```typescript
|
|
// lib/billing.ts
|
|
export async function changeSubscriptionPlan(
|
|
subscriptionId: string,
|
|
newPriceId: string,
|
|
immediate = false
|
|
) {
|
|
const subscription = await stripe.subscriptions.retrieve(subscriptionId)
|
|
const currentItem = subscription.items.data[0]
|
|
|
|
if (immediate) {
|
|
// Upgrade: apply immediately with proration
|
|
return stripe.subscriptions.update(subscriptionId, {
|
|
items: [{ id: currentItem.id, price: newPriceId }],
|
|
proration_behavior: "always_invoice",
|
|
billing_cycle_anchor: "unchanged",
|
|
})
|
|
} else {
|
|
// Downgrade: apply at period end, no proration
|
|
return stripe.subscriptions.update(subscriptionId, {
|
|
items: [{ id: currentItem.id, price: newPriceId }],
|
|
proration_behavior: "none",
|
|
billing_cycle_anchor: "unchanged",
|
|
})
|
|
}
|
|
}
|
|
|
|
// Preview proration before confirming upgrade
|
|
export async function previewProration(subscriptionId: string, newPriceId: string) {
|
|
const subscription = await stripe.subscriptions.retrieve(subscriptionId)
|
|
const prorationDate = Math.floor(Date.now() / 1000)
|
|
|
|
const invoice = await stripe.invoices.retrieveUpcoming({
|
|
customer: subscription.customer as string,
|
|
subscription: subscriptionId,
|
|
subscription_items: [{ id: subscription.items.data[0].id, price: newPriceId }],
|
|
subscription_proration_date: prorationDate,
|
|
})
|
|
|
|
return {
|
|
amountDue: invoice.amount_due,
|
|
prorationDate,
|
|
lineItems: invoice.lines.data,
|
|
}
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Complete Webhook Handler (Idempotent)
|
|
|
|
```typescript
|
|
// 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 Stripe from "stripe"
|
|
|
|
// Processed events table to ensure idempotency
|
|
async function hasProcessedEvent(eventId: string): Promise<boolean> {
|
|
const existing = await db.stripeEvent.findUnique({ where: { id: eventId } })
|
|
return !!existing
|
|
}
|
|
|
|
async function markEventProcessed(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")!
|
|
|
|
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 })
|
|
}
|
|
|
|
// Idempotency check
|
|
if (await hasProcessedEvent(event.id)) {
|
|
return NextResponse.json({ received: true, skipped: true })
|
|
}
|
|
|
|
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 handleSubscriptionUpdated(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 handleInvoicePaymentSucceeded(event.data.object as Stripe.Invoice)
|
|
break
|
|
|
|
case "invoice.payment_failed":
|
|
await handleInvoicePaymentFailed(event.data.object as Stripe.Invoice)
|
|
break
|
|
|
|
default:
|
|
console.log(`Unhandled event type: ${event.type}`)
|
|
}
|
|
|
|
await markEventProcessed(event.id, event.type)
|
|
return NextResponse.json({ received: true })
|
|
} catch (err) {
|
|
console.error(`Error processing webhook ${event.type}:`, err)
|
|
// Return 500 so Stripe retries — don't mark as processed
|
|
return NextResponse.json({ error: "Processing failed" }, { status: 500 })
|
|
}
|
|
}
|
|
|
|
async function handleCheckoutCompleted(session: Stripe.Checkout.Session) {
|
|
if (session.mode !== "subscription") return
|
|
|
|
const userId = session.metadata?.userId
|
|
if (!userId) throw new Error("No userId in checkout session metadata")
|
|
|
|
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 handleSubscriptionUpdated(subscription: Stripe.Subscription) {
|
|
const user = await db.user.findUnique({
|
|
where: { stripeSubscriptionId: subscription.id },
|
|
})
|
|
if (!user) {
|
|
// Look up by customer ID as fallback
|
|
const customer = await db.user.findUnique({
|
|
where: { stripeCustomerId: subscription.customer as string },
|
|
})
|
|
if (!customer) throw new Error(`No user found for subscription ${subscription.id}`)
|
|
}
|
|
|
|
await db.user.update({
|
|
where: { stripeSubscriptionId: subscription.id },
|
|
data: {
|
|
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.update({
|
|
where: { stripeSubscriptionId: subscription.id },
|
|
data: {
|
|
stripeSubscriptionId: null,
|
|
stripePriceId: null,
|
|
stripeCurrentPeriodEnd: null,
|
|
subscriptionStatus: "canceled",
|
|
},
|
|
})
|
|
}
|
|
|
|
async function handleInvoicePaymentFailed(invoice: Stripe.Invoice) {
|
|
if (!invoice.subscription) return
|
|
const attemptCount = invoice.attempt_count
|
|
|
|
await db.user.update({
|
|
where: { stripeSubscriptionId: invoice.subscription as string },
|
|
data: { subscriptionStatus: "past_due" },
|
|
})
|
|
|
|
if (attemptCount >= 3) {
|
|
// Send final dunning email
|
|
await sendDunningEmail(invoice.customer_email!, "final")
|
|
} else {
|
|
await sendDunningEmail(invoice.customer_email!, "retry")
|
|
}
|
|
}
|
|
|
|
async function handleInvoicePaymentSucceeded(invoice: Stripe.Invoice) {
|
|
if (!invoice.subscription) return
|
|
|
|
await db.user.update({
|
|
where: { stripeSubscriptionId: invoice.subscription as string },
|
|
data: {
|
|
subscriptionStatus: "active",
|
|
stripeCurrentPeriodEnd: new Date(invoice.period_end * 1000),
|
|
},
|
|
})
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Usage-Based Billing
|
|
|
|
```typescript
|
|
// Report usage for metered subscriptions
|
|
export async function reportUsage(subscriptionItemId: string, quantity: number) {
|
|
await stripe.subscriptionItems.createUsageRecord(subscriptionItemId, {
|
|
quantity,
|
|
timestamp: Math.floor(Date.now() / 1000),
|
|
action: "increment",
|
|
})
|
|
}
|
|
|
|
// Example: report API calls in middleware
|
|
export async function trackApiCall(userId: string) {
|
|
const user = await db.user.findUnique({ where: { id: userId } })
|
|
if (user?.stripeSubscriptionId) {
|
|
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)
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Customer Portal
|
|
|
|
```typescript
|
|
// app/api/billing/portal/route.ts
|
|
import { NextResponse } from "next/server"
|
|
import { stripe } from "@/lib/stripe"
|
|
import { getAuthUser } from "@/lib/auth"
|
|
|
|
export async function POST() {
|
|
const user = await getAuthUser()
|
|
if (!user?.stripeCustomerId) {
|
|
return NextResponse.json({ error: "No billing account" }, { status: 400 })
|
|
}
|
|
|
|
const portalSession = await stripe.billingPortal.sessions.create({
|
|
customer: user.stripeCustomerId,
|
|
return_url: `${process.env.NEXT_PUBLIC_APP_URL}/settings/billing`,
|
|
})
|
|
|
|
return NextResponse.json({ url: portalSession.url })
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Testing with Stripe CLI
|
|
|
|
```bash
|
|
# Install Stripe CLI
|
|
brew install stripe/stripe-cli/stripe
|
|
|
|
# Login
|
|
stripe login
|
|
|
|
# Forward webhooks to local dev
|
|
stripe listen --forward-to localhost:3000/api/webhooks/stripe
|
|
|
|
# Trigger specific events for testing
|
|
stripe trigger checkout.session.completed
|
|
stripe trigger customer.subscription.updated
|
|
stripe trigger invoice.payment_failed
|
|
|
|
# Test with specific customer
|
|
stripe trigger customer.subscription.updated \
|
|
--override subscription:customer=cus_xxx
|
|
|
|
# View recent events
|
|
stripe events list --limit 10
|
|
|
|
# Test cards
|
|
# Success: 4242 4242 4242 4242
|
|
# Requires auth: 4000 0025 0000 3155
|
|
# Decline: 4000 0000 0000 9995
|
|
# Insufficient funds: 4000 0000 0000 9995
|
|
```
|
|
|
|
---
|
|
|
|
## Feature Gating Helper
|
|
|
|
```typescript
|
|
// lib/subscription.ts
|
|
export function isSubscriptionActive(user: { subscriptionStatus: string | null, stripeCurrentPeriodEnd: Date | null }) {
|
|
if (!user.subscriptionStatus) return false
|
|
if (user.subscriptionStatus === "active" || user.subscriptionStatus === "trialing") return true
|
|
// Grace period: past_due but not yet expired
|
|
if (user.subscriptionStatus === "past_due" && user.stripeCurrentPeriodEnd) {
|
|
return user.stripeCurrentPeriodEnd > new Date()
|
|
}
|
|
return false
|
|
}
|
|
|
|
// Middleware usage
|
|
export async function requireActiveSubscription() {
|
|
const user = await getAuthUser()
|
|
if (!isSubscriptionActive(user)) {
|
|
redirect("/billing?reason=subscription_required")
|
|
}
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Common Pitfalls
|
|
|
|
- **Webhook delivery order not guaranteed** — always re-fetch from Stripe API, never trust event data alone for DB updates
|
|
- **Double-processing webhooks** — Stripe retries on 500; always use idempotency table
|
|
- **Trial conversion tracking** — store `hasHadTrial: true` in DB to prevent trial abuse
|
|
- **Proration surprises** — always preview proration before upgrade; show user the amount before confirming
|
|
- **Customer portal not configured** — must enable features in Stripe dashboard under Billing → Customer portal settings
|
|
- **Missing metadata on checkout** — always pass `userId` in metadata; can't link subscription to user without it
|