Testing Stripe Subscriptions and Billing: Proration, Upgrades, and Cancellation

Testing Stripe Subscriptions and Billing: Proration, Upgrades, and Cancellation

Stripe subscription testing requires simulating billing cycles, plan changes, and edge cases that only appear over time. Stripe's test mode lets you fast-forward time, trigger immediate renewals, and test proration calculations without waiting for real billing cycles. This guide covers how to test subscription creation, upgrades, cancellations, trial periods, and failed renewals.

Key Takeaways

Use test clocks to simulate time. Stripe test clocks let you advance time in test mode without waiting. Fast-forward to the trial end date, renewal date, or billing period boundary to trigger the events you want to test.

Proration happens server-side — test the amount, not your calculation. When you upgrade a subscription mid-period, Stripe calculates proration automatically. Your test should assert the resulting invoice amount, not re-implement Stripe's proration math.

Test the failed renewal scenario. A card that works for initial payment may decline on renewal. Use pm_card_chargeDeclinedInsufficientFunds for the renewal payment method and verify your dunning logic triggers.

Test "cancel at period end" vs "cancel immediately." These two cancellation modes have very different user experience implications. Most teams only test one and discover the other in production when a customer complains.

Subscription webhooks have more event types than payment webhooks. customer.subscription.updated, invoice.payment_failed, customer.subscription.deleted — all require separate handling and testing.

Why Subscription Testing Is Complex

One-time payments are simple: card charged, webhook fires, order created. Subscriptions involve:

  • Recurring billing — the same card charged monthly/yearly
  • Plan changes — upgrades and downgrades with proration
  • Trial periods — free access followed by the first charge
  • Failed renewals — the dunning cycle when renewal payments fail
  • Cancellations — immediate or at period end
  • Reactivations — canceled subscriptions brought back to active

Each of these generates different webhook events and requires different handling logic. Testing all of them manually at real billing intervals would take months. Stripe's test tools compress this to minutes.

Test Setup

import stripe
import pytest

stripe.api_key = "sk_test_your_test_key"


@pytest.fixture
def test_customer():
    """Create a test customer with a working payment method."""
    customer = stripe.Customer.create(
        email="test@example.com",
        payment_method="pm_card_visa",
    )
    stripe.Customer.modify(
        customer.id,
        invoice_settings={"default_payment_method": "pm_card_visa"},
    )
    yield customer
    # Cleanup
    stripe.Customer.delete(customer.id)


@pytest.fixture
def price_monthly():
    """Get or create a monthly subscription price."""
    # In real tests, use price IDs from your Stripe test configuration
    return "price_test_monthly_basic"

Testing Subscription Creation

def test_subscription_creation(test_customer, price_monthly):
    sub = stripe.Subscription.create(
        customer=test_customer.id,
        items=[{"price": price_monthly}],
    )
    
    assert sub.status == "active"
    assert len(sub.items.data) == 1
    assert sub.items.data[0].price.id == price_monthly
    assert sub.current_period_end > sub.current_period_start
    
    # Verify initial invoice was created and paid
    invoices = stripe.Invoice.list(customer=test_customer.id)
    first_invoice = invoices.data[0]
    assert first_invoice.status == "paid"
    assert first_invoice.subscription == sub.id

Testing Subscription Upgrades with Proration

When a customer upgrades from a basic to a pro plan mid-billing-cycle, Stripe creates a proration invoice. Test that the correct amount is charged:

def test_upgrade_creates_proration_invoice(test_customer):
    # Start on basic plan
    sub = stripe.Subscription.create(
        customer=test_customer.id,
        items=[{"price": "price_test_monthly_basic"}],
    )
    assert sub.status == "active"
    
    # Upgrade to pro plan immediately
    updated_sub = stripe.Subscription.modify(
        sub.id,
        items=[{
            "id": sub.items.data[0].id,
            "price": "price_test_monthly_pro",
        }],
        proration_behavior="create_prorations",
    )
    
    assert updated_sub.status == "active"
    assert updated_sub.items.data[0].price.id == "price_test_monthly_pro"
    
    # Proration invoice should be created
    invoices = stripe.Invoice.list(customer=test_customer.id, limit=5)
    proration_invoice = next(
        (inv for inv in invoices.data if inv.billing_reason == "subscription_update"),
        None,
    )
    assert proration_invoice is not None
    assert proration_invoice.status in ("paid", "open")


def test_upgrade_preview_proration():
    """Preview the proration amount before actually upgrading."""
    # This is useful in the frontend to show "you'll be charged $X today"
    upcoming = stripe.Invoice.upcoming(
        customer="cus_existing_customer",
        subscription="sub_existing",
        subscription_items=[{
            "id": "si_existing_item",
            "price": "price_test_monthly_pro",
        }],
    )
    
    # The upcoming invoice shows what the customer will be charged
    assert upcoming.amount_due >= 0

Testing Trial Periods with Test Clocks

Trial periods are where test clocks become essential. Instead of waiting 14 days for a trial to end, advance the test clock:

def test_trial_converts_to_paid(test_customer):
    # Create a test clock (test mode only)
    test_clock = stripe.test_helpers.TestClock.create(
        frozen_time=int(time.time()),
    )
    
    # Create customer attached to the test clock
    customer = stripe.Customer.create(
        email="trial@example.com",
        payment_method="pm_card_visa",
        test_clock=test_clock.id,
    )
    stripe.Customer.modify(
        customer.id,
        invoice_settings={"default_payment_method": "pm_card_visa"},
    )
    
    # Create subscription with 14-day trial
    sub = stripe.Subscription.create(
        customer=customer.id,
        items=[{"price": "price_test_monthly_pro"}],
        trial_period_days=14,
    )
    
    assert sub.status == "trialing"
    assert sub.trial_end is not None
    
    # Advance the test clock past the trial end
    stripe.test_helpers.TestClock.advance(
        test_clock.id,
        frozen_time=int(time_clock.frozen_time) + (15 * 24 * 60 * 60),  # +15 days
    )
    
    # Wait for the clock to advance (async operation)
    import time as time_module
    for _ in range(10):
        clock = stripe.test_helpers.TestClock.retrieve(test_clock.id)
        if clock.status == "ready":
            break
        time_module.sleep(0.5)
    
    # Retrieve updated subscription
    updated_sub = stripe.Subscription.retrieve(sub.id)
    assert updated_sub.status == "active"  # Trial ended, now active
    
    # Check that the first invoice was created
    invoices = stripe.Invoice.list(customer=customer.id)
    assert any(inv.billing_reason == "subscription_cycle" for inv in invoices.data)
    
    # Cleanup
    stripe.test_helpers.TestClock.delete(test_clock.id)

Testing Failed Renewal Payments

The dunning cycle — what happens when a renewal payment fails — is one of the most important and undertested flows:

def test_failed_renewal_payment():
    # Create customer with a card that will decline on charge
    customer = stripe.Customer.create(
        email="dunning@example.com",
        payment_method="pm_card_chargeDeclinedInsufficientFunds",
    )
    stripe.Customer.modify(
        customer.id,
        invoice_settings={
            "default_payment_method": "pm_card_chargeDeclinedInsufficientFunds"
        },
    )
    
    # Create a subscription
    try:
        sub = stripe.Subscription.create(
            customer=customer.id,
            items=[{"price": "price_test_monthly_basic"}],
        )
    except stripe.error.CardError:
        # Initial payment failed — verify your signup error handling
        pytest.skip("Test requires separate handling of initial payment failure")
    
    # For testing renewal failure specifically, create a subscription
    # with a trial, then let the trial end with a bad card
    # (or use test clocks to simulate the renewal cycle)
    
    # Alternatively, directly test the invoice payment failure webhook
    # by constructing a signed payload (see webhook testing guide)

Testing Cancellation

def test_cancel_at_period_end(test_customer, price_monthly):
    sub = stripe.Subscription.create(
        customer=test_customer.id,
        items=[{"price": price_monthly}],
    )
    assert sub.status == "active"
    
    # Cancel at period end (customer keeps access until billing period ends)
    canceled_sub = stripe.Subscription.modify(
        sub.id,
        cancel_at_period_end=True,
    )
    
    # Status is still active, but cancel_at_period_end is True
    assert canceled_sub.status == "active"
    assert canceled_sub.cancel_at_period_end is True
    assert canceled_sub.cancel_at is not None
    
    # Customer should still have access at this point
    # Your app should check cancel_at_period_end to show "cancels on [date]" UI


def test_cancel_immediately(test_customer, price_monthly):
    sub = stripe.Subscription.create(
        customer=test_customer.id,
        items=[{"price": price_monthly}],
    )
    assert sub.status == "active"
    
    # Cancel immediately
    canceled_sub = stripe.Subscription.delete(sub.id)
    
    # Status is now canceled
    assert canceled_sub.status == "canceled"
    # customer loses access immediately


def test_reactivate_subscription(test_customer, price_monthly):
    sub = stripe.Subscription.create(
        customer=test_customer.id,
        items=[{"price": price_monthly}],
    )
    
    # Schedule cancellation
    stripe.Subscription.modify(sub.id, cancel_at_period_end=True)
    
    # Customer changes their mind — reactivate
    reactivated = stripe.Subscription.modify(
        sub.id,
        cancel_at_period_end=False,
    )
    
    assert reactivated.status == "active"
    assert reactivated.cancel_at_period_end is False

Webhook Events to Test for Subscriptions

Each subscription state change fires a webhook. Test your handler for all of these:

Event When it fires What to test
customer.subscription.created New subscription Welcome email queued, access granted
customer.subscription.updated Plan change Access level updated, proration invoice
customer.subscription.deleted Canceled immediately Access revoked
invoice.payment_succeeded Renewal paid Access renewed, receipt sent
invoice.payment_failed Renewal declined Dunning email queued, access warning
customer.subscription.trial_will_end 3 days before trial ends Reminder email

Write a webhook handler test for each of these. The invoice.payment_failed test is especially important — verify that your dunning email goes to the queue and that access isn't immediately revoked (most apps give a grace period).

Organizing Subscription Tests

tests/
  payments/
    test_subscription_creation.py
    test_subscription_upgrades.py
    test_subscription_cancellation.py
    test_subscription_trials.py
    test_subscription_webhooks.py
    test_dunning_flow.py
  conftest.py  # shared fixtures

Keep subscription tests in separate files by scenario. Subscription tests are slow (they create real Stripe objects) — mark them with @pytest.mark.integration and run them in a separate CI step.

Summary

Stripe subscription testing requires covering more scenarios than one-time payment tests. The essential toolkit is test clocks (for trial periods and renewal cycles), proration preview (for upgrade pricing UI), and a systematic approach to webhook handler testing for each event type. Write tests for cancellation modes separately — "cancel at period end" and "cancel immediately" have different UX implications and different bugs.

Read more