Testing Subscription Billing Flows: Stripe, Upgrades, and Edge Cases

Testing Subscription Billing Flows: Stripe, Upgrades, and Edge Cases

Billing bugs are the most expensive bugs in SaaS. A mishandled subscription upgrade that charges users incorrectly, a webhook that processes twice, or a dunning flow that locks out paying customers — each of these can generate support tickets, chargebacks, and churn that costs far more than the engineering time to prevent them.

Stripe makes subscription billing easier than rolling your own, but it does not make it simple. There are dozens of lifecycle events, edge cases around trial periods, proration calculations, and webhook delivery guarantees that create a sprawling test surface. This guide walks you through a systematic approach to testing every critical path in a Stripe subscription integration.

Setting Up the Test Environment

Before writing any tests, configure your environment correctly. Stripe provides a test mode with test API keys and a comprehensive set of test card numbers that trigger specific behaviors.

# .env.test
STRIPE_SECRET_KEY=sk_test_...
STRIPE_WEBHOOK_SECRET=whsec_...
STRIPE_PUBLISHABLE_KEY=pk_test_...

<span class="hljs-comment"># Stripe CLI for local webhook forwarding
stripe listen --forward-to localhost:3000/webhooks/stripe

Install the Stripe CLI and use it to forward webhook events to your local server during development. For CI, you will replay webhook events directly rather than relying on live forwarding.

// stripe-test-helper.js
const Stripe = require('stripe');
const stripe = Stripe(process.env.STRIPE_SECRET_KEY);

async function createTestCustomer(email) {
  return stripe.customers.create({
    email,
    payment_method: 'pm_card_visa',
    invoice_settings: { default_payment_method: 'pm_card_visa' },
  });
}

async function createTestSubscription(customerId, priceId) {
  return stripe.subscriptions.create({
    customer: customerId,
    items: [{ price: priceId }],
    payment_behavior: 'default_incomplete',
    expand: ['latest_invoice.payment_intent'],
  });
}

module.exports = { createTestCustomer, createTestSubscription };

Testing Webhook Handling

Webhooks are the backbone of Stripe integration. Your application must handle them idempotently — the same event may be delivered multiple times, and your handler must not double-process.

Webhook Signature Verification

// webhook.test.js
const crypto = require('crypto');

function buildStripeWebhookRequest(payload, secret) {
  const timestamp = Math.floor(Date.now() / 1000);
  const signedPayload = `${timestamp}.${JSON.stringify(payload)}`;
  const signature = crypto
    .createHmac('sha256', secret)
    .update(signedPayload)
    .digest('hex');

  return {
    body: JSON.stringify(payload),
    headers: {
      'stripe-signature': `t=${timestamp},v1=${signature}`,
      'content-type': 'application/json',
    },
  };
}

describe('Stripe Webhook Handler', () => {
  it('should reject requests with invalid signatures', async () => {
    const { body, headers } = buildStripeWebhookRequest(
      { type: 'invoice.payment_succeeded' },
      'wrong-secret'
    );

    const res = await request(app)
      .post('/webhooks/stripe')
      .set(headers)
      .send(body);

    expect(res.status).toBe(400);
  });

  it('should return 200 for valid webhook even if event type is unknown', async () => {
    const { body, headers } = buildStripeWebhookRequest(
      { type: 'unknown.future.event', data: { object: {} } },
      process.env.STRIPE_WEBHOOK_SECRET
    );

    const res = await request(app)
      .post('/webhooks/stripe')
      .set(headers)
      .send(body);

    expect(res.status).toBe(200);
  });
});

Idempotency Testing

describe('Webhook Idempotency', () => {
  it('should not process the same event twice', async () => {
    const eventId = 'evt_test_' + Date.now();
    const webhookPayload = {
      id: eventId,
      type: 'invoice.payment_succeeded',
      data: {
        object: {
          id: 'in_test123',
          subscription: 'sub_test123',
          customer: 'cus_test123',
          amount_paid: 4900,
          status: 'paid',
        },
      },
    };

    const sendWebhook = async () => {
      const { body, headers } = buildStripeWebhookRequest(
        webhookPayload,
        process.env.STRIPE_WEBHOOK_SECRET
      );
      return request(app).post('/webhooks/stripe').set(headers).send(body);
    };

    await sendWebhook();
    await sendWebhook();

    const payments = await db('payments').where({ stripe_event_id: eventId });
    expect(payments.length).toBe(1);
  });
});

Testing the Subscription Lifecycle

Creating a Subscription

describe('Subscription Creation', () => {
  it('should create a subscription and activate the account on payment success', async () => {
    const user = await createUser('new@example.com');

    const res = await request(app)
      .post('/api/subscriptions')
      .set('Authorization', `Bearer ${user.token}`)
      .send({
        price_id: process.env.STRIPE_PRO_PRICE_ID,
        payment_method: 'pm_card_visa',
      });

    expect(res.status).toBe(200);
    expect(res.body.subscription_id).toBeDefined();
    expect(res.body.status).toBe('active');

    const updatedUser = await db('users').where({ id: user.id }).first();
    expect(updatedUser.plan).toBe('pro');
    expect(updatedUser.subscription_id).toBe(res.body.subscription_id);
  });

  it('should handle declined cards gracefully', async () => {
    const user = await createUser('declined@example.com');

    const res = await request(app)
      .post('/api/subscriptions')
      .set('Authorization', `Bearer ${user.token}`)
      .send({
        price_id: process.env.STRIPE_PRO_PRICE_ID,
        payment_method: 'pm_card_visa_chargeDeclined',
      });

    expect(res.status).toBe(402);
    expect(res.body.error).toMatch(/declined/i);

    const updatedUser = await db('users').where({ id: user.id }).first();
    expect(updatedUser.plan).toBe('free');
  });
});

Testing Plan Upgrades

Plan upgrades are where proration logic lives. When a user upgrades mid-cycle, Stripe calculates a prorated charge for the remainder of the billing period.

describe('Plan Upgrades', () => {
  it('should upgrade plan immediately and charge prorated amount', async () => {
    const user = await createUserWithSubscription('starter@example.com', 'starter');

    const res = await request(app)
      .post('/api/subscriptions/upgrade')
      .set('Authorization', `Bearer ${user.token}`)
      .send({ new_plan: 'pro' });

    expect(res.status).toBe(200);

    const updatedSub = await stripe.subscriptions.retrieve(user.subscription_id);
    expect(updatedSub.items.data[0].price.id).toBe(process.env.STRIPE_PRO_PRICE_ID);

    const invoices = await stripe.invoices.list({
      subscription: user.subscription_id,
      limit: 1,
    });
    expect(invoices.data[0].billing_reason).toBe('subscription_update');
    expect(invoices.data[0].amount_due).toBeGreaterThan(0);

    const updatedUser = await db('users').where({ id: user.id }).first();
    expect(updatedUser.plan).toBe('pro');
  });

  it('should grant immediate access to pro features after upgrade', async () => {
    const user = await createUserWithSubscription('starter2@example.com', 'starter');

    await request(app)
      .post('/api/subscriptions/upgrade')
      .set('Authorization', `Bearer ${user.token}`)
      .send({ new_plan: 'pro' });

    const featureRes = await request(app)
      .get('/api/advanced-analytics')
      .set('Authorization', `Bearer ${user.token}`);

    expect(featureRes.status).toBe(200);
  });
});

Testing Plan Downgrades

Downgrades are often handled differently — scheduled for end of billing period rather than immediate. Test that access is maintained until the period ends.

describe('Plan Downgrades', () => {
  it('should schedule downgrade at period end, not immediately', async () => {
    const user = await createUserWithSubscription('pro@example.com', 'pro');

    const res = await request(app)
      .post('/api/subscriptions/downgrade')
      .set('Authorization', `Bearer ${user.token}`)
      .send({ new_plan: 'starter' });

    expect(res.status).toBe(200);
    expect(res.body.effective_date).toBeDefined();

    const currentUser = await db('users').where({ id: user.id }).first();
    expect(currentUser.plan).toBe('pro');

    const sub = await stripe.subscriptions.retrieve(user.subscription_id);
    expect(sub.cancel_at_period_end).toBe(false);
    expect(sub.schedule).toBeDefined();
  });
});

Testing Cancellation

describe('Subscription Cancellation', () => {
  it('should cancel at period end and revoke access after expiry', async () => {
    const user = await createUserWithSubscription('cancel@example.com', 'pro');

    const res = await request(app)
      .delete('/api/subscriptions')
      .set('Authorization', `Bearer ${user.token}`);

    expect(res.status).toBe(200);
    expect(res.body.cancel_at_period_end).toBe(true);
    expect(res.body.access_until).toBeDefined();

    const featureRes = await request(app)
      .get('/api/advanced-analytics')
      .set('Authorization', `Bearer ${user.token}`);
    expect(featureRes.status).toBe(200);

    await simulateWebhook('customer.subscription.deleted', {
      id: user.subscription_id,
      customer: user.stripe_customer_id,
      status: 'canceled',
    });

    const postCancelRes = await request(app)
      .get('/api/advanced-analytics')
      .set('Authorization', `Bearer ${user.token}`);
    expect(postCancelRes.status).toBe(402);
  });
});

Testing Failed Payments and Dunning

Payment failures are inevitable. Your dunning workflow — retrying charges, notifying users, and eventually downgrading or suspending accounts — is critical to revenue recovery.

describe('Failed Payment Handling', () => {
  it('should send dunning email on first payment failure', async () => {
    const emailSpy = jest.spyOn(emailService, 'send');

    await simulateWebhook('invoice.payment_failed', {
      id: 'in_failed_001',
      subscription: 'sub_test123',
      customer: 'cus_test123',
      attempt_count: 1,
      next_payment_attempt: Math.floor(Date.now() / 1000) + 86400 * 3,
      amount_due: 4900,
    });

    expect(emailSpy).toHaveBeenCalledWith(
      expect.objectContaining({
        template: 'payment_failed_soft',
        to: 'user@example.com',
      })
    );
  });

  it('should downgrade account after max retry attempts', async () => {
    const user = await getUserByStripeCustomerId('cus_test123');

    await simulateWebhook('invoice.payment_failed', {
      id: 'in_failed_final',
      subscription: user.subscription_id,
      customer: user.stripe_customer_id,
      attempt_count: 4,
      next_payment_attempt: null,
      amount_due: 4900,
    });

    const updatedUser = await db('users').where({ id: user.id }).first();
    expect(updatedUser.plan).toBe('free');
    expect(updatedUser.subscription_status).toBe('past_due');
  });

  it('should restore access immediately when payment recovers', async () => {
    await db('users').where({ id: testUserId }).update({
      plan: 'free',
      subscription_status: 'past_due',
    });

    await simulateWebhook('invoice.payment_succeeded', {
      id: 'in_recovered',
      subscription: testSubscriptionId,
      customer: testCustomerId,
      billing_reason: 'subscription_cycle',
      amount_paid: 4900,
    });

    const user = await db('users').where({ id: testUserId }).first();
    expect(user.plan).toBe('pro');
    expect(user.subscription_status).toBe('active');
  });
});

Testing Trial Periods

describe('Trial Period Logic', () => {
  it('should create subscription in trialing state without charging', async () => {
    const res = await request(app)
      .post('/api/subscriptions/trial')
      .set('Authorization', `Bearer ${newUser.token}`)
      .send({ price_id: process.env.STRIPE_PRO_PRICE_ID });

    expect(res.body.status).toBe('trialing');
    expect(res.body.trial_end).toBeDefined();

    const charges = await stripe.charges.list({ customer: newUser.stripe_customer_id });
    expect(charges.data.length).toBe(0);
  });

  it('should convert trial to paid subscription at trial end', async () => {
    await simulateWebhook('customer.subscription.trial_will_end', {
      id: trialSubscriptionId,
      status: 'trialing',
      trial_end: Math.floor(Date.now() / 1000) + 86400 * 3,
    });

    expect(emailService.send).toHaveBeenCalledWith(
      expect.objectContaining({ template: 'trial_ending_soon' })
    );

    await simulateWebhook('customer.subscription.updated', {
      id: trialSubscriptionId,
      status: 'active',
      trial_end: Math.floor(Date.now() / 1000),
    });

    const user = await db('users').where({ stripe_customer_id: testCustomerId }).first();
    expect(user.plan).toBe('pro');
    expect(user.trial_active).toBe(false);
  });
});

Proration Edge Cases

Proration is notoriously tricky. Always test mid-cycle upgrades, mid-cycle downgrades, and upgrades that happen on the exact billing date.

describe('Proration Edge Cases', () => {
  it('should calculate correct proration for mid-cycle upgrade', async () => {
    jest.useFakeTimers().setSystemTime(midCycleDate);

    const prorationPreview = await request(app)
      .get('/api/subscriptions/upgrade-preview')
      .set('Authorization', `Bearer ${user.token}`)
      .query({ new_plan: 'pro' });

    const expectedProration = Math.round((PRO_PRICE - STARTER_PRICE) * (15 / 30));
    expect(prorationPreview.body.amount_due).toBeCloseTo(expectedProration, -2);

    jest.useRealTimers();
  });
});

Building a Billing Test Matrix

The most reliable approach to billing test coverage is maintaining an explicit test matrix that maps every Stripe event type to expected application behavior:

Event Expected Behavior Test Priority
checkout.session.completed Activate subscription, send welcome email Critical
invoice.payment_succeeded Update billing date, send receipt Critical
invoice.payment_failed (attempt 1) Send soft dunning email High
invoice.payment_failed (attempt 4) Downgrade to free, send final warning Critical
customer.subscription.updated Sync plan changes to DB High
customer.subscription.deleted Revoke access, send cancellation confirmation Critical
customer.subscription.trial_will_end Send trial ending email Medium

Map every row of this matrix to an automated test. Billing logic that is not tested is billing logic that will eventually fail in production — and billing failures cost real money.

The hidden danger in subscription billing is not the happy path — it is the event your webhook handler silently ignores. Every unhandled Stripe event that touches billing state is a latent production incident. Build the matrix, write the tests, and run them on every deploy.

Read more