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.