SaaS Billing Testing: How to Test Subscriptions, Usage, and Webhooks

SaaS Billing Testing: How to Test Subscriptions, Usage, and Webhooks

Billing is the highest-stakes area of a SaaS application. Bugs here cost real money — charging the wrong amount, failing to cancel subscriptions, missing usage records, or double-billing. Testing billing requires testing the logic layer (unit tests), the webhook handlers (integration tests), and the full lifecycle end-to-end.

Billing Testing Layers

Unit tests: Subscription logic, proration calculations, usage aggregation — pure functions, no Stripe API.

Integration tests: Webhook handlers, subscription state transitions — use Stripe test mode or mock.

E2E tests: Full billing lifecycle from signup to cancellation — Stripe test mode with real API calls.

Unit Testing Billing Logic

// billing/proration.js
export function calculateProration({ oldPlan, newPlan, daysRemaining, daysInPeriod }) {
  const remainingRatio = daysRemaining / daysInPeriod
  const creditForOld = oldPlan.price * remainingRatio
  const chargeForNew = newPlan.price * remainingRatio
  return Math.round((chargeForNew - creditForOld) * 100) / 100
}

// billing/proration.test.js
test('calculates proration when upgrading', () => {
  const proration = calculateProration({
    oldPlan: { price: 29 },
    newPlan: { price: 79 },
    daysRemaining: 15,
    daysInPeriod: 30,
  })
  
  // Upgrade: $79 * 0.5 - $29 * 0.5 = $39.50 - $14.50 = $25.00
  expect(proration).toBe(25.00)
})

test('calculates proration when downgrading', () => {
  const proration = calculateProration({
    oldPlan: { price: 79 },
    newPlan: { price: 29 },
    daysRemaining: 15,
    daysInPeriod: 30,
  })
  
  // Downgrade: negative means credit
  expect(proration).toBe(-25.00)
})

test('no proration on last day of period', () => {
  const proration = calculateProration({
    oldPlan: { price: 29 },
    newPlan: { price: 79 },
    daysRemaining: 0,
    daysInPeriod: 30,
  })
  
  expect(proration).toBe(0)
})

Testing Usage-Based Billing

// billing/usage.js
export async function recordUsage(tenantId, quantity) {
  const subscription = await db.subscriptions.findActive(tenantId)
  if (!subscription) throw new Error('No active subscription')
  
  await db.usageRecords.create({
    tenantId,
    subscriptionId: subscription.id,
    quantity,
    recordedAt: new Date(),
  })
  
  const totalThisMonth = await db.usageRecords.sumForMonth(tenantId)
  
  if (totalThisMonth > subscription.plan.limit) {
    await notifyUsageLimitApproach(tenantId, totalThisMonth, subscription.plan.limit)
  }
}

// billing/usage.test.js
test('records usage event', async () => {
  db.subscriptions.findActive.mockResolvedValue({
    id: 'sub-1',
    plan: { limit: 1000 }
  })
  db.usageRecords.create.mockResolvedValue(undefined)
  db.usageRecords.sumForMonth.mockResolvedValue(100)
  
  await recordUsage('tenant-a', 50)
  
  expect(db.usageRecords.create).toHaveBeenCalledWith({
    tenantId: 'tenant-a',
    subscriptionId: 'sub-1',
    quantity: 50,
    recordedAt: expect.any(Date),
  })
})

test('notifies when usage exceeds limit', async () => {
  db.subscriptions.findActive.mockResolvedValue({
    id: 'sub-1',
    plan: { limit: 100 }
  })
  db.usageRecords.sumForMonth.mockResolvedValue(150)  // Over limit
  notifyUsageLimitApproach.mockResolvedValue(undefined)
  
  await recordUsage('tenant-a', 10)
  
  expect(notifyUsageLimitApproach).toHaveBeenCalledWith('tenant-a', 150, 100)
})

Testing Stripe Webhook Handlers

Stripe sends events for subscription lifecycle: customer.subscription.created, invoice.payment_succeeded, invoice.payment_failed, etc.

// webhooks/stripe-handler.js
export async function handleStripeWebhook(event) {
  switch (event.type) {
    case 'invoice.payment_succeeded':
      await handlePaymentSucceeded(event.data.object)
      break
    case 'invoice.payment_failed':
      await handlePaymentFailed(event.data.object)
      break
    case 'customer.subscription.deleted':
      await handleSubscriptionCancelled(event.data.object)
      break
    default:
      console.log(`Unhandled event type: ${event.type}`)
  }
}

async function handlePaymentFailed(invoice) {
  const tenant = await db.tenants.findByStripeCustomerId(invoice.customer)
  if (!tenant) return
  
  await db.tenants.update(tenant.id, { status: 'payment_failed' })
  await emailService.sendPaymentFailedNotification(tenant.email)
}
// webhooks/stripe-handler.test.js
import { stripeInvoicePaymentFailed } from './fixtures/stripe-events'

test('handles payment_failed: marks tenant and sends email', async () => {
  db.tenants.findByStripeCustomerId.mockResolvedValue({
    id: 'tenant-1',
    email: 'admin@company.com',
    status: 'active'
  })
  
  await handleStripeWebhook(stripeInvoicePaymentFailed)
  
  expect(db.tenants.update).toHaveBeenCalledWith('tenant-1', { status: 'payment_failed' })
  expect(emailService.sendPaymentFailedNotification).toHaveBeenCalledWith('admin@company.com')
})

Store real Stripe event payloads as fixtures:

// webhooks/fixtures/stripe-events.js
export const stripeInvoicePaymentFailed = {
  id: 'evt_test_123',
  type: 'invoice.payment_failed',
  data: {
    object: {
      id: 'in_test_456',
      customer: 'cus_test_abc',
      amount_due: 9900,
      currency: 'usd',
      status: 'open',
    }
  }
}

Testing Webhook Signature Verification

test('rejects webhook with invalid signature', async () => {
  const response = await request(app)
    .post('/webhooks/stripe')
    .set('Stripe-Signature', 'invalid-sig')
    .send(JSON.stringify(stripeInvoicePaymentFailed))
  
  expect(response.status).toBe(400)
})

test('accepts webhook with valid signature', async () => {
  const payload = JSON.stringify(stripeInvoicePaymentFailed)
  const timestamp = Math.floor(Date.now() / 1000)
  const sig = stripe.webhooks.generateTestHeaderString({
    payload,
    secret: process.env.STRIPE_WEBHOOK_SECRET,
  })
  
  const response = await request(app)
    .post('/webhooks/stripe')
    .set('Stripe-Signature', sig)
    .set('Content-Type', 'application/json')
    .send(payload)
  
  expect(response.status).toBe(200)
})

Testing Idempotent Webhook Processing

Stripe may send the same event multiple times. Your handlers must be idempotent:

test('processing same event twice does not double-charge', async () => {
  const event = stripeInvoicePaymentSucceeded
  
  await handleStripeWebhook(event)
  await handleStripeWebhook(event)  // Second delivery
  
  // Should only update once
  expect(db.tenants.update).toHaveBeenCalledTimes(1)
})

Testing Trial Expiry

test('trial expiry downgrades account to free plan', async () => {
  const tenant = await db.tenants.create({
    plan: 'trial',
    trialEndsAt: new Date(Date.now() - 1000)  // Yesterday
  })
  
  await processTrialExpiry()
  
  const updated = await db.tenants.findById(tenant.id)
  expect(updated.plan).toBe('free')
  expect(updated.status).toBe('active')
})

Summary

Billing testing requires unit tests for calculation logic, webhook handler tests with realistic Stripe payloads, and idempotency tests for double-delivery scenarios. Keep Stripe payloads as JSON fixtures — they're the contract between Stripe and your system. Test every subscription lifecycle event: creation, renewal success, renewal failure, cancellation, trial expiry. Any billing bug that makes it to production will cost either money or customer trust.

Read more