fix: checkout session discount params

This commit is contained in:
John Yeo
2026-03-02 19:45:51 +00:00
parent fb3e7cdb68
commit 40ba90ee8f
8 changed files with 761 additions and 321 deletions

View File

@@ -7,7 +7,7 @@ import { msToSeconds, orgToReturnUrl } from "@autumn/shared";
import type Stripe from "stripe";
import type { AutumnContext } from "@/honoUtils/HonoEnv";
import { buildStripeCheckoutSessionItems } from "@/internal/billing/v2/providers/stripe/utils/checkoutSessions/buildStripeCheckoutSessionItems";
import { stripeDiscountsToParams } from "@/internal/billing/v2/providers/stripe/utils/discounts/stripeDiscountsToParams";
import { stripeDiscountsToCheckoutParams } from "@/internal/billing/v2/providers/stripe/utils/discounts/stripeDiscountsToParams";
export const buildStripeCheckoutSessionAction = ({
ctx,
@@ -64,7 +64,7 @@ export const buildStripeCheckoutSessionAction = ({
// 6. Build discounts for checkout session
const discounts = stripeDiscounts?.length
? stripeDiscountsToParams({ stripeDiscounts })
? stripeDiscountsToCheckoutParams({ stripeDiscounts })
: undefined;
// 7. Build params (only variable params - static params added in execute)

View File

@@ -1,14 +1,35 @@
import type { StripeDiscountWithCoupon } from "@autumn/shared";
/**
* Maps internal discount objects to Stripe API `discounts` param format.
* Uses { promotion_code: id } when the discount originates from a promo code,
* otherwise uses { coupon: id } for direct coupon references.
* Maps internal discount objects to Stripe API `discounts` param format for subscription updates.
* Uses { discount: id } for existing discounts (preserving original start/end),
* { promotion_code: id } for promo-code-based new discounts,
* and { coupon: id } for new coupon-based discounts.
*/
export const stripeDiscountsToParams = ({
stripeDiscounts,
}: {
stripeDiscounts: StripeDiscountWithCoupon[];
}): (
| { discount: string }
| { coupon: string }
| { promotion_code: string }
)[] => {
return stripeDiscounts.map((d) => {
if (d.id) return { discount: d.id };
if (d.promotionCodeId) return { promotion_code: d.promotionCodeId };
return { coupon: d.source.coupon.id };
});
};
/**
* Maps discount objects to Stripe checkout session `discounts` param format.
* Checkout sessions only accept { coupon } or { promotion_code } — not { discount }.
*/
export const stripeDiscountsToCheckoutParams = ({
stripeDiscounts,
}: {
stripeDiscounts: StripeDiscountWithCoupon[];
}): ({ coupon: string } | { promotion_code: string })[] => {
return stripeDiscounts.map((d) =>
d.promotionCodeId

View File

@@ -1,46 +1,6 @@
import { test } from "bun:test";
import {
FreeTrialDuration,
type UpdateSubscriptionV1Params,
} from "@autumn/shared";
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";
const customerId = "temp-test";
test.concurrent(`${chalk.yellowBright("temp: rest update then rpc inverse update returns product to baseline")}`, async () => {
const proProd = products.pro({
id: "pro",
items: [items.monthlyCredits({ includedUsage: 100 })],
});
const customerId = "temp";
const { autumnV1, autumnV2_1 } = await initScenario({
customerId,
actions: [],
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [proProd] }),
],
});
const result = await autumnV1.billing.attach({
customer_id: customerId,
product_id: proProd.id,
free_trial: {
length: 14,
duration: FreeTrialDuration.Day,
},
});
console.log(result);
const updateResult =
await autumnV2_1.subscriptions.update<UpdateSubscriptionV1Params>({
customer_id: customerId,
plan_id: proProd.id,
});
console.log(updateResult);
test(`${chalk.yellowBright("temp: placeholder")}`, async () => {
// Placeholder test
});

View File

@@ -0,0 +1,238 @@
/**
* Immediate Switch Discount Preservation Tests
*
* Tests that discounts are preserved (same ID, same duration/end date)
* when upgrading from one paid product to another.
* Users should NOT get extra discount duration from plan changes.
*/
import { expect, test } from "bun:test";
import {
applySubscriptionDiscount,
createPercentCoupon,
getStripeSubscription,
} from "@tests/integration/billing/utils/discounts/discountTestUtils";
import { items } from "@tests/utils/fixtures/items";
import { products } from "@tests/utils/fixtures/products";
import { advanceTestClock } from "@tests/utils/stripeUtils";
import { initScenario, s } from "@tests/utils/testInitUtils/initScenario";
import chalk from "chalk";
// ═══════════════════════════════════════════════════════════════════════════════
// TEST 1: Upgrade pro → premium preserves discount identity and duration
// ═══════════════════════════════════════════════════════════════════════════════
/**
* Scenario:
* - Customer has pro ($20/mo) with 2-month repeating 20% off coupon
* - Advance 2 weeks (mid-cycle)
* - Upgrade to premium ($50/mo) — immediate switch
*
* Expected:
* - Discount ID unchanged (same di_xxx)
* - Discount end timestamp unchanged (duration not reset)
*/
test.concurrent(`${chalk.yellowBright("immediate-switch-discounts 1: upgrade pro -> premium preserves discount identity and duration")}`, async () => {
const customerId = "imm-switch-discount-upgrade";
const pro = products.pro({
id: "pro",
items: [items.monthlyMessages({ includedUsage: 500 })],
});
const premium = products.premium({
id: "premium",
items: [items.monthlyMessages({ includedUsage: 1000 })],
});
const { autumnV1, testClockId, ctx } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [pro, premium] }),
],
actions: [s.billing.attach({ productId: pro.id })],
});
// Apply 2-month repeating 20% off coupon
const { stripeCli, subscription: sub } = await getStripeSubscription({
customerId,
});
const coupon = await createPercentCoupon({
stripeCli,
percentOff: 20,
duration: "repeating",
durationInMonths: 2,
});
await applySubscriptionDiscount({
stripeCli,
subscriptionId: sub.id,
couponIds: [coupon.id],
});
// Record discount before upgrade
const subWithDiscount = await stripeCli.subscriptions.retrieve(sub.id, {
expand: ["discounts.source.coupon"],
});
expect(subWithDiscount.discounts?.length).toBeGreaterThanOrEqual(1);
const discountBefore = subWithDiscount.discounts![0];
const discountIdBefore =
typeof discountBefore !== "string" ? discountBefore.id : null;
const discountEndBefore =
typeof discountBefore !== "string" ? discountBefore.end : null;
expect(discountIdBefore).not.toBeNull();
expect(discountEndBefore).not.toBeNull();
// Advance 2 weeks
await advanceTestClock({
stripeCli: ctx.stripeCli,
testClockId: testClockId!,
numberOfDays: 14,
});
// Upgrade to premium (immediate switch)
await autumnV1.billing.attach({
customer_id: customerId,
product_id: premium.id,
redirect_mode: "if_required",
});
// Verify discount is preserved
const { subscription: subAfterUpgrade } = await getStripeSubscription({
customerId,
});
const subAfter = await stripeCli.subscriptions.retrieve(subAfterUpgrade.id, {
expand: ["discounts.source.coupon"],
});
expect(subAfter.discounts?.length).toBeGreaterThanOrEqual(1);
const discountAfter = subAfter.discounts![0];
const discountIdAfter =
typeof discountAfter !== "string" ? discountAfter.id : null;
const discountEndAfter =
typeof discountAfter !== "string" ? discountAfter.end : null;
// Discount ID must be the same (not re-created)
expect(discountIdAfter).toBe(discountIdBefore);
// Discount end must be the same (duration not reset)
expect(discountEndAfter).toBe(discountEndBefore);
});
// ═══════════════════════════════════════════════════════════════════════════════
// TEST 2: Upgrade pro → premium → ultra preserves discount through multiple switches
// ═══════════════════════════════════════════════════════════════════════════════
/**
* Scenario:
* - Customer has pro ($20/mo) with 3-month repeating 20% off coupon
* - Advance 2 weeks
* - Upgrade to premium ($50/mo)
* - Upgrade to ultra ($200/mo)
*
* Expected:
* - Discount ID unchanged through both upgrades
* - Discount end timestamp unchanged (duration not reset)
*/
test.concurrent(`${chalk.yellowBright("immediate-switch-discounts 2: discount preserved through multiple upgrades")}`, async () => {
const customerId = "imm-switch-discount-multi-upgrade";
const pro = products.pro({
id: "pro",
items: [items.monthlyMessages({ includedUsage: 500 })],
});
const premium = products.premium({
id: "premium",
items: [items.monthlyMessages({ includedUsage: 1000 })],
});
const ultra = products.ultra({
id: "ultra",
items: [items.monthlyMessages({ includedUsage: 5000 })],
});
const { autumnV1, testClockId, ctx } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [pro, premium, ultra] }),
],
actions: [s.billing.attach({ productId: pro.id })],
});
// Apply 3-month repeating 20% off coupon
const { stripeCli, subscription: sub } = await getStripeSubscription({
customerId,
});
const coupon = await createPercentCoupon({
stripeCli,
percentOff: 20,
duration: "repeating",
durationInMonths: 3,
});
await applySubscriptionDiscount({
stripeCli,
subscriptionId: sub.id,
couponIds: [coupon.id],
});
// Record original discount
const subWithDiscount = await stripeCli.subscriptions.retrieve(sub.id, {
expand: ["discounts.source.coupon"],
});
const discountBefore = subWithDiscount.discounts![0];
const discountIdBefore =
typeof discountBefore !== "string" ? discountBefore.id : null;
const discountEndBefore =
typeof discountBefore !== "string" ? discountBefore.end : null;
expect(discountIdBefore).not.toBeNull();
expect(discountEndBefore).not.toBeNull();
// Advance 2 weeks
await advanceTestClock({
stripeCli: ctx.stripeCli,
testClockId: testClockId!,
numberOfDays: 14,
});
// Upgrade pro → premium
await autumnV1.billing.attach({
customer_id: customerId,
product_id: premium.id,
redirect_mode: "if_required",
});
// Upgrade premium → ultra
await autumnV1.billing.attach({
customer_id: customerId,
product_id: ultra.id,
redirect_mode: "if_required",
});
// Verify discount is preserved after both upgrades
const { subscription: subAfter } = await getStripeSubscription({
customerId,
});
const subAfterExpanded = await stripeCli.subscriptions.retrieve(subAfter.id, {
expand: ["discounts.source.coupon"],
});
expect(subAfterExpanded.discounts?.length).toBeGreaterThanOrEqual(1);
const discountAfter = subAfterExpanded.discounts![0];
const discountIdAfter =
typeof discountAfter !== "string" ? discountAfter.id : null;
const discountEndAfter =
typeof discountAfter !== "string" ? discountAfter.end : null;
// Discount ID must be the same through both upgrades
expect(discountIdAfter).toBe(discountIdBefore);
// Discount end must be the same (duration not reset)
expect(discountEndAfter).toBe(discountEndBefore);
});

View File

@@ -1,17 +1,12 @@
/**
* Scheduled Switch Discount Tests (Attach V2)
* Schedule Discount Preservation 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.
* when a plan is downgraded via scheduled switch (paid paid).
*
* Key behaviors tested:
* - Percent-off discount persists after scheduling a downgrade
* - Percent-off discount persists after scheduling a downgrade and through cycle advance
* - 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
@@ -20,11 +15,9 @@
import { expect, test } from "bun:test";
import type { ApiCustomerV3 } from "@autumn/shared";
import {
applyCustomerCoupon,
applySubscriptionDiscount,
createAmountCoupon,
createPercentCoupon,
deleteCoupon,
getStripeSubscription,
} from "@tests/integration/billing/utils/discounts/discountTestUtils";
import { expectCustomerFeatureCorrect } from "@tests/integration/billing/utils/expectCustomerFeatureCorrect";
@@ -77,11 +70,8 @@ const extractCouponId = (discount: unknown): string | null => {
* - 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 () => {
test.concurrent(`${chalk.yellowBright("schedule-discounts 1: 20% discount preserved after scheduling and cycle advance")}`, async () => {
const customerId = "sched-switch-discount-20pct";
const proMessagesItem = items.monthlyMessages({ includedUsage: 500 });
@@ -213,7 +203,7 @@ test.concurrent(`${chalk.yellowBright("scheduled-switch-discounts 1: 20% discoun
* 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 () => {
test.concurrent(`${chalk.yellowBright("schedule-discounts 2: $10 off discount preserved after scheduling downgrade")}`, async () => {
const customerId = "sched-switch-discount-10off";
const proMessagesItem = items.monthlyMessages({ includedUsage: 500 });
@@ -301,7 +291,7 @@ test.concurrent(`${chalk.yellowBright("scheduled-switch-discounts 2: $10 off dis
* 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 () => {
test.concurrent(`${chalk.yellowBright("schedule-discounts 3: discount preserved when replacing scheduled downgrade")}`, async () => {
const customerId = "sched-switch-discount-replace";
const messagesItem = items.monthlyMessages({ includedUsage: 100 });
@@ -411,7 +401,7 @@ test.concurrent(`${chalk.yellowBright("scheduled-switch-discounts 3: discount pr
* Expected Result:
* - Both discounts still present on subscription after scheduling
*/
test.concurrent(`${chalk.yellowBright("scheduled-switch-discounts 4: multiple discounts preserved after scheduling")}`, async () => {
test.concurrent(`${chalk.yellowBright("schedule-discounts 4: multiple discounts preserved after scheduling")}`, async () => {
const customerId = "sched-switch-discount-multi";
const proMessagesItem = items.monthlyMessages({ includedUsage: 500 });
@@ -509,7 +499,7 @@ test.concurrent(`${chalk.yellowBright("scheduled-switch-discounts 4: multiple di
* - 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 () => {
test.concurrent(`${chalk.yellowBright("schedule-discounts 5: discount preserved after upgrade cancels scheduled downgrade")}`, async () => {
const customerId = "sched-switch-discount-upgrade";
const proMessagesItem = items.monthlyMessages({ includedUsage: 500 });
@@ -600,217 +590,3 @@ test.concurrent(`${chalk.yellowBright("scheduled-switch-discounts 5: discount pr
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);
});
// ═══════════════════════════════════════════════════════════════════════════════
// TEST 8: Downgrade with deleted coupon (rollover scenario)
// ═══════════════════════════════════════════════════════════════════════════════
/**
* Scenario (mirrors handleStripeInvoiceDiscounts rollover flow):
* - Customer has premium ($50/mo)
* - A coupon is applied to the CUSTOMER (not the subscription) via rawRequest
* - The coupon is immediately deleted from Stripe
* - The customer-level discount survives but its coupon is gone
* - Customer attempts to downgrade to pro ($20/mo)
*
* Expected Result:
* - The downgrade should succeed without Stripe errors
* - Premium is canceling, pro is scheduled
*
* BUG: Without validation, setupStripeDiscountsForBilling falls back to the
* customer discount (since there are no subscription discounts). The customer
* discount's source.coupon is expanded but references a deleted coupon.
* When buildStripePhasesUpdate maps it to { discount: discount.id } and
* Stripe tries to resolve it, it throws "No such coupon: '{couponId}'".
*/
test.concurrent(`${chalk.yellowBright("scheduled-switch-discounts 8: downgrade succeeds when coupon was deleted (rollover scenario)")}`, async () => {
const customerId = "sched-switch-discount-deleted-coupon";
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 })],
});
// Get Stripe client and customer ID
const { stripeCli, stripeCustomerId } = await getStripeSubscription({
customerId,
});
// Create coupon and apply to the CUSTOMER (not the subscription).
// This mirrors the rollover flow in handleStripeInvoiceDiscounts which uses
// rawRequest POST /v1/customers/{id} with { coupon: newCoupon.id }
const coupon = await createPercentCoupon({
stripeCli,
percentOff: 20,
});
await applyCustomerCoupon({
stripeCustomerId,
couponId: coupon.id,
});
// DELETE the coupon — mirrors handleStripeInvoiceDiscounts line 148:
// stripeCli.coupons.del(newCoupon.id)
// The customer-level discount survives but its coupon object is gone
await deleteCoupon({ stripeCli, couponId: coupon.id });
// Schedule downgrade to pro — this is where the bug manifests.
// setupStripeDiscountsForBilling finds no subscription discounts, falls back
// to customer discount. The customer discount has an expanded source.coupon
// referencing the deleted coupon. Without the fix, Stripe throws
// "No such coupon: '{couponId}'" when creating the schedule phases.
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,
});
});

View File

@@ -0,0 +1,372 @@
/**
* Discount Edge Case Tests (Attach V2)
*
* Tests edge cases around discount preservation during scheduled switches:
* - Repeating coupon duration preserved across phase transitions
* - Downgrade succeeds when coupon was deleted (rollover scenario)
* - Promo code applied via checkout is preserved during downgrade to free
*/
import { expect, test } from "bun:test";
import {
type ApiCustomerV3,
CouponDurationType,
RewardType,
} from "@autumn/shared";
import {
applyCustomerCoupon,
applySubscriptionDiscount,
createPercentCoupon,
deleteCoupon,
getStripeSubscription,
} from "@tests/integration/billing/utils/discounts/discountTestUtils";
import {
expectCustomerProducts,
expectProductCanceling,
expectProductScheduled,
} from "@tests/integration/billing/utils/expectCustomerProductCorrect";
import { completeStripeCheckoutFormV2 } from "@tests/utils/browserPool/completeStripeCheckoutFormV2";
import { items } from "@tests/utils/fixtures/items";
import { products } from "@tests/utils/fixtures/products";
import { timeout } from "@tests/utils/genUtils";
import { advanceTestClock } from "@tests/utils/stripeUtils";
import { advanceToNextInvoice } from "@tests/utils/testAttachUtils/testAttachUtils";
import { initScenario, s } from "@tests/utils/testInitUtils/initScenario";
import chalk from "chalk";
import { constructCoupon } from "@/utils/scriptUtils/createTestProducts";
/**
* Helper to extract the discount ID from a Stripe discount object.
*/
const extractDiscountId = (discount: unknown): string | null => {
if (typeof discount === "string") return discount;
if (discount && typeof discount === "object" && "id" in discount) {
return (discount as { id: string }).id;
}
return null;
};
// ═══════════════════════════════════════════════════════════════════════════════
// TEST 1: 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("discount-edge-cases 1: 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);
});
// ═══════════════════════════════════════════════════════════════════════════════
// TEST 2: Downgrade with deleted coupon (rollover scenario)
// ═══════════════════════════════════════════════════════════════════════════════
/**
* Scenario (mirrors handleStripeInvoiceDiscounts rollover flow):
* - Customer has premium ($50/mo)
* - A coupon is applied to the CUSTOMER (not the subscription) via rawRequest
* - The coupon is immediately deleted from Stripe
* - The customer-level discount survives but its coupon is gone
* - Customer attempts to downgrade to pro ($20/mo)
*
* Expected Result:
* - The downgrade should succeed without Stripe errors
* - Premium is canceling, pro is scheduled
*/
test.concurrent(`${chalk.yellowBright("discount-edge-cases 2: downgrade succeeds when coupon was deleted (rollover scenario)")}`, async () => {
const customerId = "sched-switch-discount-deleted-coupon";
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 })],
});
// Get Stripe client and customer ID
const { stripeCli, stripeCustomerId } = await getStripeSubscription({
customerId,
});
// Create coupon and apply to the CUSTOMER (not the subscription).
// This mirrors the rollover flow in handleStripeInvoiceDiscounts which uses
// rawRequest POST /v1/customers/{id} with { coupon: newCoupon.id }
const coupon = await createPercentCoupon({
stripeCli,
percentOff: 20,
});
await applyCustomerCoupon({
stripeCustomerId,
couponId: coupon.id,
});
// DELETE the coupon — mirrors handleStripeInvoiceDiscounts line 148:
// stripeCli.coupons.del(newCoupon.id)
// The customer-level discount survives but its coupon object is gone
await deleteCoupon({ stripeCli, couponId: coupon.id });
// Schedule downgrade to pro — this is where the bug manifests.
// setupStripeDiscountsForBilling finds no subscription discounts, falls back
// to customer discount. The customer discount has an expanded source.coupon
// referencing the deleted coupon. Without the fix, Stripe throws
// "No such coupon: '{couponId}'" when creating the schedule phases.
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,
});
});
// ═══════════════════════════════════════════════════════════════════════════════
// TEST 3: Promo code via checkout preserved during downgrade to free
// ═══════════════════════════════════════════════════════════════════════════════
/**
* Scenario (reproduces the real customer bug):
* - Customer subscribes to pro ($20/mo) via Stripe Checkout
* - Promo code (20% off, 2 months) is entered on the checkout page
* - Advance 14 days (mid-cycle)
* - Downgrade to free (schedules cancel_at via direct subscription update)
*
* Expected Result:
* - The discount ID should be PRESERVED (same di_xxx before and after)
* - The discount's start/end timestamps should NOT change
*
* Bug: stripeDiscountsToParams was sending { coupon: "..." } instead of
* { discount: "di_xxx" }, causing Stripe to create a brand new discount
* (with fresh dates and no promotion_code reference) for promo-code-applied
* discounts.
*/
test.concurrent(`${chalk.yellowBright("discount-edge-cases 3: promo code via checkout preserved during downgrade to free")}`, async () => {
const customerId = "promo-checkout-to-free";
const free = products.base({
id: "free",
items: [items.monthlyMessages({ includedUsage: 100 })],
});
const pro = products.pro({
id: "pro",
items: [items.monthlyMessages({ includedUsage: 500 })],
});
// Create a reward with a promo code (20% off for 2 months)
const promoCode = `PROMO${Date.now()}`;
const reward = constructCoupon({
id: "promo-checkout-reward",
promoCode,
discountType: RewardType.PercentageDiscount,
discountValue: 20,
});
// Override duration to 2 months (matching the real customer's case)
reward.discount_config!.duration_type = CouponDurationType.Months;
reward.discount_config!.duration_value = 2;
const { autumnV1, testClockId, ctx } = await initScenario({
customerId,
setup: [
// No payment method → forces checkout flow
s.customer({ testClock: true }),
s.products({ list: [free, pro] }),
s.reward({ reward, productId: pro.id }),
],
actions: [],
});
// Attach pro WITHOUT passing reward → checkout has allow_promotion_codes: true
const res = await autumnV1.attach({
customer_id: customerId,
product_id: pro.id,
});
// Complete checkout with promo code entered on the Stripe checkout page
await completeStripeCheckoutFormV2({
url: res.checkout_url,
promoCode,
});
await timeout(10000);
// Record discount state before downgrade
const subBefore = await getStripeSubscription({
customerId,
expand: ["data.discounts"],
});
const discountBefore = subBefore.subscription.discounts?.[0];
expect(discountBefore).toBeDefined();
const discountIdBefore = extractDiscountId(discountBefore);
const discountEndBefore =
typeof discountBefore !== "string" ? discountBefore?.end : null;
expect(discountIdBefore).not.toBeNull();
expect(discountEndBefore).not.toBeNull();
// Advance 14 days (mid-cycle)
await advanceTestClock({
stripeCli: ctx.stripeCli,
testClockId: testClockId!,
numberOfDays: 14,
});
// Downgrade to free (triggers direct subscription update with cancel_at)
await autumnV1.attach({
customer_id: customerId,
product_id: free.id,
redirect_mode: "if_required",
});
// Verify discount is preserved (same ID, same end date)
const subAfter = await getStripeSubscription({
customerId,
expand: ["data.discounts"],
});
const discountAfter = subAfter.subscription.discounts?.[0];
expect(discountAfter).toBeDefined();
const discountIdAfter = extractDiscountId(discountAfter);
const discountEndAfter =
typeof discountAfter !== "string" ? discountAfter?.end : null;
// The discount ID should be the same (not a new discount)
expect(discountIdAfter).toBe(discountIdBefore);
// The discount end should be the same (not reset)
expect(discountEndAfter).toBe(discountEndBefore);
});

View File

@@ -2,60 +2,26 @@
* Integration tests for Stripe discounts in update subscription flow.
*
* These tests verify that discounts applied at subscription or customer level
* are correctly reflected in preview totals.
* are correctly reflected in preview totals, and that discount identity/duration
* is preserved through cancel/uncancel operations.
*/
import { expect, test } from "bun:test";
import {
applySubscriptionDiscount,
createPercentCoupon,
getStripeSubscription,
} from "@tests/integration/billing/utils/discounts/discountTestUtils";
import { TestFeature } from "@tests/setup/v2Features.js";
import { items } from "@tests/utils/fixtures/items.js";
import { products } from "@tests/utils/fixtures/products.js";
import ctx from "@tests/utils/testInitUtils/createTestContext.js";
import { advanceTestClock } from "@tests/utils/stripeUtils";
import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js";
import chalk from "chalk";
import { createStripeCli } from "@/external/connect/createStripeCli.js";
import { CusService } from "@/internal/customers/CusService.js";
const billingUnits = 12;
const pricePerUnit = 10;
/**
* Helper to get Stripe subscription for a customer
*/
const getStripeSubscription = async ({
customerId,
}: {
customerId: string;
}) => {
const stripeCli = createStripeCli({ org: ctx.org, env: ctx.env });
const fullCustomer = await CusService.getFull({
ctx,
idOrInternalId: customerId,
});
const stripeCustomerId =
fullCustomer.processor?.id || fullCustomer.processor?.processor_id;
if (!stripeCustomerId) {
throw new Error("Missing Stripe customer ID");
}
const subscriptions = await stripeCli.subscriptions.list({
customer: stripeCustomerId,
status: "all",
});
if (subscriptions.data.length === 0) {
throw new Error("No subscriptions found");
}
return {
stripeCli,
stripeCustomerId,
subscription: subscriptions.data[0],
};
};
// =============================================================================
// PERCENT-OFF DISCOUNT TESTS
// =============================================================================
@@ -518,3 +484,107 @@ test.concurrent(`${chalk.yellowBright("discount: promotion code applied to subsc
expect(preview.total).toBe(expectedAmount);
});
// =============================================================================
// DISCOUNT PRESERVATION: CANCEL / UNCANCEL
// =============================================================================
/**
* Scenario:
* - Customer has pro ($20/mo) with 2-month repeating 20% off coupon
* - Advance 2 weeks (mid-cycle)
* - Cancel at end of cycle
* - Uncancel
*
* Expected:
* - Discount ID unchanged (same di_xxx)
* - Discount end timestamp unchanged (duration not reset)
*/
test.concurrent(`${chalk.yellowBright("discount: cancel then uncancel preserves discount identity and duration")}`, async () => {
const customerId = "discount-cancel-uncancel";
const pro = products.pro({
id: "pro",
items: [items.monthlyMessages({ includedUsage: 500 })],
});
const { autumnV1, testClockId, ctx } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [pro] }),
],
actions: [s.billing.attach({ productId: pro.id })],
});
// Apply 2-month repeating 20% off coupon
const { stripeCli, subscription: sub } = await getStripeSubscription({
customerId,
});
const coupon = await createPercentCoupon({
stripeCli,
percentOff: 20,
duration: "repeating",
durationInMonths: 2,
});
await applySubscriptionDiscount({
stripeCli,
subscriptionId: sub.id,
couponIds: [coupon.id],
});
// Record discount before any changes
const subWithDiscount = await stripeCli.subscriptions.retrieve(sub.id, {
expand: ["discounts.source.coupon"],
});
expect(subWithDiscount.discounts?.length).toBeGreaterThanOrEqual(1);
const discountBefore = subWithDiscount.discounts![0];
const discountIdBefore =
typeof discountBefore !== "string" ? discountBefore.id : null;
const discountEndBefore =
typeof discountBefore !== "string" ? discountBefore.end : null;
expect(discountIdBefore).not.toBeNull();
expect(discountEndBefore).not.toBeNull();
// Advance 2 weeks
await advanceTestClock({
stripeCli: ctx.stripeCli,
testClockId: testClockId!,
numberOfDays: 14,
});
// Cancel at end of cycle
await autumnV1.subscriptions.update({
customer_id: customerId,
product_id: pro.id,
cancel_action: "cancel_end_of_cycle",
});
// Uncancel
await autumnV1.subscriptions.update({
customer_id: customerId,
product_id: pro.id,
cancel_action: "uncancel",
});
// Verify discount is preserved
const subAfter = await stripeCli.subscriptions.retrieve(sub.id, {
expand: ["discounts.source.coupon"],
});
expect(subAfter.discounts?.length).toBeGreaterThanOrEqual(1);
const discountAfter = subAfter.discounts![0];
const discountIdAfter =
typeof discountAfter !== "string" ? discountAfter.id : null;
const discountEndAfter =
typeof discountAfter !== "string" ? discountAfter.end : null;
// Discount ID must be the same (not re-created)
expect(discountIdAfter).toBe(discountIdBefore);
// Discount end must be the same (duration not reset)
expect(discountEndAfter).toBe(discountEndBefore);
});

View File

@@ -12,8 +12,10 @@ import { CusService } from "@/internal/customers/CusService.js";
*/
export const getStripeSubscription = async ({
customerId,
expand,
}: {
customerId: string;
expand?: string[];
}) => {
const stripeCli = createStripeCli({ org: ctx.org, env: ctx.env });
@@ -32,6 +34,7 @@ export const getStripeSubscription = async ({
const subscriptions = await stripeCli.subscriptions.list({
customer: stripeCustomerId,
status: "all",
expand,
});
if (subscriptions.data.length === 0) {