Claude Code for Payment Systems: Subscriptions, Billing, and Payment Orchestration — Claude Skills 360 Blog
Blog / Development / Claude Code for Payment Systems: Subscriptions, Billing, and Payment Orchestration
Development

Claude Code for Payment Systems: Subscriptions, Billing, and Payment Orchestration

Published: August 1, 2026
Read time: 10 min read
By: Claude Skills 360

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.

Put these ideas into practice

Claude Skills 360 gives you production-ready skills for everything in this article — and 2,350+ more. Start free or go all-in.

Back to Blog

Get 360 skills free