diff --git a/server/src/internal/billing/v2/providers/stripe/actionBuilders/buildStripeCheckoutSessionAction.ts b/server/src/internal/billing/v2/providers/stripe/actionBuilders/buildStripeCheckoutSessionAction.ts index e07dbfd84..9b09fa4a3 100644 --- a/server/src/internal/billing/v2/providers/stripe/actionBuilders/buildStripeCheckoutSessionAction.ts +++ b/server/src/internal/billing/v2/providers/stripe/actionBuilders/buildStripeCheckoutSessionAction.ts @@ -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) diff --git a/server/src/internal/billing/v2/providers/stripe/utils/discounts/stripeDiscountsToParams.ts b/server/src/internal/billing/v2/providers/stripe/utils/discounts/stripeDiscountsToParams.ts index 3a0ad9517..f1db38b27 100644 --- a/server/src/internal/billing/v2/providers/stripe/utils/discounts/stripeDiscountsToParams.ts +++ b/server/src/internal/billing/v2/providers/stripe/utils/discounts/stripeDiscountsToParams.ts @@ -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 diff --git a/server/tests/_temp/temp.test.ts b/server/tests/_temp/temp.test.ts index 3cda38851..6c0d5ba89 100644 --- a/server/tests/_temp/temp.test.ts +++ b/server/tests/_temp/temp.test.ts @@ -1,113 +1,6 @@ import { test } from "bun:test"; -import { - type ApiPlanV1, - BillingInterval, - BillingMethod, - type CreatePlanParamsInput, - TierBehavior, - TierInfinite, -} from "@autumn/shared"; -import { TestFeature } from "@tests/setup/v2Features"; -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 productId = "only_price_interval"; - - const { - autumnV2_1: autumnV2, - autumnV1, - autumnV0, - } = await initScenario({ - customerId, - setup: [s.products({ list: [] })], - actions: [], - }); - - try { - await autumnV2.products.delete(`${productId}_v2`); - } catch (_error) {} - - await autumnV2.products.create({ - id: `${productId}_v2`, - name: "Volume V2 Test", - items: [ - { - feature_id: TestFeature.Messages, - price: { - interval: BillingInterval.Month, - billing_method: BillingMethod.Prepaid, - billing_units: 1, - tier_behavior: TierBehavior.VolumeBased, - tiers: [ - { to: 100, amount: 10, flat_amount: 100 }, - { to: TierInfinite, amount: 20, flat_amount: 90 }, - ], - }, - }, - ], - }); - - const v2 = await autumnV2.products.get(`${productId}_v2`); - console.log(JSON.stringify(v2, null, 2)); - - // try { - // await autumnV2.products.delete(`${productId}_v1`); - // } catch (_error) {} - - // await autumnV2.products.create({ - // plan_id: `${productId}_v1`, - // name: "Volume V1 Test", - // description: "Volume V1 Test", - // group: "Volume V1 Test", - // add_on: false, - // auto_enable: true, - // items: [ - // { - // feature_id: TestFeature.Messages, - // price: { - // interval: BillingInterval.Month, - // billing_method: BillingMethod.Prepaid, - // billing_units: 1, - // tier_behavior: TierBehavior.VolumeBased, - // tiers: [ - // { to: 100, amount: 10, flat_amount: 100 }, - // { to: TierInfinite, amount: 20, flat_amount: 90 }, - // ], - // }, - // }, - // ], - // }); - - // const v1 = await autumnV1.products.get(productId); - // console.log(JSON.stringify(v1, null, 2)); - - // try { - // await autumnV0.products.delete(productId); - // } catch (_error) {} - - // await autumnV0.products.create({ - // id: productId, - // name: "Volume V0 Test", - // items: [ - // { - // feature_id: TestFeature.Messages, - // price: { - // interval: BillingInterval.Month, - // billing_method: BillingMethod.Prepaid, - // billing_units: 1, - // tier_behavior: TierBehavior.VolumeBased, - // tiers: [ - // { to: 100, amount: 10, flat_amount: 100 }, - // { to: TierInfinite, amount: 20, flat_amount: 90 }, - // ], - // }, - // }, - // ], - // }); - - // const v0 = await autumnV0.products.get(productId); - // console.log(JSON.stringify(v0, null, 2)); +test(`${chalk.yellowBright("temp: placeholder")}`, async () => { + // Placeholder test }); diff --git a/server/tests/integration/billing/attach/immediate-switch/immediate-switch-discounts.test.ts b/server/tests/integration/billing/attach/immediate-switch/immediate-switch-discounts.test.ts new file mode 100644 index 000000000..95c8ed9f6 --- /dev/null +++ b/server/tests/integration/billing/attach/immediate-switch/immediate-switch-discounts.test.ts @@ -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); +}); diff --git a/server/tests/integration/billing/attach/scheduled-switch/scheduled-switch-discounts.test.ts b/server/tests/integration/billing/attach/scheduled-switch/discounts/scheduled-switch-discounts-basic.test.ts similarity index 66% rename from server/tests/integration/billing/attach/scheduled-switch/scheduled-switch-discounts.test.ts rename to server/tests/integration/billing/attach/scheduled-switch/discounts/scheduled-switch-discounts-basic.test.ts index c3075ce94..95ae9c2e0 100644 --- a/server/tests/integration/billing/attach/scheduled-switch/scheduled-switch-discounts.test.ts +++ b/server/tests/integration/billing/attach/scheduled-switch/discounts/scheduled-switch-discounts-basic.test.ts @@ -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(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(customerId); - await expectProductCanceling({ - customer, - productId: premium.id, - }); - await expectProductScheduled({ - customer, - productId: pro.id, - }); -}); diff --git a/server/tests/integration/billing/attach/scheduled-switch/discounts/scheduled-switch-discounts-edge.test.ts b/server/tests/integration/billing/attach/scheduled-switch/discounts/scheduled-switch-discounts-edge.test.ts new file mode 100644 index 000000000..7a2337634 --- /dev/null +++ b/server/tests/integration/billing/attach/scheduled-switch/discounts/scheduled-switch-discounts-edge.test.ts @@ -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(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(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); +}); diff --git a/server/tests/integration/billing/update-subscription/discounts/subscription-discounts.test.ts b/server/tests/integration/billing/update-subscription/discounts/subscription-discounts.test.ts index ff54c0d50..b483d686b 100644 --- a/server/tests/integration/billing/update-subscription/discounts/subscription-discounts.test.ts +++ b/server/tests/integration/billing/update-subscription/discounts/subscription-discounts.test.ts @@ -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); +}); diff --git a/server/tests/integration/billing/utils/discounts/discountTestUtils.ts b/server/tests/integration/billing/utils/discounts/discountTestUtils.ts index 4610d9720..4a3cf22af 100644 --- a/server/tests/integration/billing/utils/discounts/discountTestUtils.ts +++ b/server/tests/integration/billing/utils/discounts/discountTestUtils.ts @@ -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) {