Payment systems are the highest-stakes code in most applications — bugs cost money directly. Claude Code generates payment integration code that handles the edge cases: proration when users change plans mid-billing-cycle, retry logic for failed payments, idempotent webhook handlers, and the subscription state machine that keeps billing synchronized with your product access.
Stripe Subscription Setup
Implement subscription billing: monthly and annual plans,
upgrade/downgrade with proration, and a 14-day free trial.
// services/SubscriptionService.ts
import Stripe from 'stripe';
import { db } from '../db';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, { apiVersion: '2024-11-20.acacia' });
const PLANS = {
starter_monthly: { priceId: process.env.STRIPE_STARTER_MONTHLY_PRICE_ID!, name: 'Starter Monthly' },
starter_annual: { priceId: process.env.STRIPE_STARTER_ANNUAL_PRICE_ID!, name: 'Starter Annual' },
pro_monthly: { priceId: process.env.STRIPE_PRO_MONTHLY_PRICE_ID!, name: 'Pro Monthly' },
pro_annual: { priceId: process.env.STRIPE_PRO_ANNUAL_PRICE_ID!, name: 'Pro Annual' },
} as const;
export class SubscriptionService {
// Create or get customer
async ensureCustomer(userId: string, email: string): Promise<string> {
const user = await db('users').where('id', userId).first();
if (user.stripe_customer_id) return user.stripe_customer_id;
const customer = await stripe.customers.create({
email,
metadata: { userId },
});
await db('users').where('id', userId).update({ stripe_customer_id: customer.id });
return customer.id;
}
// Start subscription with trial
async createSubscription(
userId: string,
email: string,
planKey: keyof typeof PLANS,
paymentMethodId: string,
) {
const customerId = await this.ensureCustomer(userId, email);
// Attach and set as default payment method
await stripe.paymentMethods.attach(paymentMethodId, { customer: customerId });
await stripe.customers.update(customerId, {
invoice_settings: { default_payment_method: paymentMethodId },
});
const subscription = await stripe.subscriptions.create({
customer: customerId,
items: [{ price: PLANS[planKey].priceId }],
trial_period_days: 14,
payment_settings: {
payment_method_types: ['card'],
save_default_payment_method: 'on_subscription',
},
expand: ['latest_invoice.payment_intent'],
metadata: { userId, planKey },
});
await db('subscriptions').insert({
user_id: userId,
stripe_subscription_id: subscription.id,
stripe_customer_id: customerId,
plan: planKey,
status: subscription.status,
trial_end: subscription.trial_end ? new Date(subscription.trial_end * 1000) : null,
current_period_end: new Date(subscription.current_period_end * 1000),
});
return subscription;
}
// Upgrade/downgrade with proration
async changePlan(userId: string, newPlanKey: keyof typeof PLANS) {
const sub = await db('subscriptions').where('user_id', userId).first();
if (!sub) throw new Error('No active subscription');
const stripeSub = await stripe.subscriptions.retrieve(sub.stripe_subscription_id);
const currentItem = stripeSub.items.data[0];
const updatedSub = await stripe.subscriptions.update(sub.stripe_subscription_id, {
items: [{ id: currentItem.id, price: PLANS[newPlanKey].priceId }],
proration_behavior: 'create_prorations', // Bill/credit immediately for difference
billing_cycle_anchor: 'unchanged', // Keep same renewal date
});
await db('subscriptions').where('user_id', userId).update({
plan: newPlanKey,
status: updatedSub.status,
updated_at: new Date(),
});
return updatedSub;
}
// Cancel at period end (not immediately)
async cancelSubscription(userId: string) {
const sub = await db('subscriptions').where('user_id', userId).first();
const updatedSub = await stripe.subscriptions.update(sub.stripe_subscription_id, {
cancel_at_period_end: true, // Don't cancel immediately — let them use until period ends
});
await db('subscriptions').where('user_id', userId).update({
cancel_at_period_end: true,
cancel_at: new Date(updatedSub.cancel_at! * 1000),
});
}
}
Webhook Handler
Build a webhook handler for Stripe events.
Must be idempotent — safe to replay the same event twice.
Handle: subscription state changes, payment failures, trial ending.
// api/webhooks/stripe.ts
import Stripe from 'stripe';
import { db } from '../db';
import { sendEmail } from '../email';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
export async function POST(request: Request) {
const body = await request.text();
const signature = request.headers.get('stripe-signature')!;
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(
body,
signature,
process.env.STRIPE_WEBHOOK_SECRET!,
);
} catch (err) {
return Response.json({ error: 'Invalid signature' }, { status: 400 });
}
// Idempotency: skip if already processed
const existing = await db('webhook_events').where('stripe_event_id', event.id).first();
if (existing) {
return Response.json({ received: true, skipped: true });
}
// Record before processing to prevent duplicate handling on retry
await db('webhook_events').insert({
stripe_event_id: event.id,
event_type: event.type,
processed_at: new Date(),
});
try {
await handleEvent(event);
return Response.json({ received: true });
} catch (error) {
// Mark as failed for manual investigation
await db('webhook_events').where('stripe_event_id', event.id).update({
failed: true,
error: (error as Error).message,
});
// Return 500 — Stripe will retry
return Response.json({ error: 'Handler failed' }, { status: 500 });
}
}
async function handleEvent(event: Stripe.Event) {
switch (event.type) {
case 'customer.subscription.updated': {
const sub = event.data.object as Stripe.Subscription;
const userId = sub.metadata.userId;
await db('subscriptions').where('stripe_subscription_id', sub.id).update({
status: sub.status,
current_period_end: new Date(sub.current_period_end * 1000),
cancel_at_period_end: sub.cancel_at_period_end,
plan: sub.metadata.planKey,
});
// Update product access based on status
await updateProductAccess(userId, sub.status === 'active' || sub.status === 'trialing');
break;
}
case 'invoice.payment_failed': {
const invoice = event.data.object as Stripe.Invoice;
const customer = await stripe.customers.retrieve(invoice.customer as string);
const email = (customer as Stripe.Customer).email!;
// Dunning: first failure loses access after grace period
const attemptCount = invoice.attempt_count;
if (attemptCount === 1) {
await sendEmail(email, 'payment-failed-first', {
nextRetryDate: new Date((invoice.next_payment_attempt ?? 0) * 1000).toLocaleDateString(),
updatePaymentUrl: `${process.env.APP_URL}/billing/update-payment`,
});
} else if (attemptCount === 3) {
// Final failure — suspend access
const userId = invoice.metadata?.userId;
if (userId) {
await updateProductAccess(userId, false);
await sendEmail(email, 'subscription-suspended', {});
}
}
break;
}
case 'customer.subscription.trial_will_end': {
// Fires 3 days before trial ends
const sub = event.data.object as Stripe.Subscription;
const customer = await stripe.customers.retrieve(sub.customer as string);
const email = (customer as Stripe.Customer).email!;
const trialEnd = new Date(sub.trial_end! * 1000);
const daysLeft = Math.ceil((trialEnd.getTime() - Date.now()) / (1000 * 60 * 60 * 24));
await sendEmail(email, 'trial-ending-soon', {
daysLeft,
upgradeUrl: `${process.env.APP_URL}/billing`,
});
break;
}
case 'invoice.payment_succeeded': {
const invoice = event.data.object as Stripe.Invoice;
const userId = invoice.metadata?.userId;
if (userId) {
// Re-enable access if it was suspended
await updateProductAccess(userId, true);
// Store invoice for receipts
await db('invoices').insert({
user_id: userId,
stripe_invoice_id: invoice.id,
amount_cents: invoice.amount_paid,
currency: invoice.currency,
paid_at: new Date(invoice.status_transitions.paid_at! * 1000),
invoice_url: invoice.hosted_invoice_url,
invoice_pdf: invoice.invoice_pdf,
}).onConflict('stripe_invoice_id').ignore();
}
break;
}
}
}
async function updateProductAccess(userId: string, hasAccess: boolean) {
await db('users').where('id', userId).update({
has_product_access: hasAccess,
access_updated_at: new Date(),
});
}
Customer Portal
Add a Stripe Customer Portal for self-service billing management.
Users can update payment method, view invoices, and cancel.
// api/billing/portal/route.ts
export async function POST(request: Request) {
const session = await getSession(request); // Your auth
const user = await db('users').where('id', session.userId).first();
if (!user.stripe_customer_id) {
return Response.json({ error: 'No billing account' }, { status: 400 });
}
const portalSession = await stripe.billingPortal.sessions.create({
customer: user.stripe_customer_id,
return_url: `${process.env.NEXT_PUBLIC_APP_URL}/settings/billing`,
});
return Response.json({ url: portalSession.url });
}
// In the frontend — just redirect to Stripe's hosted portal
async function openBillingPortal() {
const response = await fetch('/api/billing/portal', { method: 'POST' });
const { url } = await response.json();
window.location.href = url;
}
For the Stripe integration baseline including one-time payments and webhook setup, see the Stripe integration guide. For testing payment flows in your test suite, see the testing strategies guide. The Claude Skills 360 bundle includes payment system skill sets for subscriptions, invoicing, and dunning workflows. Start with the free tier to try subscription billing scaffolding.