Testing Billing Systems: Subscription Lifecycle and Proration

Testing Billing Systems: Subscription Lifecycle and Proration

Subscription billing is complex. A user upgrades mid-cycle, downgrades at renewal, reactivates after cancellation, or disputes a charge — each scenario has specific proration rules, invoice behaviors, and state transitions. Without systematic testing, billing bugs are guaranteed.

This guide covers testing subscription lifecycle scenarios: plan changes, proration, trial periods, invoice generation, and dunning flows.

Subscription State Machine

A subscription moves through these states:

trialing → active → past_due → unpaid → canceled
              ↓                            ↑
         (canceled before trial ends) ──────
              ↓
         (cancel_at_period_end=true)
              ↓
         canceled (at period end)

Each state transition needs a test.

Testing with Stripe Test Clocks

Stripe's Test Clocks let you advance time to trigger subscription events without waiting:

// tests/billing/test-clock.ts
import Stripe from 'stripe';

const stripe = new Stripe(process.env.STRIPE_TEST_SECRET_KEY!, { apiVersion: '2023-10-16' });

async function createTestClock(startTime: Date): Promise<string> {
  const clock = await stripe.testHelpers.testClocks.create({
    frozen_time: Math.floor(startTime.getTime() / 1000),
    name: `Test Clock ${Date.now()}`,
  });
  return clock.id;
}

async function advanceClock(clockId: string, toTime: Date): Promise<void> {
  await stripe.testHelpers.testClocks.advance(clockId, {
    frozen_time: Math.floor(toTime.getTime() / 1000),
  });
  
  // Wait for Stripe to process events triggered by the clock advance
  await new Promise((resolve) => setTimeout(resolve, 2000));
}
// tests/billing/subscription-lifecycle.test.ts
import Stripe from 'stripe';

const stripe = new Stripe(process.env.STRIPE_TEST_SECRET_KEY!);

describe('Subscription lifecycle', () => {
  let clockId: string;
  let customerId: string;
  let subscriptionId: string;
  const now = new Date('2026-01-01T00:00:00Z');

  beforeAll(async () => {
    clockId = await createTestClock(now);
    
    // Create customer with test clock
    const customer = await stripe.customers.create({
      email: 'billing-test@example.com',
      test_clock: clockId,
    });
    customerId = customer.id;
    
    // Attach payment method
    const paymentMethod = await stripe.paymentMethods.create({
      type: 'card',
      card: { token: 'tok_visa' },
    });
    await stripe.paymentMethods.attach(paymentMethod.id, { customer: customerId });
    await stripe.customers.update(customerId, {
      invoice_settings: { default_payment_method: paymentMethod.id },
    });
  });

  afterAll(async () => {
    await stripe.testHelpers.testClocks.delete(clockId);
  });

  it('creates active subscription with immediate charge', async () => {
    const subscription = await stripe.subscriptions.create({
      customer: customerId,
      items: [{ price: process.env.STRIPE_MONTHLY_PRICE_ID! }],
    });
    
    subscriptionId = subscription.id;
    
    expect(subscription.status).toBe('active');
    expect(subscription.current_period_start).toBeDefined();
  });

  it('subscription renews at period end', async () => {
    // Advance clock to renewal date + 1 day
    const renewalDate = new Date(now);
    renewalDate.setMonth(renewalDate.getMonth() + 1);
    renewalDate.setDate(renewalDate.getDate() + 1);
    
    await advanceClock(clockId, renewalDate);
    
    const subscription = await stripe.subscriptions.retrieve(subscriptionId);
    
    // Period should have advanced
    const newPeriodStart = new Date(subscription.current_period_start * 1000);
    expect(newPeriodStart.getMonth()).toBe(renewalDate.getMonth() - 1);
  });

  it('sets cancel_at_period_end without immediate cancellation', async () => {
    const updated = await stripe.subscriptions.update(subscriptionId, {
      cancel_at_period_end: true,
    });
    
    expect(updated.cancel_at_period_end).toBe(true);
    expect(updated.status).toBe('active'); // Still active until period ends
    expect(updated.cancel_at).toBeDefined(); // Scheduled cancellation date
  });
});

Testing Plan Changes and Proration

describe('Plan changes and proration', () => {
  it('upgrades plan mid-cycle with correct proration', async () => {
    // Create subscription on basic plan (mid-cycle upgrade)
    const sub = await stripe.subscriptions.create({
      customer: customerId,
      items: [{ price: BASIC_PLAN_PRICE_ID }],
    });
    
    // Advance 15 days into the billing cycle
    const midCycle = new Date(sub.current_period_start * 1000);
    midCycle.setDate(midCycle.getDate() + 15);
    await advanceClock(clockId, midCycle);
    
    // Upgrade to premium plan
    const updated = await stripe.subscriptions.update(sub.id, {
      items: [{
        id: sub.items.data[0].id,
        price: PREMIUM_PLAN_PRICE_ID,
      }],
      proration_behavior: 'create_prorations',
    });
    
    // Verify the upcoming invoice includes proration credit
    const upcomingInvoice = await stripe.invoices.retrieveUpcoming({
      customer: customerId,
      subscription: sub.id,
    });
    
    const prorationItems = upcomingInvoice.lines.data.filter(
      (line) => line.proration
    );
    
    expect(prorationItems.length).toBeGreaterThan(0);
    
    // Credit for unused basic plan time
    const creditItem = prorationItems.find((item) => item.amount < 0);
    expect(creditItem).toBeDefined();
    
    // Charge for premium plan time
    const chargeItem = prorationItems.find((item) => item.amount > 0);
    expect(chargeItem).toBeDefined();
  });

  it('downgrade at renewal has no mid-cycle proration', async () => {
    // Schedule downgrade at period end
    await stripe.subscriptions.update(subscriptionId, {
      items: [{
        id: subscriptionItems[0].id,
        price: BASIC_PLAN_PRICE_ID,
      }],
      proration_behavior: 'none',
    });
    
    // Advance to renewal
    await advanceClock(clockId, nextPeriodDate);
    
    const subscription = await stripe.subscriptions.retrieve(subscriptionId);
    const currentPrice = subscription.items.data[0].price.id;
    
    expect(currentPrice).toBe(BASIC_PLAN_PRICE_ID);
  });
});

Testing Trial Periods

describe('Trial periods', () => {
  it('subscription starts as trialing', async () => {
    const trialEnd = new Date(now);
    trialEnd.setDate(trialEnd.getDate() + 14);
    
    const subscription = await stripe.subscriptions.create({
      customer: customerId,
      items: [{ price: MONTHLY_PRICE_ID }],
      trial_end: Math.floor(trialEnd.getTime() / 1000),
    });
    
    expect(subscription.status).toBe('trialing');
    expect(subscription.trial_end).toBeDefined();
  });

  it('subscription transitions to active after trial', async () => {
    const trialEnd = new Date(now);
    trialEnd.setDate(trialEnd.getDate() + 14);
    
    const sub = await stripe.subscriptions.create({
      customer: customerId,
      items: [{ price: MONTHLY_PRICE_ID }],
      trial_end: Math.floor(trialEnd.getTime() / 1000),
    });
    
    // Advance past trial end
    const postTrial = new Date(trialEnd);
    postTrial.setDate(postTrial.getDate() + 1);
    await advanceClock(clockId, postTrial);
    
    const updated = await stripe.subscriptions.retrieve(sub.id);
    expect(updated.status).toBe('active');
    expect(updated.trial_end).toBeNull();
  });

  it('no charge during trial period', async () => {
    const trialEnd = new Date(now);
    trialEnd.setDate(trialEnd.getDate() + 14);
    
    const sub = await stripe.subscriptions.create({
      customer: customerId,
      items: [{ price: MONTHLY_PRICE_ID }],
      trial_end: Math.floor(trialEnd.getTime() / 1000),
    });
    
    const invoices = await stripe.invoices.list({ customer: customerId, subscription: sub.id });
    
    // Trial invoice should have $0 amount
    const trialInvoice = invoices.data.find((inv) => inv.subscription === sub.id);
    expect(trialInvoice?.amount_due).toBe(0);
  });
});

Unit Testing Proration Calculations

Test proration logic independent of Stripe:

// src/billing/proration.ts
export function calculateProration(
  currentPlanPrice: number,
  newPlanPrice: number,
  daysInPeriod: number,
  daysRemaining: number
): {
  credit: number;
  charge: number;
  netAmount: number;
} {
  const dailyCurrentRate = currentPlanPrice / daysInPeriod;
  const dailyNewRate = newPlanPrice / daysInPeriod;
  
  const credit = Math.round(dailyCurrentRate * daysRemaining);
  const charge = Math.round(dailyNewRate * daysRemaining);
  
  return {
    credit: -credit, // Negative = credit
    charge,
    netAmount: charge - credit,
  };
}
// tests/billing/proration.test.ts
import { describe, it, expect } from 'vitest';
import { calculateProration } from '~/billing/proration';

describe('calculateProration', () => {
  it('calculates mid-cycle upgrade correctly', () => {
    const result = calculateProration(
      1000,   // $10.00 current plan (in cents)
      3000,   // $30.00 new plan (in cents)
      31,     // 31-day billing period
      15,     // 15 days remaining
    );
    
    // Credit: ~$4.84 (15/31 * $10)
    // Charge: ~$14.52 (15/31 * $30)
    // Net: ~$9.68
    
    expect(result.credit).toBeLessThan(0); // Credit is negative
    expect(result.charge).toBeGreaterThan(0);
    expect(result.netAmount).toBeGreaterThan(0); // Upgrade costs more
  });

  it('calculates zero cost for same plan switch', () => {
    const result = calculateProration(1000, 1000, 30, 15);
    expect(result.netAmount).toBe(0);
  });

  it('full period upgrade charges full amount', () => {
    const result = calculateProration(1000, 3000, 30, 30);
    expect(result.credit).toBe(-1000);
    expect(result.charge).toBe(3000);
    expect(result.netAmount).toBe(2000);
  });
});

Summary

Subscription billing testing requires covering:

  • State transitions: trialing → active → past_due → canceled
  • Stripe Test Clocks: advance time to test renewal, trial end, and dunning without waiting
  • Plan changes: proration items appear on upgrades, downgrades scheduled at period end
  • Trial periods: trialing status, no charge during trial, automatic transition to active
  • Proration unit tests: verify calculation logic independently of Stripe

Billing bugs are expensive — customers get double charged, upgrades don't apply, or cancellations don't take effect. Systematic automated testing catches these before customers see them.

Read more