Files
cfw-autumn/server/tests/integration/billing/stripe-webhooks/subscription-deleted/subscription-deleted.test.ts
2026-01-23 08:43:48 +00:00

397 lines
12 KiB
TypeScript

/**
* Customer Subscription Deleted Webhook Tests
*
* Tests for handling the `customer.subscription.deleted` Stripe webhook event.
* These tests simulate canceling subscriptions directly through the Stripe client
* (not through Autumn's cancel API) to verify the webhook handler works correctly.
*
* The webhook handler should:
* - Expire customer products on the deleted subscription
* - Activate default products if available
* - Delete any scheduled products in the same group
*/
import { test } from "bun:test";
import type { ApiCustomerV3 } from "@autumn/shared";
import {
expectCustomerProducts,
expectProductActive,
expectProductCanceling,
expectProductScheduled,
} from "@tests/integration/billing/utils/expectCustomerProductCorrect";
import { expectNoStripeSubscription } from "@tests/integration/billing/utils/expectNoStripeSubscription";
import { getSubscriptionId } from "@tests/integration/billing/utils/stripe/getSubscriptionId";
import { items } from "@tests/utils/fixtures/items";
import { products } from "@tests/utils/fixtures/products";
import { initScenario, s } from "@tests/utils/testInitUtils/initScenario";
import chalk from "chalk";
import { timeout } from "@/utils/genUtils";
// ═══════════════════════════════════════════════════════════════════════════════
// TEST 1: Cancel subscription directly via Stripe (with default free)
// ═══════════════════════════════════════════════════════════════════════════════
/**
* Scenario:
* - Init pro ($20/mo) and free (default) products
* - Attach pro to customer
* - Cancel subscription directly via Stripe client
*
* Expected Result:
* - Pro is removed
* - Free default becomes active
* - No Stripe subscription exists
*/
test(`${chalk.yellowBright("sub.deleted: cancel active subscription via Stripe (with default)")}`, async () => {
const customerId = "sub-deleted-basic";
const messagesItem = items.monthlyMessages({ includedUsage: 100 });
const free = products.base({
id: "free",
items: [messagesItem],
isDefault: true,
});
const pro = products.pro({
id: "pro",
items: [messagesItem],
});
const { autumnV1, ctx } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [free, pro] }),
],
actions: [s.attach({ productId: pro.id })],
});
// Verify pro is active
const customerAfterAttach =
await autumnV1.customers.get<ApiCustomerV3>(customerId);
await expectProductActive({
customer: customerAfterAttach,
productId: pro.id,
});
// Get subscription ID from internal customer data
const subscriptionId = await getSubscriptionId({
ctx,
customerId,
productId: pro.id,
});
// Cancel subscription directly via Stripe client (simulating external cancellation)
await ctx.stripeCli.subscriptions.cancel(subscriptionId);
// Wait for webhook to process
await timeout(8000);
// Verify pro is gone and free is active
const customerAfterCancel =
await autumnV1.customers.get<ApiCustomerV3>(customerId);
await expectCustomerProducts({
customer: customerAfterCancel,
notPresent: [pro.id],
active: [free.id],
});
// Verify no Stripe subscription exists
await expectNoStripeSubscription({
db: ctx.db,
customerId,
org: ctx.org,
env: ctx.env,
});
});
// ═══════════════════════════════════════════════════════════════════════════════
// TEST 2: Cancel subscription after end_of_cycle cancel (via Stripe)
// ═══════════════════════════════════════════════════════════════════════════════
/**
* Scenario:
* - Init pro ($20/mo) and free (default) products
* - Attach pro to customer
* - Cancel pro via Autumn API (end_of_cycle) - Pro is canceling, Free is scheduled
* - Cancel subscription directly via Stripe client (immediate cancellation)
*
* Expected Result:
* - Pro is removed immediately (not waiting for cycle end)
* - Free default becomes active
* - No Stripe subscription exists
*/
test(`${chalk.yellowBright("sub.deleted: cancel after end_of_cycle via Stripe")}`, async () => {
const customerId = "sub-deleted-after-eoc";
const messagesItem = items.monthlyMessages({ includedUsage: 100 });
const free = products.base({
id: "free",
items: [messagesItem],
isDefault: true,
});
const pro = products.pro({
id: "pro",
items: [messagesItem],
});
const { autumnV1, ctx } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [free, pro] }),
],
actions: [s.attach({ productId: pro.id })],
});
// Verify pro is active
const customerAfterAttach =
await autumnV1.customers.get<ApiCustomerV3>(customerId);
await expectProductActive({
customer: customerAfterAttach,
productId: pro.id,
});
// Cancel pro at end of cycle via Autumn API
await autumnV1.subscriptions.update({
customer_id: customerId,
product_id: pro.id,
cancel_action: "cancel_end_of_cycle",
});
// Verify pro is canceling and free is scheduled
const customerAfterEoc =
await autumnV1.customers.get<ApiCustomerV3>(customerId);
await expectProductCanceling({
customer: customerAfterEoc,
productId: pro.id,
});
await expectProductScheduled({
customer: customerAfterEoc,
productId: free.id,
});
await timeout(3000); // wait for lock to be released
// Get subscription ID from internal customer data
const subscriptionId = await getSubscriptionId({
ctx,
customerId,
productId: pro.id,
});
// Cancel subscription directly via Stripe client (simulating external immediate cancellation)
await ctx.stripeCli.subscriptions.cancel(subscriptionId);
// Wait for webhook to process
await timeout(8000);
// Verify pro is gone and free is active
const customerAfterCancel =
await autumnV1.customers.get<ApiCustomerV3>(customerId);
await expectCustomerProducts({
customer: customerAfterCancel,
notPresent: [pro.id],
active: [free.id],
});
// Verify no Stripe subscription exists
await expectNoStripeSubscription({
db: ctx.db,
customerId,
org: ctx.org,
env: ctx.env,
});
});
// ═══════════════════════════════════════════════════════════════════════════════
// TEST 3: Cancel subscription with scheduled downgrade via Stripe
// ═══════════════════════════════════════════════════════════════════════════════
/**
* Scenario:
* - Init premium ($50/mo), pro ($20/mo), and free (default) products
* - Attach premium to customer
* - Attach pro (downgrade) - Premium is canceling, Pro is scheduled
* - Cancel subscription directly via Stripe client
*
* Expected Result:
* - Premium is removed
* - Pro scheduled product is also removed (downgrade cancelled)
* - Free default becomes active
* - No Stripe subscription exists
*/
test(`${chalk.yellowBright("sub.deleted: cancel with scheduled downgrade via Stripe")}`, async () => {
const customerId = "sub-deleted-downgrade";
const messagesItem = items.monthlyMessages({ includedUsage: 100 });
const free = products.base({
id: "free",
items: [messagesItem],
isDefault: true,
});
const pro = products.pro({
id: "pro",
items: [messagesItem],
});
const premiumPriceItem = items.monthlyPrice({ price: 50 });
const premium = products.base({
id: "premium",
items: [messagesItem, premiumPriceItem],
});
const { autumnV1, ctx } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [free, pro, premium] }),
],
actions: [
s.attach({ productId: premium.id }),
s.attach({ productId: pro.id }), // Downgrade: premium canceling, pro scheduled
],
});
// Verify premium is canceling and pro is scheduled
const customerAfterDowngrade =
await autumnV1.customers.get<ApiCustomerV3>(customerId);
await expectProductCanceling({
customer: customerAfterDowngrade,
productId: premium.id,
});
await expectProductScheduled({
customer: customerAfterDowngrade,
productId: pro.id,
});
// Get subscription ID from internal customer data (from premium product)
const subscriptionId = await getSubscriptionId({
ctx,
customerId,
productId: premium.id,
});
// Cancel subscription directly via Stripe client
await ctx.stripeCli.subscriptions.cancel(subscriptionId);
// Wait for webhook to process
await timeout(8000);
// Verify premium and pro are gone, free is active
const customerAfterCancel =
await autumnV1.customers.get<ApiCustomerV3>(customerId);
await expectCustomerProducts({
customer: customerAfterCancel,
notPresent: [premium.id, pro.id],
active: [free.id],
});
// Verify no Stripe subscription exists
await expectNoStripeSubscription({
db: ctx.db,
customerId,
org: ctx.org,
env: ctx.env,
});
});
// ═══════════════════════════════════════════════════════════════════════════════
// TEST 4: Cancel subscription with add-on via Stripe
// ═══════════════════════════════════════════════════════════════════════════════
/**
* Scenario:
* - Init pro ($20/mo), free (default), and monthly add-on ($10/mo) products
* - Attach pro to customer
* - Attach add-on to customer
* - Cancel the subscription directly via Stripe client
*
* Expected Result:
* - Pro is removed
* - Add-on is also removed (on same subscription)
* - Free default becomes active
* - No Stripe subscription exists
*/
test(`${chalk.yellowBright("sub.deleted: cancel subscription with add-on via Stripe")}`, async () => {
const customerId = "sub-deleted-addon";
const messagesItem = items.monthlyMessages({ includedUsage: 100 });
const wordsItem = items.monthlyWords({ includedUsage: 50 });
const free = products.base({
id: "free",
items: [messagesItem],
isDefault: true,
});
const pro = products.pro({
id: "pro",
items: [messagesItem],
});
const addon = products.recurringAddOn({
id: "addon",
items: [wordsItem],
});
const { autumnV1, ctx } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [free, pro, addon] }),
],
actions: [
s.attach({ productId: pro.id }),
s.attach({ productId: addon.id }),
],
});
// Verify pro and addon are active
const customerAfterAttach =
await autumnV1.customers.get<ApiCustomerV3>(customerId);
await expectCustomerProducts({
customer: customerAfterAttach,
active: [pro.id, addon.id],
});
// Get subscription ID from internal customer data (from pro product)
const subscriptionId = await getSubscriptionId({
ctx,
customerId,
productId: pro.id,
});
// Cancel subscription directly via Stripe client
await ctx.stripeCli.subscriptions.cancel(subscriptionId);
// Wait for webhook to process
await timeout(8000);
// Verify pro and addon are gone, free is active
const customerAfterCancel =
await autumnV1.customers.get<ApiCustomerV3>(customerId);
await expectCustomerProducts({
customer: customerAfterCancel,
notPresent: [pro.id, addon.id],
active: [free.id],
});
// Verify no Stripe subscription exists
await expectNoStripeSubscription({
db: ctx.db,
customerId,
org: ctx.org,
env: ctx.env,
});
});