Files
cfw-autumn/server/tests/integration/billing/attach/scheduled-switch/scheduled-switch-discounts.test.ts
2026-02-17 21:22:31 +00:00

721 lines
23 KiB
TypeScript

/**
* Scheduled Switch Discount Tests (Attach V2)
*
* Tests that discounts (coupons) applied to a Stripe subscription are preserved
* when a plan is downgraded via scheduled switch.
*
* The bug: When creating subscription schedule phases in buildStripePhasesUpdate,
* the `discounts` parameter is NOT set on the phases. This means when the subscription
* transitions to the next phase at billing cycle end, discounts are lost.
*
* Key behaviors tested:
* - Percent-off discount persists after scheduling a downgrade
* - Amount-off discount persists after scheduling a downgrade
* - Discount persists after advancing cycle (phase transition)
* - Discount survives when replacing a scheduled downgrade with another
* - Multiple discounts survive scheduling
* - Discount survives upgrade that cancels a scheduled downgrade
*/
import { expect, test } from "bun:test";
import type { ApiCustomerV3 } from "@autumn/shared";
import {
applySubscriptionDiscount,
createAmountCoupon,
createPercentCoupon,
getStripeSubscription,
} from "@tests/integration/billing/utils/discounts/discountTestUtils";
import { expectCustomerFeatureCorrect } from "@tests/integration/billing/utils/expectCustomerFeatureCorrect";
import {
expectCustomerProducts,
expectProductCanceling,
expectProductScheduled,
} from "@tests/integration/billing/utils/expectCustomerProductCorrect";
import { TestFeature } from "@tests/setup/v2Features";
import { items } from "@tests/utils/fixtures/items";
import { products } from "@tests/utils/fixtures/products";
import { advanceToNextInvoice } from "@tests/utils/testAttachUtils/testAttachUtils";
import { initScenario, s } from "@tests/utils/testInitUtils/initScenario";
import chalk from "chalk";
/**
* Helper to extract coupon ID from a Stripe discount object.
* Handles both string and expanded object forms.
*/
const extractCouponId = (discount: unknown): string | null => {
if (typeof discount === "string") return discount;
if (
discount &&
typeof discount === "object" &&
"source" in discount &&
discount.source &&
typeof discount.source === "object" &&
"coupon" in discount.source &&
discount.source.coupon &&
typeof discount.source.coupon === "object" &&
"id" in discount.source.coupon
) {
return discount.source.coupon.id as string;
}
return null;
};
// ═══════════════════════════════════════════════════════════════════════════════
// TEST 1: Premium to Pro with 20% discount - verify discount preserved through cycle
// ═══════════════════════════════════════════════════════════════════════════════
/**
* Scenario:
* - Customer has premium ($50/mo) with 20% off coupon on subscription
* - Downgrade to pro ($20/mo) - scheduled for end of cycle
* - Advance test clock to next billing cycle
*
* Expected Result:
* - Discount still present on Stripe subscription after scheduling the downgrade
* - Premium is canceling, pro is scheduled
* - After cycle: pro is active, premium removed
* - Discount should STILL be on the subscription after the phase transition
*
* THIS TEST EXPOSES THE BUG: subscription schedule phases don't carry discounts,
* so when the subscription transitions to the pro phase, the discount is lost.
*/
test.concurrent(`${chalk.yellowBright("scheduled-switch-discounts 1: 20% discount preserved after scheduling and cycle advance")}`, async () => {
const customerId = "sched-switch-discount-20pct";
const proMessagesItem = items.monthlyMessages({ includedUsage: 500 });
const pro = products.pro({
id: "pro",
items: [proMessagesItem],
});
const premiumMessagesItem = items.monthlyMessages({
includedUsage: 1000,
});
const premium = products.premium({
id: "premium",
items: [premiumMessagesItem],
});
const { autumnV1, ctx, testClockId, advancedTo } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [pro, premium] }),
],
actions: [s.billing.attach({ productId: premium.id })],
});
// Apply 20% discount to the subscription
const { stripeCli, subscription: subBefore } = await getStripeSubscription({
customerId,
});
const coupon = await createPercentCoupon({
stripeCli,
percentOff: 20,
});
await applySubscriptionDiscount({
stripeCli,
subscriptionId: subBefore.id,
couponIds: [coupon.id],
});
// Verify discount is applied before downgrade
const subWithDiscount = await stripeCli.subscriptions.retrieve(subBefore.id, {
expand: ["discounts.source.coupon"],
});
expect(subWithDiscount.discounts?.length).toBeGreaterThanOrEqual(1);
// Schedule downgrade to pro
await autumnV1.billing.attach({
customer_id: customerId,
product_id: pro.id,
redirect_mode: "if_required",
});
// Verify product states after scheduling
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
await expectProductCanceling({
customer,
productId: premium.id,
});
await expectProductScheduled({
customer,
productId: pro.id,
});
// Verify discount is still on the subscription after scheduling the downgrade
const { subscription: subAfterSchedule } = await getStripeSubscription({
customerId,
});
const subAfterScheduleExpanded = await stripeCli.subscriptions.retrieve(
subAfterSchedule.id,
{ expand: ["discounts.source.coupon"] },
);
expect(subAfterScheduleExpanded.discounts?.length).toBeGreaterThanOrEqual(1);
expect(extractCouponId(subAfterScheduleExpanded.discounts?.[0])).toBe(
coupon.id,
);
// Advance to next billing cycle
await advanceToNextInvoice({
stripeCli: ctx.stripeCli,
testClockId: testClockId!,
currentEpochMs: advancedTo,
withPause: true,
});
const customerAfterCycle =
await autumnV1.customers.get<ApiCustomerV3>(customerId);
// Verify pro is active, premium removed
await expectCustomerProducts({
customer: customerAfterCycle,
active: [pro.id],
notPresent: [premium.id],
});
// Verify features updated to pro tier
expectCustomerFeatureCorrect({
customer: customerAfterCycle,
featureId: TestFeature.Messages,
includedUsage: 500,
balance: 500,
usage: 0,
});
// KEY BUG CHECK: verify discount survives the phase transition
const { subscription: subAfterCycle } = await getStripeSubscription({
customerId,
});
const subAfterCycleExpanded = await stripeCli.subscriptions.retrieve(
subAfterCycle.id,
{ expand: ["discounts.source.coupon"] },
);
// The discount should still be present after the scheduled switch completed
expect(subAfterCycleExpanded.discounts?.length).toBeGreaterThanOrEqual(1);
expect(extractCouponId(subAfterCycleExpanded.discounts?.[0])).toBe(coupon.id);
});
// ═══════════════════════════════════════════════════════════════════════════════
// TEST 2: Premium to Pro with $10 off discount - verify discount after schedule
// ═══════════════════════════════════════════════════════════════════════════════
/**
* Scenario:
* - Customer has premium ($50/mo) with $10 off coupon on subscription
* - Downgrade to pro ($20/mo) - scheduled for end of cycle
*
* Expected Result:
* - Amount-off discount still present on subscription after scheduling
*/
test.concurrent(`${chalk.yellowBright("scheduled-switch-discounts 2: $10 off discount preserved after scheduling downgrade")}`, async () => {
const customerId = "sched-switch-discount-10off";
const proMessagesItem = items.monthlyMessages({ includedUsage: 500 });
const pro = products.pro({
id: "pro",
items: [proMessagesItem],
});
const premiumMessagesItem = items.monthlyMessages({
includedUsage: 1000,
});
const premium = products.premium({
id: "premium",
items: [premiumMessagesItem],
});
const { autumnV1 } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [pro, premium] }),
],
actions: [s.billing.attach({ productId: premium.id })],
});
// Apply $10 off discount to the subscription
const { stripeCli, subscription: subBefore } = await getStripeSubscription({
customerId,
});
const coupon = await createAmountCoupon({
stripeCli,
amountOffCents: 1000,
});
await applySubscriptionDiscount({
stripeCli,
subscriptionId: subBefore.id,
couponIds: [coupon.id],
});
// Schedule downgrade to pro
await autumnV1.billing.attach({
customer_id: customerId,
product_id: pro.id,
redirect_mode: "if_required",
});
// Verify product states
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
await expectProductCanceling({
customer,
productId: premium.id,
});
await expectProductScheduled({
customer,
productId: pro.id,
});
// Verify discount is still on the subscription
const { subscription: subAfter } = await getStripeSubscription({
customerId,
});
const subAfterExpanded = await stripeCli.subscriptions.retrieve(subAfter.id, {
expand: ["discounts.source.coupon"],
});
expect(subAfterExpanded.discounts?.length).toBeGreaterThanOrEqual(1);
expect(extractCouponId(subAfterExpanded.discounts?.[0])).toBe(coupon.id);
});
// ═══════════════════════════════════════════════════════════════════════════════
// TEST 3: Premium to Pro (scheduled) to Free (replace) - discount preserved
// ═══════════════════════════════════════════════════════════════════════════════
/**
* Scenario:
* - Customer has premium ($50/mo) with 20% off coupon
* - Downgrade to pro ($20/mo) - scheduled
* - Replace scheduled with free - re-schedules
*
* Expected Result:
* - Discount still on subscription after replacing the scheduled downgrade
*
* The schedule is released and recreated during replacement. This test verifies
* that the discount survives the release + recreate cycle.
*/
test.concurrent(`${chalk.yellowBright("scheduled-switch-discounts 3: discount preserved when replacing scheduled downgrade")}`, async () => {
const customerId = "sched-switch-discount-replace";
const messagesItem = items.monthlyMessages({ includedUsage: 100 });
const free = products.base({
id: "free",
items: [messagesItem],
});
const proMessagesItem = items.monthlyMessages({ includedUsage: 500 });
const pro = products.pro({
id: "pro",
items: [proMessagesItem],
});
const premiumMessagesItem = items.monthlyMessages({
includedUsage: 1000,
});
const premium = products.premium({
id: "premium",
items: [premiumMessagesItem],
});
const { autumnV1 } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [free, pro, premium] }),
],
actions: [s.billing.attach({ productId: premium.id })],
});
// Apply 20% discount to the subscription
const { stripeCli, subscription: subBefore } = await getStripeSubscription({
customerId,
});
const coupon = await createPercentCoupon({
stripeCli,
percentOff: 20,
});
await applySubscriptionDiscount({
stripeCli,
subscriptionId: subBefore.id,
couponIds: [coupon.id],
});
// Schedule downgrade to pro
await autumnV1.billing.attach({
customer_id: customerId,
product_id: pro.id,
redirect_mode: "if_required",
});
// Verify mid-state
const customerMid = await autumnV1.customers.get<ApiCustomerV3>(customerId);
await expectProductCanceling({
customer: customerMid,
productId: premium.id,
});
await expectProductScheduled({
customer: customerMid,
productId: pro.id,
});
// Replace scheduled pro with free
await autumnV1.billing.attach({
customer_id: customerId,
product_id: free.id,
redirect_mode: "if_required",
});
// Verify product states after replacement
const customerAfterReplace =
await autumnV1.customers.get<ApiCustomerV3>(customerId);
await expectProductCanceling({
customer: customerAfterReplace,
productId: premium.id,
});
await expectProductScheduled({
customer: customerAfterReplace,
productId: free.id,
});
// Verify discount is still on the subscription after schedule replacement
const { subscription: subAfterReplace } = await getStripeSubscription({
customerId,
});
const subAfterExpanded = await stripeCli.subscriptions.retrieve(
subAfterReplace.id,
{ expand: ["discounts.source.coupon"] },
);
expect(subAfterExpanded.discounts?.length).toBeGreaterThanOrEqual(1);
expect(extractCouponId(subAfterExpanded.discounts?.[0])).toBe(coupon.id);
});
// ═══════════════════════════════════════════════════════════════════════════════
// TEST 4: Multiple discounts preserved after scheduled downgrade
// ═══════════════════════════════════════════════════════════════════════════════
/**
* Scenario:
* - Customer has premium ($50/mo) with two discounts (20% off + $5 off)
* - Downgrade to pro ($20/mo) - scheduled
*
* Expected Result:
* - Both discounts still present on subscription after scheduling
*/
test.concurrent(`${chalk.yellowBright("scheduled-switch-discounts 4: multiple discounts preserved after scheduling")}`, async () => {
const customerId = "sched-switch-discount-multi";
const proMessagesItem = items.monthlyMessages({ includedUsage: 500 });
const pro = products.pro({
id: "pro",
items: [proMessagesItem],
});
const premiumMessagesItem = items.monthlyMessages({
includedUsage: 1000,
});
const premium = products.premium({
id: "premium",
items: [premiumMessagesItem],
});
const { autumnV1 } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [pro, premium] }),
],
actions: [s.billing.attach({ productId: premium.id })],
});
// Apply two discounts to the subscription
const { stripeCli, subscription: subBefore } = await getStripeSubscription({
customerId,
});
const percentCoupon = await createPercentCoupon({
stripeCli,
percentOff: 20,
});
const amountCoupon = await createAmountCoupon({
stripeCli,
amountOffCents: 500,
});
// Apply both discounts
await applySubscriptionDiscount({
stripeCli,
subscriptionId: subBefore.id,
couponIds: [percentCoupon.id, amountCoupon.id],
});
// Verify both discounts applied
const subWithDiscounts = await stripeCli.subscriptions.retrieve(
subBefore.id,
{ expand: ["discounts.source.coupon"] },
);
expect(subWithDiscounts.discounts?.length).toBe(2);
// Schedule downgrade to pro
await autumnV1.billing.attach({
customer_id: customerId,
product_id: pro.id,
redirect_mode: "if_required",
});
// Verify product states
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
await expectProductCanceling({
customer,
productId: premium.id,
});
await expectProductScheduled({
customer,
productId: pro.id,
});
// Verify BOTH discounts are still on the subscription
const { subscription: subAfter } = await getStripeSubscription({
customerId,
});
const subAfterExpanded = await stripeCli.subscriptions.retrieve(subAfter.id, {
expand: ["discounts.source.coupon"],
});
expect(subAfterExpanded.discounts?.length).toBe(2);
});
// ═══════════════════════════════════════════════════════════════════════════════
// TEST 5: Upgrade from scheduled downgrade - discount preserved
// ═══════════════════════════════════════════════════════════════════════════════
/**
* Scenario:
* - Customer has premium ($50/mo) with 20% off coupon
* - Downgrade to pro ($20/mo) - scheduled
* - Upgrade to ultra ($200/mo) - immediate, cancels schedule
*
* Expected Result:
* - Discount should still be on the subscription after upgrade cancels the schedule
* - Ultra is active, premium and pro are removed
*/
test.concurrent(`${chalk.yellowBright("scheduled-switch-discounts 5: discount preserved after upgrade cancels scheduled downgrade")}`, async () => {
const customerId = "sched-switch-discount-upgrade";
const proMessagesItem = items.monthlyMessages({ includedUsage: 500 });
const pro = products.pro({
id: "pro",
items: [proMessagesItem],
});
const premiumMessagesItem = items.monthlyMessages({
includedUsage: 1000,
});
const premium = products.premium({
id: "premium",
items: [premiumMessagesItem],
});
const ultraMessagesItem = items.monthlyMessages({
includedUsage: 5000,
});
const ultra = products.ultra({
id: "ultra",
items: [ultraMessagesItem],
});
const { autumnV1 } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [pro, premium, ultra] }),
],
actions: [
s.billing.attach({ productId: premium.id }),
s.billing.attach({ productId: pro.id }),
],
});
// Apply 20% discount to the subscription
const { stripeCli, subscription: subBefore } = await getStripeSubscription({
customerId,
});
const coupon = await createPercentCoupon({
stripeCli,
percentOff: 20,
});
await applySubscriptionDiscount({
stripeCli,
subscriptionId: subBefore.id,
couponIds: [coupon.id],
});
// Verify scheduled state
const customerBefore =
await autumnV1.customers.get<ApiCustomerV3>(customerId);
await expectProductCanceling({
customer: customerBefore,
productId: premium.id,
});
await expectProductScheduled({
customer: customerBefore,
productId: pro.id,
});
// Upgrade to ultra (should cancel scheduled downgrade)
await autumnV1.billing.attach({
customer_id: customerId,
product_id: ultra.id,
redirect_mode: "if_required",
});
// Verify ultra is active, premium and pro removed
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
await expectCustomerProducts({
customer,
active: [ultra.id],
notPresent: [premium.id, pro.id],
});
// Verify discount is still on the subscription after upgrade
const { subscription: subAfter } = await getStripeSubscription({
customerId,
});
const subAfterExpanded = await stripeCli.subscriptions.retrieve(subAfter.id, {
expand: ["discounts.source.coupon"],
});
expect(subAfterExpanded.discounts?.length).toBeGreaterThanOrEqual(1);
expect(extractCouponId(subAfterExpanded.discounts?.[0])).toBe(coupon.id);
});
// ═══════════════════════════════════════════════════════════════════════════════
// TEST 6: Repeating coupon duration preserved across phase transition
// ═══════════════════════════════════════════════════════════════════════════════
/**
* Scenario:
* - Customer has premium ($50/mo) with 3-month repeating 20% off coupon
* - Downgrade to pro ($20/mo) - scheduled
* - Advance test clock past one billing cycle
*
* Expected Result:
* - After cycle: pro is active with discount still present
* - The discount's `end` timestamp should be the SAME as the original
* (not reset to phase2_start + 3 months)
* - This means if 1 month was used on premium, only 2 months remain on pro
*
* This test catches the bug where using `coupon: couponId` on phases
* creates a fresh discount with a reset duration, instead of using
* `discount: discountId` to preserve the original duration.
*/
test.concurrent(`${chalk.yellowBright("scheduled-switch-discounts 6: repeating coupon duration preserved across phase transition")}`, async () => {
const customerId = "sched-switch-discount-duration";
const proMessagesItem = items.monthlyMessages({ includedUsage: 500 });
const pro = products.pro({
id: "pro",
items: [proMessagesItem],
});
const premiumMessagesItem = items.monthlyMessages({
includedUsage: 1000,
});
const premium = products.premium({
id: "premium",
items: [premiumMessagesItem],
});
const { autumnV1, ctx, testClockId, advancedTo } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [pro, premium] }),
],
actions: [s.billing.attach({ productId: premium.id })],
});
// Create a 3-month repeating coupon and apply to subscription
const { stripeCli, subscription: subBefore } = await getStripeSubscription({
customerId,
});
const coupon = await createPercentCoupon({
stripeCli,
percentOff: 20,
duration: "repeating",
durationInMonths: 3,
});
await applySubscriptionDiscount({
stripeCli,
subscriptionId: subBefore.id,
couponIds: [coupon.id],
});
// Record the original discount's end timestamp
const subWithDiscount = await stripeCli.subscriptions.retrieve(subBefore.id, {
expand: ["discounts.source.coupon"],
});
expect(subWithDiscount.discounts?.length).toBeGreaterThanOrEqual(1);
const originalDiscount = subWithDiscount.discounts?.[0];
expect(originalDiscount).toBeDefined();
const originalDiscountEnd =
typeof originalDiscount !== "string" ? originalDiscount?.end : null;
expect(originalDiscountEnd).not.toBeNull();
// Schedule downgrade to pro
await autumnV1.billing.attach({
customer_id: customerId,
product_id: pro.id,
redirect_mode: "if_required",
});
// Advance to next billing cycle
await advanceToNextInvoice({
stripeCli: ctx.stripeCli,
testClockId: testClockId!,
currentEpochMs: advancedTo,
withPause: true,
});
// Verify pro is now active
const customerAfterCycle =
await autumnV1.customers.get<ApiCustomerV3>(customerId);
await expectCustomerProducts({
customer: customerAfterCycle,
active: [pro.id],
notPresent: [premium.id],
});
// KEY ASSERTION: discount still present AND end timestamp is preserved
const { subscription: subAfterCycle } = await getStripeSubscription({
customerId,
});
const subAfterExpanded = await stripeCli.subscriptions.retrieve(
subAfterCycle.id,
{ expand: ["discounts.source.coupon"] },
);
expect(subAfterExpanded.discounts?.length).toBeGreaterThanOrEqual(1);
const discountAfterCycle = subAfterExpanded.discounts?.[0];
const discountEndAfterCycle =
typeof discountAfterCycle !== "string" ? discountAfterCycle?.end : null;
// The discount end should be the SAME as the original - not reset
// If it was reset, it would be ~phase2_start + 3 months (much later)
expect(discountEndAfterCycle).toBe(originalDiscountEnd);
});