Designing Subscription Billing with Claude Code: Stripe Billing, Plan Changes, Webhooks
Source: Dev.to
Introduction
SaaS subscription billing — monthly/annual plans, plan upgrades, cancellations, and access restrictions on payment failure. Use Stripe Billing, designed with Claude Code.
Subscription Billing Design Rules
Stripe Billing
- Stripe is the source of truth for Customer/Subscription/Product
- Sync local DB only via Stripe Webhooks
- Never manually update DB (Webhook is the only truth)
Plan Management
- Upgrade: immediate (automatic prorated billing)
- Downgrade: effective next billing cycle
- Cancellation: continue until period end
Payment Failure
- Retry at 3, 7, 14 days after failure
- Final failure → subscription paused → access restriction
- Soft block (can read, cannot create)
// src/billing/stripeService.ts
const PLANS = {
pro: {
monthly: process.env.STRIPE_PRICE_PRO_MONTHLY!,
annual: process.env.STRIPE_PRICE_PRO_ANNUAL!,
},
business: {
monthly: process.env.STRIPE_PRICE_BUSINESS_MONTHLY!,
annual: process.env.STRIPE_PRICE_BUSINESS_ANNUAL!,
},
};
export async function createSubscription(
userId: string,
plan: 'pro' | 'business',
billingCycle: 'monthly' | 'annual',
paymentMethodId: string
) {
const user = await prisma.user.findUniqueOrThrow({ where: { id: userId } });
let customerId = user.stripeCustomerId;
if (!customerId) {
const customer = await stripe.customers.create({
email: user.email,
name: user.name,
metadata: { userId },
});
customerId = customer.id;
await prisma.user.update({
where: { id: userId },
data: { stripeCustomerId: customerId },
});
}
await stripe.customers.update(customerId, {
invoice_settings: { default_payment_method: paymentMethodId },
});
const subscription = await stripe.subscriptions.create({
customer: customerId,
items: [{ price: PLANS[plan][billingCycle] }],
expand: ['latest_invoice.payment_intent'],
});
await prisma.subscription.upsert({
where: { userId },
create: {
userId,
stripeSubscriptionId: subscription.id,
stripeCustomerId: customerId,
plan,
status: subscription.status,
currentPeriodEnd: new Date(subscription.current_period_end * 1000),
},
update: {
plan,
status: subscription.status,
currentPeriodEnd: new Date(subscription.current_period_end * 1000),
},
});
return subscription;
}
export async function upgradeSubscription(
userId: string,
newPlan: 'pro' | 'business',
billingCycle: 'monthly' | 'annual'
) {
const sub = await prisma.subscription.findUniqueOrThrow({ where: { userId } });
const stripeSub = await stripe.subscriptions.retrieve(sub.stripeSubscriptionId);
await stripe.subscriptions.update(sub.stripeSubscriptionId, {
items: [
{
id: stripeSub.items.data[0].id,
price: PLANS[newPlan][billingCycle],
},
],
proration_behavior: 'always_invoice', // Immediate prorated billing
});
await prisma.subscription.update({ where: { userId }, data: { plan: newPlan } });
}
export async function cancelSubscription(userId: string, reason?: string) {
const sub = await prisma.subscription.findUniqueOrThrow({ where: { userId } });
await stripe.subscriptions.update(sub.stripeSubscriptionId, {
cancel_at_period_end: true,
metadata: { cancellationReason: reason ?? 'user_requested' },
});
await prisma.subscription.update({
where: { userId },
data: { cancelAtPeriodEnd: true },
});
}
export async function handleStripeWebhook(
payload: Buffer,
signature: string
): Promise {
const event = stripe.webhooks.constructEvent(
payload,
signature,
process.env.STRIPE_WEBHOOK_SECRET!
);
// Idempotency check
const existing = await prisma.processedWebhook.findUnique({
where: { stripeEventId: event.id },
});
if (existing) return;
switch (event.type) {
case 'invoice.payment_failed': {
const invoice = event.data.object as Stripe.Invoice;
const sub = await prisma.subscription.findFirst({
where: { stripeCustomerId: invoice.customer as string },
});
if (!sub) break;
if ((invoice.attempt_count ?? 1) >= 3) {
await prisma.subscription.update({
where: { id: sub.id },
data: { status: 'past_due', accessBlocked: true },
});
await sendNotification(sub.userId, 'subscription_access_blocked', {
retryUrl: `${process.env.APP_URL}/billing/retry`,
});
}
break;
}
case 'customer.subscription.updated': {
const stripeSub = event.data.object as Stripe.Subscription;
await prisma.subscription.update({
where: { stripeSubscriptionId: stripeSub.id },
data: {
status: stripeSub.status,
currentPeriodEnd: new Date(stripeSub.current_period_end * 1000),
},
});
break;
}
}
await prisma.processedWebhook.create({
data: {
stripeEventId: event.id,
type: event.type,
processedAt: new Date(),
},
});
}
Design Summary
- Stripe is the source of truth; sync via webhooks only, no manual DB updates.
- Upgrade: immediate with
proration_behavior: 'always_invoice'. - Cancellation: set
cancel_at_period_end: trueto end at period end. - Webhook idempotency: track
stripeEventIdto prevent duplicate processing.