DEV Community

myougaTheAxo
myougaTheAxo

Posted on

Designing Subscription Billing with Claude Code: Stripe Billing, Plan Changes, Webhooks

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)
Enter fullscreen mode Exit fullscreen mode

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 } });
}
Enter fullscreen mode Exit fullscreen mode

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() } });
}
Enter fullscreen mode Exit fullscreen mode

Summary

Design Subscription Billing with Claude Code:

  1. CLAUDE.md — Stripe is truth, sync via Webhooks only, no manual DB updates
  2. Upgrade immediately (proration_behavior: 'always_invoice' for automatic prorating)
  3. Cancellation at period end (cancel_at_period_end: true) — don't cancel immediately
  4. 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)