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

Published: (March 11, 2026 at 01:56 AM EDT)
3 min read
Source: Dev.to

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: true to end at period end.
  • Webhook idempotency: track stripeEventId to prevent duplicate processing.
0 views
Back to Blog

Related posts

Read more »