Testing Subscription Billing Flows: Upgrades, Downgrades, and Cancellations
Billing bugs are the most expensive bugs in SaaS. A customer who gets charged incorrectly after a downgrade files a chargeback. A cancelled account that keeps getting charged generates legal exposure. A failed upgrade that doesn't grant access creates a support crisis.
Unlike UI bugs, billing bugs often go unnoticed for weeks. The customer didn't realize they were double-charged. The subscription didn't actually cancel server-side even though it looked cancelled in the UI. The downgraded account still has access to premium features.
Testing billing flows is not glamorous work. It's essential work.
What to Test in Every Billing Flow
Before writing any code, define the states you need to verify for each transition:
| Transition | Verify in Database | Verify in Stripe | Verify in App |
|---|---|---|---|
| Upgrade | Plan updated, billing date updated | Subscription item changed, proration created | Feature access granted immediately |
| Downgrade | Plan updated, effective date set | Schedule created or immediate change | Feature access revoked at correct time |
| Cancellation | Cancelled status, end date set | Cancel at period end or immediately | Access until end of period |
| Reactivation | Active status restored | Subscription reactivated | Full feature access restored |
| Failed payment | Past-due status | Invoice status, retry schedule | Appropriate access restriction |
Setting Up a Billing Test Environment
Never test against your live Stripe account. Use Stripe's test mode with test card numbers:
// test/helpers/billing.js
const stripe = require('stripe')(process.env.STRIPE_TEST_SECRET_KEY);
async function createTestSubscription(tenantId, planId) {
const customer = await stripe.customers.create({
email: `test-${tenantId}@example.com`,
payment_method: 'pm_card_visa', // Stripe test payment method
invoice_settings: { default_payment_method: 'pm_card_visa' },
metadata: { tenantId },
});
const subscription = await stripe.subscriptions.create({
customer: customer.id,
items: [{ price: planId }],
expand: ['latest_invoice.payment_intent'],
});
return { customer, subscription };
}Testing Upgrade Flows
An upgrade has three components: the Stripe subscription change, the database update, and the application-layer access change.
Stripe Subscription Upgrade
describe('Subscription upgrade', () => {
let tenant, starterSub;
beforeEach(async () => {
tenant = await createTestTenant({ plan: 'starter' });
starterSub = await createTestSubscription(tenant.id, PLANS.starter.priceId);
await linkSubscriptionToTenant(tenant.id, starterSub.subscription.id);
});
afterEach(async () => {
await stripe.subscriptions.cancel(starterSub.subscription.id);
await deleteTestTenant(tenant.id);
});
it('immediately changes the Stripe subscription to professional plan', async () => {
await request(app)
.post('/api/billing/upgrade')
.send({ planId: 'professional' })
.set('Authorization', `Bearer ${tenant.token}`);
const updatedSub = await stripe.subscriptions.retrieve(starterSub.subscription.id);
const currentPriceId = updatedSub.items.data[0].price.id;
expect(currentPriceId).toBe(PLANS.professional.priceId);
});
it('grants professional features immediately after upgrade', async () => {
await request(app)
.post('/api/billing/upgrade')
.send({ planId: 'professional' })
.set('Authorization', `Bearer ${tenant.token}`);
const featureResponse = await request(app)
.get('/api/features/advanced-analytics')
.set('Authorization', `Bearer ${tenant.token}`);
expect(featureResponse.status).toBe(200);
expect(featureResponse.body.enabled).toBe(true);
});
it('creates a proration invoice for mid-cycle upgrade', async () => {
await request(app)
.post('/api/billing/upgrade')
.send({ planId: 'professional' })
.set('Authorization', `Bearer ${tenant.token}`);
const invoices = await stripe.invoices.list({
customer: starterSub.customer.id,
limit: 5,
});
const prorationInvoice = invoices.data.find(inv =>
inv.lines.data.some(line => line.proration)
);
expect(prorationInvoice).toBeDefined();
});
});Testing Downgrade Flows
Downgrades are harder to test because they often take effect at the end of a billing period. You need to verify both the scheduled state and the post-transition state.
describe('Subscription downgrade', () => {
it('schedules downgrade at end of billing period', async () => {
await request(app)
.post('/api/billing/downgrade')
.send({ planId: 'starter' })
.set('Authorization', `Bearer ${professionalTenant.token}`);
// Check database: should show pending downgrade, not immediate plan change
const tenantRecord = await getTenantFromDb(professionalTenant.id);
expect(tenantRecord.currentPlan).toBe('professional'); // still professional
expect(tenantRecord.pendingPlan).toBe('starter');
expect(tenantRecord.pendingPlanEffectiveDate).toBeDefined();
});
it('maintains professional features until billing period ends', async () => {
await request(app)
.post('/api/billing/downgrade')
.send({ planId: 'starter' })
.set('Authorization', `Bearer ${professionalTenant.token}`);
const response = await request(app)
.get('/api/features/advanced-analytics')
.set('Authorization', `Bearer ${professionalTenant.token}`);
expect(response.body.enabled).toBe(true); // still enabled until period ends
});
it('revokes premium features after downgrade takes effect', async () => {
// Simulate billing period end by triggering Stripe webhook
await simulateStripeWebhook('customer.subscription.updated', {
id: professionalTenant.subscriptionId,
items: { data: [{ price: { id: PLANS.starter.priceId } }] },
status: 'active',
});
const response = await request(app)
.get('/api/features/advanced-analytics')
.set('Authorization', `Bearer ${professionalTenant.token}`);
expect(response.body.enabled).toBe(false); // revoked after downgrade
});
});Testing Webhook Handlers
Stripe communicates subscription changes via webhooks. Your webhook handler is the critical integration point — if it fails, your database and Stripe get out of sync.
// test/helpers/stripe-webhooks.js
async function simulateStripeWebhook(eventType, payload) {
const event = {
id: `evt_test_${Date.now()}`,
type: eventType,
data: { object: payload },
};
// Sign the event as Stripe would
const timestamp = Math.floor(Date.now() / 1000);
const signature = stripe.webhooks.generateTestHeaderString({
payload: JSON.stringify(event),
secret: process.env.STRIPE_WEBHOOK_SECRET,
});
return request(app)
.post('/api/webhooks/stripe')
.set('stripe-signature', signature)
.send(event);
}
describe('Stripe webhook handler', () => {
it('handles customer.subscription.updated', async () => {
const response = await simulateStripeWebhook('customer.subscription.updated', {
id: tenant.subscriptionId,
status: 'active',
items: { data: [{ price: { id: PLANS.professional.priceId } }] },
});
expect(response.status).toBe(200);
const tenant = await getTenantFromDb(tenant.id);
expect(tenant.plan).toBe('professional');
});
it('handles customer.subscription.deleted (cancellation)', async () => {
const response = await simulateStripeWebhook('customer.subscription.deleted', {
id: tenant.subscriptionId,
status: 'canceled',
canceled_at: Math.floor(Date.now() / 1000),
});
expect(response.status).toBe(200);
const tenant = await getTenantFromDb(tenant.id);
expect(tenant.subscriptionStatus).toBe('cancelled');
});
it('rejects webhook events with invalid signatures', async () => {
const response = await request(app)
.post('/api/webhooks/stripe')
.set('stripe-signature', 'invalid-signature')
.send({ type: 'customer.subscription.updated' });
expect(response.status).toBe(400);
});
it('is idempotent for duplicate webhook events', async () => {
const event = {
id: 'evt_test_idempotency_check',
type: 'customer.subscription.updated',
data: { object: { id: tenant.subscriptionId, status: 'active' } },
};
const response1 = await postSignedWebhook(event);
const response2 = await postSignedWebhook(event); // duplicate
expect(response1.status).toBe(200);
expect(response2.status).toBe(200); // should not throw on duplicate
// Verify database wasn't double-updated
const updates = await getWebhookEventLog(event.id);
expect(updates).toHaveLength(1);
});
});Testing Cancellation Flows
describe('Subscription cancellation', () => {
it('cancels at period end by default', async () => {
await request(app)
.post('/api/billing/cancel')
.set('Authorization', `Bearer ${tenant.token}`);
const sub = await stripe.subscriptions.retrieve(tenant.subscriptionId);
expect(sub.cancel_at_period_end).toBe(true);
expect(sub.status).toBe('active'); // still active until end of period
});
it('maintains access until billing period ends after cancellation', async () => {
await request(app)
.post('/api/billing/cancel')
.set('Authorization', `Bearer ${tenant.token}`);
const response = await request(app)
.get('/api/dashboard')
.set('Authorization', `Bearer ${tenant.token}`);
expect(response.status).toBe(200); // still accessible
});
it('revokes access after final billing period', async () => {
// Simulate the subscription expiring
await simulateStripeWebhook('customer.subscription.deleted', {
id: tenant.subscriptionId,
status: 'canceled',
});
const response = await request(app)
.get('/api/dashboard')
.set('Authorization', `Bearer ${tenant.token}`);
expect(response.status).toBe(402); // or redirect to upgrade page
});
});Testing Dunning (Failed Payment Recovery)
describe('Dunning flow', () => {
it('restricts access when subscription becomes past_due', async () => {
await simulateStripeWebhook('customer.subscription.updated', {
id: tenant.subscriptionId,
status: 'past_due',
});
const response = await request(app)
.get('/api/export')
.set('Authorization', `Bearer ${tenant.token}`);
// Premium features locked, but basic access maintained
expect(response.status).toBe(402);
expect(response.body.reason).toBe('payment_past_due');
});
it('restores access when payment succeeds after dunning', async () => {
// First, set to past_due
await simulateStripeWebhook('customer.subscription.updated', {
id: tenant.subscriptionId, status: 'past_due',
});
// Then payment succeeds
await simulateStripeWebhook('invoice.payment_succeeded', {
subscription: tenant.subscriptionId,
status: 'paid',
});
const response = await request(app)
.get('/api/export')
.set('Authorization', `Bearer ${tenant.token}`);
expect(response.status).toBe(200); // access restored
});
});Key Takeaways
- Always test against Stripe's test mode — never production
- Verify all three layers: Stripe state, database state, and application access
- Webhook handler tests are the most important billing tests you'll write
- Test idempotency: Stripe can send the same webhook event multiple times
- Downgrade and cancellation tests must verify both "during period" and "after period" states
- Dunning tests verify your grace period logic works as intended