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.