fix: checkout session discount params
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user