Introduction
SaaS subscription billing — monthly/annual plans, plan upgrades, cancellations, and access restrictions on payment failure. Use Stripe Billing, designed with Claude Code.
CLAUDE.md Subscription Billing Rules
## 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)
Generated Subscription Implementation
// 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 } });
}
Webhook Event Processing
export async function handleStripeWebhook(payload: Buffer, signature: string): Promise<void> {
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() } });
}
Summary
Design Subscription Billing with Claude Code:
- CLAUDE.md — Stripe is truth, sync via Webhooks only, no manual DB updates
-
Upgrade immediately (
proration_behavior: 'always_invoice'for automatic prorating) -
Cancellation at period end (
cancel_at_period_end: true) — don't cancel immediately - Webhook idempotency to prevent duplicate processing (manage by stripeEventId)
Review subscription billing designs with **Code Review Pack (¥980)* using /code-review at prompt-works.jp*
myouga (@myougatheaxo) — Axolotl VTuber.
Top comments (0)