diff --git a/server/tests/integration/billing/update-subscription/uncancel/uncancel-addon.test.ts b/server/tests/integration/billing/update-subscription/uncancel/uncancel-addon.test.ts new file mode 100644 index 000000000..8f5914f1b --- /dev/null +++ b/server/tests/integration/billing/update-subscription/uncancel/uncancel-addon.test.ts @@ -0,0 +1,337 @@ +import { expect, test } from "bun:test"; +import type { ApiCustomerV3 } from "@autumn/shared"; +import { + expectProductActive, + expectProductCanceling, +} from "@tests/integration/billing/utils/expectCustomerProductCorrect"; +import { expectSubToBeCorrect } from "@tests/merged/mergeUtils/expectSubCorrect"; +import { TestFeature } from "@tests/setup/v2Features"; +import { items } from "@tests/utils/fixtures/items"; +import { products } from "@tests/utils/fixtures/products"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario"; +import chalk from "chalk"; +import { constructProduct } from "@/utils/scriptUtils/createTestProducts"; + +/** + * Uncancel Add-on Tests + * + * Tests for uncanceling add-on products and multi-subscription scenarios. + */ + +// ═══════════════════════════════════════════════════════════════════════════════ +// TEST 1: Uncancel add-on while main is active +// ═══════════════════════════════════════════════════════════════════════════════ + +test.concurrent(`${chalk.yellowBright("uncancel addon: main active")}`, async () => { + const customerId = "uncancel-addon-main-active"; + const messagesItem = items.monthlyMessages({ includedUsage: 100 }); + const creditsItem = items.monthlyCredits({ includedUsage: 50 }); + + const pro = products.pro({ items: [messagesItem] }); + const addon = constructProduct({ + id: "addon", + items: [creditsItem], + type: "pro", + isDefault: false, + isAddOn: true, + }); + + const { autumnV1, ctx } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [pro, addon] }), + ], + actions: [ + s.attach({ productId: pro.id }), + s.attach({ productId: addon.id }), + s.cancel({ productId: addon.id }), + ], + }); + + // Verify pro is active and addon is canceling + const customerAfterCancel = + await autumnV1.customers.get(customerId); + await expectProductActive({ + customer: customerAfterCancel, + productId: pro.id, + }); + await expectProductCanceling({ + customer: customerAfterCancel, + productId: addon.id, + }); + + // Uncancel the addon + await autumnV1.subscriptions.update({ + customer_id: customerId, + product_id: addon.id, + cancel: null, + }); + + // Verify addon is now active, pro unchanged + const customerAfterUncancel = + await autumnV1.customers.get(customerId); + await expectProductActive({ + customer: customerAfterUncancel, + productId: addon.id, + }); + await expectProductActive({ + customer: customerAfterUncancel, + productId: pro.id, + }); + + // Verify balances + expect(customerAfterUncancel.features?.[TestFeature.Messages]?.balance).toBe( + 100, + ); + expect(customerAfterUncancel.features?.[TestFeature.Credits]?.balance).toBe( + 50, + ); + + // Verify Stripe subscription is correct + await expectSubToBeCorrect({ + db: ctx.db, + customerId, + org: ctx.org, + env: ctx.env, + shouldBeCanceled: false, + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════════ +// TEST 2: Uncancel main while add-on is canceling +// ═══════════════════════════════════════════════════════════════════════════════ + +test.concurrent(`${chalk.yellowBright("uncancel main: addon canceling")}`, async () => { + const customerId = "uncancel-main-addon-cancel"; + const messagesItem = items.monthlyMessages({ includedUsage: 100 }); + const creditsItem = items.monthlyCredits({ includedUsage: 50 }); + + const pro = products.pro({ items: [messagesItem] }); + const addon = constructProduct({ + id: "addon", + items: [creditsItem], + type: "pro", + isDefault: false, + isAddOn: true, + }); + + const { autumnV1, ctx } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [pro, addon] }), + ], + actions: [ + s.attach({ productId: pro.id }), + s.attach({ productId: addon.id }), + s.cancel({ productId: pro.id }), + s.cancel({ productId: addon.id }), + ], + }); + + // Verify both are canceling + const customerAfterCancel = + await autumnV1.customers.get(customerId); + await expectProductCanceling({ + customer: customerAfterCancel, + productId: pro.id, + }); + await expectProductCanceling({ + customer: customerAfterCancel, + productId: addon.id, + }); + + // Uncancel only the main product + await autumnV1.subscriptions.update({ + customer_id: customerId, + product_id: pro.id, + cancel: null, + }); + + // Verify main is active, addon still canceling + const customerAfterUncancel = + await autumnV1.customers.get(customerId); + await expectProductActive({ + customer: customerAfterUncancel, + productId: pro.id, + }); + await expectProductCanceling({ + customer: customerAfterUncancel, + productId: addon.id, + }); + + // Verify Stripe subscription + await expectSubToBeCorrect({ + db: ctx.db, + customerId, + org: ctx.org, + env: ctx.env, + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════════ +// TEST 3: Uncancel both main and add-on +// ═══════════════════════════════════════════════════════════════════════════════ + +test.concurrent(`${chalk.yellowBright("uncancel both: main and addon")}`, async () => { + const customerId = "uncancel-both"; + const messagesItem = items.monthlyMessages({ includedUsage: 100 }); + const creditsItem = items.monthlyCredits({ includedUsage: 50 }); + + const pro = products.pro({ items: [messagesItem] }); + const addon = constructProduct({ + id: "addon", + items: [creditsItem], + type: "pro", + isDefault: false, + isAddOn: true, + }); + + const { autumnV1, ctx } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [pro, addon] }), + ], + actions: [ + s.attach({ productId: pro.id }), + s.attach({ productId: addon.id }), + s.cancel({ productId: pro.id }), + s.cancel({ productId: addon.id }), + ], + }); + + // Verify both are canceling + const customerAfterCancel = + await autumnV1.customers.get(customerId); + await expectProductCanceling({ + customer: customerAfterCancel, + productId: pro.id, + }); + await expectProductCanceling({ + customer: customerAfterCancel, + productId: addon.id, + }); + + // Uncancel both products + await autumnV1.subscriptions.update({ + customer_id: customerId, + product_id: pro.id, + cancel: null, + }); + await autumnV1.subscriptions.update({ + customer_id: customerId, + product_id: addon.id, + cancel: null, + }); + + // Verify both are active + const customerAfterUncancel = + await autumnV1.customers.get(customerId); + await expectProductActive({ + customer: customerAfterUncancel, + productId: pro.id, + }); + await expectProductActive({ + customer: customerAfterUncancel, + productId: addon.id, + }); + + // Verify balances + expect(customerAfterUncancel.features?.[TestFeature.Messages]?.balance).toBe( + 100, + ); + expect(customerAfterUncancel.features?.[TestFeature.Credits]?.balance).toBe( + 50, + ); + + // Verify Stripe subscription + await expectSubToBeCorrect({ + db: ctx.db, + customerId, + org: ctx.org, + env: ctx.env, + shouldBeCanceled: false, + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════════ +// TEST 4: Uncancel product on separate subscription +// ═══════════════════════════════════════════════════════════════════════════════ + +test.concurrent(`${chalk.yellowBright("uncancel: separate subscriptions")}`, async () => { + const customerId = "uncancel-separate-subs"; + const messagesItem = items.monthlyMessages({ includedUsage: 100 }); + const creditsItem = items.monthlyCredits({ includedUsage: 50 }); + + // Main product - monthly billing + const pro = products.pro({ items: [messagesItem] }); + + // Add-on with different billing - will create separate subscription + const addon = constructProduct({ + id: "addon", + items: [creditsItem], + type: "pro", + isDefault: false, + isAddOn: true, + }); + + const { autumnV1, ctx } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [pro, addon] }), + ], + actions: [ + s.attach({ productId: pro.id }), + s.attach({ productId: addon.id }), + ], + }); + + // Cancel only the addon + await autumnV1.cancel({ + customer_id: customerId, + product_id: addon.id, + }); + + // Verify addon is canceling, pro still active + const customerAfterCancel = + await autumnV1.customers.get(customerId); + await expectProductActive({ + customer: customerAfterCancel, + productId: pro.id, + }); + await expectProductCanceling({ + customer: customerAfterCancel, + productId: addon.id, + }); + + // Uncancel the addon + await autumnV1.subscriptions.update({ + customer_id: customerId, + product_id: addon.id, + cancel: null, + }); + + // Verify both active + const customerAfterUncancel = + await autumnV1.customers.get(customerId); + await expectProductActive({ + customer: customerAfterUncancel, + productId: pro.id, + }); + await expectProductActive({ + customer: customerAfterUncancel, + productId: addon.id, + }); + + // Verify Stripe - main subscription should not have been affected + await expectSubToBeCorrect({ + db: ctx.db, + customerId, + org: ctx.org, + env: ctx.env, + shouldBeCanceled: false, + }); +}); diff --git a/server/tests/integration/billing/update-subscription/uncancel/uncancel-basic.test.ts b/server/tests/integration/billing/update-subscription/uncancel/uncancel-basic.test.ts new file mode 100644 index 000000000..0f71bd2e0 --- /dev/null +++ b/server/tests/integration/billing/update-subscription/uncancel/uncancel-basic.test.ts @@ -0,0 +1,355 @@ +import { expect, test } from "bun:test"; +import { type ApiCustomerV3, ErrCode } from "@autumn/shared"; +import { + expectProductActive, + expectProductCanceling, + expectProductNotPresent, + expectProductScheduled, +} from "@tests/integration/billing/utils/expectCustomerProductCorrect"; +import { expectSubToBeCorrect } from "@tests/merged/mergeUtils/expectSubCorrect"; +import { TestFeature } from "@tests/setup/v2Features"; +import { expectAutumnError } from "@tests/utils/expectUtils/expectErrUtils"; +import { items } from "@tests/utils/fixtures/items"; +import { products } from "@tests/utils/fixtures/products"; +import { advanceToNextInvoice } from "@tests/utils/testAttachUtils/testAttachUtils"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario"; +import chalk from "chalk"; +import { constructProduct } from "@/utils/scriptUtils/createTestProducts"; + +/** + * Uncancel Basic Tests + * + * Core uncancel functionality and error cases. + * Tests: cancel: null via subscriptions.update() + */ + +// ═══════════════════════════════════════════════════════════════════════════════ +// TEST 1: Uncancel with scheduled default product +// ═══════════════════════════════════════════════════════════════════════════════ + +test.concurrent(`${chalk.yellowBright("uncancel: with scheduled default product")}`, async () => { + const customerId = "uncancel-with-default"; + const messagesItem = items.monthlyMessages({ includedUsage: 100 }); + const freeMessagesItem = items.monthlyMessages({ includedUsage: 10 }); + + const pro = products.pro({ items: [messagesItem] }); + const free = constructProduct({ + id: "free", + items: [freeMessagesItem], + type: "free", + isDefault: true, + }); + + const { autumnV1, ctx } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [pro, free] }), + ], + actions: [s.attach({ productId: pro.id }), s.cancel({ productId: pro.id })], + }); + + // Verify pro is canceling and free is scheduled + const customerAfterCancel = + await autumnV1.customers.get(customerId); + await expectProductCanceling({ + customer: customerAfterCancel, + productId: pro.id, + }); + await expectProductScheduled({ + customer: customerAfterCancel, + productId: free.id, + }); + + // Uncancel via subscriptions.update with cancel: null + await autumnV1.subscriptions.update({ + customer_id: customerId, + product_id: pro.id, + cancel: null, + }); + + // Verify pro is now active (not canceling) + const customerAfterUncancel = + await autumnV1.customers.get(customerId); + await expectProductActive({ + customer: customerAfterUncancel, + productId: pro.id, + }); + + // Scheduled free product should be deleted + await expectProductNotPresent({ + customer: customerAfterUncancel, + productId: free.id, + }); + + // Verify balance unchanged (still 100 from pro) + expect(customerAfterUncancel.features?.[TestFeature.Messages]?.balance).toBe( + 100, + ); + + // Verify Stripe subscription is correct (cancel_at cleared, schedule released) + await expectSubToBeCorrect({ + db: ctx.db, + customerId, + org: ctx.org, + env: ctx.env, + shouldBeCanceled: false, + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════════ +// TEST 2: Uncancel already active product (no-op) +// ═══════════════════════════════════════════════════════════════════════════════ + +test.concurrent(`${chalk.yellowBright("uncancel: already active (no-op)")}`, async () => { + const customerId = "uncancel-noop"; + const messagesItem = items.monthlyMessages({ includedUsage: 100 }); + const pro = products.pro({ items: [messagesItem] }); + + const { autumnV1, ctx } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [pro] }), + ], + actions: [s.attach({ productId: pro.id })], + }); + + // Verify pro is active (not canceling) + const customerBefore = + await autumnV1.customers.get(customerId); + await expectProductActive({ + customer: customerBefore, + productId: pro.id, + }); + + // Uncancel on already active product - should be a no-op + await autumnV1.subscriptions.update({ + customer_id: customerId, + product_id: pro.id, + cancel: null, + }); + + // Verify pro is still active + const customerAfter = await autumnV1.customers.get(customerId); + await expectProductActive({ + customer: customerAfter, + productId: pro.id, + }); + + // Balance unchanged + expect(customerAfter.features?.[TestFeature.Messages]?.balance).toBe(100); + + // Stripe subscription correct + await expectSubToBeCorrect({ + db: ctx.db, + customerId, + org: ctx.org, + env: ctx.env, + shouldBeCanceled: false, + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════════ +// TEST 3: Uncancel preserves usage +// ═══════════════════════════════════════════════════════════════════════════════ + +test.concurrent(`${chalk.yellowBright("uncancel: preserves usage")}`, async () => { + const customerId = "uncancel-usage"; + const messagesItem = items.monthlyMessages({ includedUsage: 100 }); + const pro = products.pro({ items: [messagesItem] }); + + const { autumnV1, ctx } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [pro] }), + ], + actions: [s.attach({ productId: pro.id, timeout: 4000 })], + }); + + // Track some usage (the timeout waits after track completes) + const messagesUsage = 40; + await autumnV1.track( + { + customer_id: customerId, + feature_id: TestFeature.Messages, + value: messagesUsage, + }, + { timeout: 4000 }, + ); + + // Verify usage tracked + const customerWithUsage = + await autumnV1.customers.get(customerId); + expect(customerWithUsage.features?.[TestFeature.Messages]?.usage).toBe( + messagesUsage, + ); + + // Cancel pro + + await autumnV1.cancel({ + customer_id: customerId, + product_id: pro.id, + }); + // await autumnV1.subscriptions.update({ + // customer_id: customerId, + // product_id: pro.id, + // cancel: "end_of_cycle", + // }); + + await new Promise((resolve) => setTimeout(resolve, 4000)); + + // Verify pro is canceling + const customerAfterCancel = + await autumnV1.customers.get(customerId); + + await expectProductCanceling({ + customer: customerAfterCancel, + productId: pro.id, + }); + + // Uncancel + await autumnV1.subscriptions.update({ + customer_id: customerId, + product_id: pro.id, + cancel: null, + }); + + await new Promise((resolve) => setTimeout(resolve, 4000)); + + // Verify pro is active and usage preserved + const customerAfterUncancel = + await autumnV1.customers.get(customerId); + await expectProductActive({ + customer: customerAfterUncancel, + productId: pro.id, + }); + + // Usage should be preserved + expect(customerAfterUncancel.features?.[TestFeature.Messages]?.usage).toBe( + messagesUsage, + ); + expect(customerAfterUncancel.features?.[TestFeature.Messages]?.balance).toBe( + 100 - messagesUsage, + ); + + // Stripe subscription correct + await expectSubToBeCorrect({ + db: ctx.db, + customerId, + org: ctx.org, + env: ctx.env, + shouldBeCanceled: false, + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════════ +// TEST 4: Error - cannot uncancel scheduled product +// ═══════════════════════════════════════════════════════════════════════════════ + +test.concurrent(`${chalk.yellowBright("error: uncancel scheduled product")}`, async () => { + const customerId = "uncancel-err-scheduled"; + const messagesItem = items.monthlyMessages({ includedUsage: 100 }); + const premiumMessagesItem = items.monthlyMessages({ includedUsage: 500 }); + + const pro = products.pro({ items: [messagesItem] }); + const premium = constructProduct({ + id: "premium", + items: [premiumMessagesItem], + type: "premium", + isDefault: false, + }); + + const { autumnV1 } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [premium, pro] }), + ], + actions: [s.attach({ productId: premium.id })], + }); + + // Downgrade from premium to pro - pro becomes scheduled + await autumnV1.attach({ + customer_id: customerId, + product_id: pro.id, + }); + + // Verify pro is scheduled + const customerAfterDowngrade = + await autumnV1.customers.get(customerId); + await expectProductScheduled({ + customer: customerAfterDowngrade, + productId: pro.id, + }); + + // Try to uncancel the scheduled product - should error + await expectAutumnError({ + errCode: ErrCode.InvalidRequest, + func: async () => { + await autumnV1.subscriptions.update({ + customer_id: customerId, + product_id: pro.id, + cancel: null, + }); + }, + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════════ +// TEST 5: Error - cannot uncancel expired product +// ═══════════════════════════════════════════════════════════════════════════════ + +test.concurrent(`${chalk.yellowBright("error: uncancel expired product")}`, async () => { + const customerId = "uncancel-err-expired"; + const messagesItem = items.monthlyMessages({ includedUsage: 100 }); + const freeMessagesItem = items.monthlyMessages({ includedUsage: 10 }); + + const pro = products.pro({ items: [messagesItem] }); + const free = constructProduct({ + id: "free", + items: [freeMessagesItem], + type: "free", + isDefault: true, + }); + + const { autumnV1, ctx, testClockId } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [pro, free] }), + ], + actions: [s.attach({ productId: pro.id }), s.cancel({ productId: pro.id })], + }); + + // Advance to next billing cycle so pro expires + await advanceToNextInvoice({ + stripeCli: ctx.stripeCli, + testClockId: testClockId!, + }); + + // Verify pro is expired (free should be active now) + const customerAfterAdvance = + await autumnV1.customers.get(customerId); + await expectProductNotPresent({ + customer: customerAfterAdvance, + productId: pro.id, + }); + await expectProductActive({ + customer: customerAfterAdvance, + productId: free.id, + }); + + // Try to uncancel the expired product - should error + await expectAutumnError({ + errCode: ErrCode.InternalError, + func: async () => { + await autumnV1.subscriptions.update({ + customer_id: customerId, + product_id: pro.id, + cancel: null, + }); + }, + }); +}); diff --git a/server/tests/integration/billing/update-subscription/uncancel/uncancel-combined.test.ts b/server/tests/integration/billing/update-subscription/uncancel/uncancel-combined.test.ts new file mode 100644 index 000000000..a6be138f1 --- /dev/null +++ b/server/tests/integration/billing/update-subscription/uncancel/uncancel-combined.test.ts @@ -0,0 +1,378 @@ +import { expect, test } from "bun:test"; +import { type ApiCustomerV3, ms } from "@autumn/shared"; +import { expectCustomerInvoiceCorrect } from "@tests/integration/billing/utils/expectCustomerInvoiceCorrect"; +import { + expectProductActive, + expectProductCanceling, + expectProductNotPresent, + expectProductScheduled, +} from "@tests/integration/billing/utils/expectCustomerProductCorrect"; +import { + expectProductTrialing, + getTrialEndsAt, +} from "@tests/integration/billing/utils/expectCustomerProductTrialing"; +import { expectSubToBeCorrect } from "@tests/merged/mergeUtils/expectSubCorrect"; +import { TestFeature } from "@tests/setup/v2Features"; +import { items } from "@tests/utils/fixtures/items"; +import { products } from "@tests/utils/fixtures/products"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario"; +import chalk from "chalk"; +import { constructProduct } from "@/utils/scriptUtils/createTestProducts"; + +/** + * Uncancel Combined Tests + * + * Tests for uncancel combined with other update operations. + * Tests: cancel: null + options, cancel: null + items, cancel: null + trialing + */ + +// =============================================================================== +// TEST 1: Uncancel + update quantity +// =============================================================================== + +/** + * Scenario: + * - User is on Pro with some usage tracked + * - User cancels Pro -> free default is scheduled + * - User uncancels AND updates quantity in the same request + * + * Expected Result: + * - Pro should be active (not canceling) + * - Scheduled free product should be deleted + * - Quantity should be updated + * - Usage should be preserved + */ +test.concurrent(`${chalk.yellowBright("uncancel + update quantity")}`, async () => { + const customerId = "uncancel-plus-qty"; + const prepaidItem = items.prepaidMessages({ includedUsage: 0 }); + const freeMessagesItem = items.monthlyMessages({ includedUsage: 10 }); + + const pro = products.pro({ items: [prepaidItem] }); + const free = constructProduct({ + id: "free", + items: [freeMessagesItem], + type: "free", + isDefault: true, + }); + + const { autumnV1, ctx } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [pro, free] }), + ], + actions: [ + s.attach({ + productId: pro.id, + options: [{ feature_id: TestFeature.Messages, quantity: 100 }], + }), + ], + }); + + // Track some usage (the timeout waits after track completes) + const messagesUsage = 40; + await autumnV1.track( + { + customer_id: customerId, + feature_id: TestFeature.Messages, + value: messagesUsage, + }, + { timeout: 2000 }, + ); + + // Verify usage tracked + const customerWithUsage = + await autumnV1.customers.get(customerId); + expect(customerWithUsage.features?.[TestFeature.Messages]?.usage).toBe( + messagesUsage, + ); + + // Cancel pro + await autumnV1.cancel({ + customer_id: customerId, + product_id: pro.id, + }); + + // Verify pro is canceling and free is scheduled + const customerAfterCancel = + await autumnV1.customers.get(customerId); + await expectProductCanceling({ + customer: customerAfterCancel, + productId: pro.id, + }); + await expectProductScheduled({ + customer: customerAfterCancel, + productId: free.id, + }); + + // Uncancel AND update quantity in the same request + const newQuantity = 200; + const preview = await autumnV1.subscriptions.previewUpdate({ + customer_id: customerId, + product_id: pro.id, + cancel: null, + options: [{ feature_id: TestFeature.Messages, quantity: newQuantity }], + }); + + await autumnV1.subscriptions.update({ + customer_id: customerId, + product_id: pro.id, + cancel: null, + options: [{ feature_id: TestFeature.Messages, quantity: newQuantity }], + }); + + // Verify pro is now active (not canceling) + const customerAfterUncancel = + await autumnV1.customers.get(customerId); + await expectProductActive({ + customer: customerAfterUncancel, + productId: pro.id, + }); + + // Scheduled free product should be deleted + await expectProductNotPresent({ + customer: customerAfterUncancel, + productId: free.id, + }); + + // Balance should be updated (new quantity minus usage) + expect(customerAfterUncancel.features?.[TestFeature.Messages]?.balance).toBe( + newQuantity - messagesUsage, + ); + + // Usage should be preserved + expect(customerAfterUncancel.features?.[TestFeature.Messages]?.usage).toBe( + messagesUsage, + ); + + // Verify Stripe subscription is correct + await expectSubToBeCorrect({ + db: ctx.db, + customerId, + org: ctx.org, + env: ctx.env, + shouldBeCanceled: false, + }); + + // Verify invoices + expectCustomerInvoiceCorrect({ + customer: customerAfterUncancel, + count: 2, // Initial attach + update + latestTotal: preview.total, + }); +}); + +// =============================================================================== +// TEST 2: Uncancel + custom plan (items) +// =============================================================================== + +/** + * Scenario: + * - User is on Pro + * - User cancels Pro -> free default is scheduled + * - User uncancels AND provides custom items in the same request + * + * Expected Result: + * - Pro should be active with custom items + * - Scheduled free product should be deleted + */ +test.concurrent(`${chalk.yellowBright("uncancel + custom plan (items)")}`, async () => { + const customerId = "uncancel-plus-items"; + const messagesItem = items.monthlyMessages({ includedUsage: 100 }); + const freeMessagesItem = items.monthlyMessages({ includedUsage: 10 }); + + const pro = products.pro({ items: [messagesItem] }); + const free = constructProduct({ + id: "free", + items: [freeMessagesItem], + type: "free", + isDefault: true, + }); + + const { autumnV1, ctx } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [pro, free] }), + ], + actions: [s.attach({ productId: pro.id })], + }); + + // Cancel pro + await autumnV1.cancel({ + customer_id: customerId, + product_id: pro.id, + }); + + // Verify pro is canceling and free is scheduled + const customerAfterCancel = + await autumnV1.customers.get(customerId); + await expectProductCanceling({ + customer: customerAfterCancel, + productId: pro.id, + }); + await expectProductScheduled({ + customer: customerAfterCancel, + productId: free.id, + }); + + // Uncancel AND provide custom items in the same request + const updatedMessagesItem = items.monthlyMessages({ includedUsage: 200 }); + const newPriceItem = items.monthlyPrice({ price: 30 }); + + const preview = await autumnV1.subscriptions.previewUpdate({ + customer_id: customerId, + product_id: pro.id, + cancel: null, + items: [updatedMessagesItem, newPriceItem], + }); + + await autumnV1.subscriptions.update({ + customer_id: customerId, + product_id: pro.id, + cancel: null, + items: [updatedMessagesItem, newPriceItem], + }); + + // Verify pro is now active (not canceling) + const customerAfterUncancel = + await autumnV1.customers.get(customerId); + await expectProductActive({ + customer: customerAfterUncancel, + productId: pro.id, + }); + + // Scheduled free product should be deleted + await expectProductNotPresent({ + customer: customerAfterUncancel, + productId: free.id, + }); + + // Balance should reflect the custom plan (200 messages) + expect(customerAfterUncancel.features?.[TestFeature.Messages]?.balance).toBe( + 200, + ); + + // Verify Stripe subscription is correct + await expectSubToBeCorrect({ + db: ctx.db, + customerId, + org: ctx.org, + env: ctx.env, + shouldBeCanceled: false, + }); + + // Verify invoices + expectCustomerInvoiceCorrect({ + customer: customerAfterUncancel, + count: 2, // Initial attach + update + latestTotal: preview.total, + }); +}); + +// =============================================================================== +// TEST 3: Uncancel trialing product +// =============================================================================== + +/** + * Scenario: + * - User is on Pro with trial (14 days) + * - User cancels Pro while trialing -> product becomes trialing + canceling + * - User uncancels + * + * Expected Result: + * - Pro should be trialing (still in trial period) + * - Trial end time should be unchanged + * - Product should no longer be canceling + */ +test.concurrent(`${chalk.yellowBright("uncancel trialing product")}`, async () => { + const customerId = "uncancel-trialing"; + const messagesItem = items.monthlyMessages({ includedUsage: 100 }); + + const proTrial = products.proWithTrial({ + items: [messagesItem], + id: "pro-trial", + trialDays: 14, + }); + + const { autumnV1, ctx, advancedTo } = await initScenario({ + customerId, + setup: [ + s.customer({ testClock: true, paymentMethod: "success" }), + s.products({ list: [proTrial] }), + ], + actions: [s.attach({ productId: proTrial.id })], + }); + + // Verify initially trialing + const customerAfterAttach = + await autumnV1.customers.get(customerId); + await expectProductTrialing({ + customer: customerAfterAttach, + productId: proTrial.id, + }); + + // Get the trial end time before cancel + const trialEndsAtBefore = await getTrialEndsAt({ + customer: customerAfterAttach, + productId: proTrial.id, + }); + expect(trialEndsAtBefore).toBeDefined(); + + // Cancel the trialing product + await autumnV1.cancel({ + customer_id: customerId, + product_id: proTrial.id, + }); + + // Verify product is canceling (canceled flag set, but still trialing) + const customerAfterCancel = + await autumnV1.customers.get(customerId); + await expectProductCanceling({ + customer: customerAfterCancel, + productId: proTrial.id, + }); + + // Uncancel + await autumnV1.subscriptions.update({ + customer_id: customerId, + product_id: proTrial.id, + cancel: null, + }); + + // Verify product is still trialing and no longer canceling + const customerAfterUncancel = + await autumnV1.customers.get(customerId); + + // Should be trialing (active with trial status) + await expectProductTrialing({ + customer: customerAfterUncancel, + productId: proTrial.id, + }); + + // Get the trial end time after uncancel + const trialEndsAtAfter = await getTrialEndsAt({ + customer: customerAfterUncancel, + productId: proTrial.id, + }); + + // Trial end time should be unchanged (within tolerance) + expect(trialEndsAtAfter).toBeDefined(); + expect( + Math.abs(trialEndsAtAfter! - trialEndsAtBefore!) < ms.minutes(10), + ).toBe(true); + + // Balance should be unchanged + expect(customerAfterUncancel.features?.[TestFeature.Messages]?.balance).toBe( + 100, + ); + + // Verify Stripe subscription is correct (not set to cancel) + await expectSubToBeCorrect({ + db: ctx.db, + customerId, + org: ctx.org, + env: ctx.env, + shouldBeCanceled: false, + }); +}); diff --git a/server/tests/integration/billing/update-subscription/uncancel/uncancel-edge-cases.test.ts b/server/tests/integration/billing/update-subscription/uncancel/uncancel-edge-cases.test.ts new file mode 100644 index 000000000..38357e6a1 --- /dev/null +++ b/server/tests/integration/billing/update-subscription/uncancel/uncancel-edge-cases.test.ts @@ -0,0 +1,539 @@ +import { expect, test } from "bun:test"; +import { type ApiCustomerV3, FreeTrialDuration, ms } from "@autumn/shared"; +import { expectCustomerFeatureCorrect } from "@tests/integration/billing/utils/expectCustomerFeatureCorrect"; +import { expectCustomerInvoiceCorrect } from "@tests/integration/billing/utils/expectCustomerInvoiceCorrect"; +import { + expectProductActive, + expectProductCanceling, + expectProductNotPresent, + expectProductScheduled, +} from "@tests/integration/billing/utils/expectCustomerProductCorrect"; +import { + expectProductNotTrialing, + expectProductTrialing, +} from "@tests/integration/billing/utils/expectCustomerProductTrialing"; +import { expectPreviewNextCycleCorrect } from "@tests/integration/billing/utils/expectPreviewNextCycleCorrect"; +import { expectSubToBeCorrect } from "@tests/merged/mergeUtils/expectSubCorrect"; +import { TestFeature } from "@tests/setup/v2Features"; +import { items } from "@tests/utils/fixtures/items"; +import { products } from "@tests/utils/fixtures/products"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario"; +import chalk from "chalk"; +import { constructProduct } from "@/utils/scriptUtils/createTestProducts"; + +/** + * Uncancel Edge Cases Tests + * + * Critical edge cases for update subscription that combine multiple operations. + * These tests cover parameter combinations that weren't previously tested. + */ + +// =============================================================================== +// TEST 1: Uncancel + version upgrade +// =============================================================================== + +/** + * User is on Pro v1 (canceling), uncancels AND upgrades to v2 in one request. + * Verifies: version change + uncancel combined, proration, scheduled product deleted. + */ +test.concurrent(`${chalk.yellowBright("uncancel + version upgrade")}`, async () => { + const customerId = "uncancel-version-upgrade"; + const messagesItemV1 = items.monthlyMessages({ includedUsage: 100 }); + const freeMessagesItem = items.monthlyMessages({ includedUsage: 10 }); + + // products.pro already includes $20/month price + const pro = products.pro({ items: [messagesItemV1] }); + const free = constructProduct({ + id: "free", + items: [freeMessagesItem], + type: "free", + isDefault: true, + }); + + const { autumnV1 } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [pro, free] }), + ], + actions: [s.attach({ productId: pro.id }), s.cancel({ productId: pro.id })], + }); + + // Verify pro is canceling and free is scheduled + const customerAfterCancel = + await autumnV1.customers.get(customerId); + await expectProductCanceling({ + customer: customerAfterCancel, + productId: pro.id, + }); + await expectProductScheduled({ + customer: customerAfterCancel, + productId: free.id, + }); + + // Create v2 with increased features (price stays $20 from pro) + const messagesItemV2 = items.monthlyMessages({ includedUsage: 200 }); + await autumnV1.products.update(pro.id, { + items: [messagesItemV2], + }); + + // Preview uncancel + version upgrade (same price, just feature change) + await autumnV1.subscriptions.previewUpdate({ + customer_id: customerId, + product_id: pro.id, + cancel: null, + version: 2, + }); + + // Execute uncancel + version upgrade + await autumnV1.subscriptions.update({ + customer_id: customerId, + product_id: pro.id, + cancel: null, + version: 2, + }); + + // Verify pro is now active (not canceling) + const customerAfterUpdate = + await autumnV1.customers.get(customerId); + await expectProductActive({ + customer: customerAfterUpdate, + productId: pro.id, + }); + + // Scheduled free product should be deleted + await expectProductNotPresent({ + customer: customerAfterUpdate, + productId: free.id, + }); + + // Should have v2 features (200 messages) + expectCustomerFeatureCorrect({ + customer: customerAfterUpdate, + featureId: TestFeature.Messages, + includedUsage: 200, + balance: 200, + usage: 0, + }); +}); + +// =============================================================================== +// TEST 2: Uncancel + add trial (paid product enters trial) +// =============================================================================== + +/** + * User is on paid Pro (canceling, not trialing), uncancels AND adds a trial. + * Verifies: gets refund for entering trial, trial end set correctly. + */ +test.concurrent(`${chalk.yellowBright("uncancel + add trial")}`, async () => { + const customerId = "uncancel-add-trial"; + const messagesItem = items.monthlyMessages({ includedUsage: 100 }); + const freeMessagesItem = items.monthlyMessages({ includedUsage: 10 }); + + // products.pro already includes $20/month price + const pro = products.pro({ items: [messagesItem] }); + const free = constructProduct({ + id: "free", + items: [freeMessagesItem], + type: "free", + isDefault: true, + }); + + const { autumnV1, ctx, advancedTo } = await initScenario({ + customerId, + setup: [ + s.customer({ testClock: true, paymentMethod: "success" }), + s.products({ list: [pro, free] }), + ], + actions: [s.attach({ productId: pro.id }), s.cancel({ productId: pro.id })], + }); + + // Verify pro is canceling (not trialing) + const customerAfterCancel = + await autumnV1.customers.get(customerId); + await expectProductCanceling({ + customer: customerAfterCancel, + productId: pro.id, + }); + await expectProductNotTrialing({ + customer: customerAfterCancel, + productId: pro.id, + }); + + // Preview uncancel + add trial + const trialDays = 14; + const preview = await autumnV1.subscriptions.previewUpdate({ + customer_id: customerId, + product_id: pro.id, + cancel: null, + free_trial: { + length: trialDays, + duration: FreeTrialDuration.Day, + card_required: true, + unique_fingerprint: false, + }, + }); + + // Should refund for entering trial (negative total) + expect(preview.total).toBeLessThan(0); + + // next_cycle should show when trial ends + expectPreviewNextCycleCorrect({ + preview, + startsAt: advancedTo + ms.days(trialDays), + total: 20, // pro price + }); + + // Execute uncancel + add trial + await autumnV1.subscriptions.update({ + customer_id: customerId, + product_id: pro.id, + cancel: null, + free_trial: { + length: trialDays, + duration: FreeTrialDuration.Day, + card_required: true, + unique_fingerprint: false, + }, + }); + + // Verify pro is now active AND trialing + const customerAfterUpdate = + await autumnV1.customers.get(customerId); + await expectProductActive({ + customer: customerAfterUpdate, + productId: pro.id, + }); + await expectProductTrialing({ + customer: customerAfterUpdate, + productId: pro.id, + trialEndsAt: advancedTo + ms.days(trialDays), + }); + + // Scheduled free should be deleted + await expectProductNotPresent({ + customer: customerAfterUpdate, + productId: free.id, + }); + + // Verify Stripe subscription + await expectSubToBeCorrect({ + db: ctx.db, + customerId, + org: ctx.org, + env: ctx.env, + shouldBeCanceled: false, + }); +}); + +// =============================================================================== +// TEST 3: Remove trial while canceling (cancel state preserved) +// =============================================================================== + +/** + * User is on Pro (trialing AND canceling), removes trial but does NOT uncancel. + * Verifies: cancel state is preserved, charged for ending trial. + */ +test.concurrent(`${chalk.yellowBright("remove trial while canceling: cancel preserved")}`, async () => { + const customerId = "remove-trial-while-cancel"; + const messagesItem = items.monthlyMessages({ includedUsage: 100 }); + const freeMessagesItem = items.monthlyMessages({ includedUsage: 10 }); + + // products.proWithTrial includes $20/month price + trial + const pro = products.proWithTrial({ + items: [messagesItem], + id: "pro-trial", + trialDays: 14, + }); + const free = constructProduct({ + id: "free", + items: [freeMessagesItem], + type: "free", + isDefault: true, + }); + + const { autumnV1, ctx } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [pro, free] }), + ], + actions: [s.attach({ productId: pro.id }), s.cancel({ productId: pro.id })], + }); + + // Verify trialing + canceling + const customerAfterCancel = + await autumnV1.customers.get(customerId); + await expectProductCanceling({ + customer: customerAfterCancel, + productId: pro.id, + }); + await expectProductTrialing({ + customer: customerAfterCancel, + productId: pro.id, + }); + await expectProductScheduled({ + customer: customerAfterCancel, + productId: free.id, + }); + + // Preview removing trial (NOT uncanceling) + const preview = await autumnV1.subscriptions.previewUpdate({ + customer_id: customerId, + product_id: pro.id, + free_trial: null, + }); + + // Should charge for ending trial + expect(preview.total).toBeGreaterThan(0); + + // Execute remove trial + await autumnV1.subscriptions.update({ + customer_id: customerId, + product_id: pro.id, + free_trial: null, + }); + + // Verify pro is STILL canceling but NOT trialing + const customerAfterUpdate = + await autumnV1.customers.get(customerId); + await expectProductCanceling({ + customer: customerAfterUpdate, + productId: pro.id, + }); + await expectProductNotTrialing({ + customer: customerAfterUpdate, + productId: pro.id, + }); + + // Scheduled free should STILL be scheduled + await expectProductScheduled({ + customer: customerAfterUpdate, + productId: free.id, + }); + + // Verify invoices (don't assert latestTotal - proration varies based on timing) + expectCustomerInvoiceCorrect({ + customer: customerAfterUpdate, + count: 2, + }); + + // Verify Stripe subscription (should still be set to cancel) + await expectSubToBeCorrect({ + db: ctx.db, + customerId, + org: ctx.org, + env: ctx.env, + shouldBeCanceled: true, + }); +}); + +// =============================================================================== +// TEST 4: Uncancel during downgrade (Premium -> Pro, uncancel Premium) +// =============================================================================== + +/** + * User is on Premium ($50), downgrades to Pro (Premium canceling, Pro scheduled). + * User uncancels Premium. Verifies: Premium active, scheduled Pro deleted. + */ +test.concurrent(`${chalk.yellowBright("uncancel during downgrade")}`, async () => { + const customerId = "uncancel-downgrade"; + const premiumMessagesItem = items.monthlyMessages({ includedUsage: 500 }); + const proMessagesItem = items.monthlyMessages({ includedUsage: 100 }); + + // constructProduct with type: "premium" adds $50/month price + const premium = constructProduct({ + id: "premium", + items: [premiumMessagesItem], + type: "premium", + isDefault: false, + }); + + // products.pro adds $20/month price + const pro = products.pro({ items: [proMessagesItem] }); + + const { autumnV1, ctx } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [premium, pro] }), + ], + actions: [s.attach({ productId: premium.id })], + }); + + // Verify premium is active + const customerBefore = + await autumnV1.customers.get(customerId); + await expectProductActive({ + customer: customerBefore, + productId: premium.id, + }); + + // Downgrade from Premium to Pro + await autumnV1.attach({ + customer_id: customerId, + product_id: pro.id, + }); + + // Verify Premium is canceling, Pro is scheduled + const customerAfterDowngrade = + await autumnV1.customers.get(customerId); + await expectProductCanceling({ + customer: customerAfterDowngrade, + productId: premium.id, + }); + await expectProductScheduled({ + customer: customerAfterDowngrade, + productId: pro.id, + }); + + // Uncancel Premium + await autumnV1.subscriptions.update({ + customer_id: customerId, + product_id: premium.id, + cancel: null, + }); + + // Verify Premium is active, Pro is deleted + const customerAfterUncancel = + await autumnV1.customers.get(customerId); + await expectProductActive({ + customer: customerAfterUncancel, + productId: premium.id, + }); + await expectProductNotPresent({ + customer: customerAfterUncancel, + productId: pro.id, + }); + + // Features should be unchanged + expectCustomerFeatureCorrect({ + customer: customerAfterUncancel, + featureId: TestFeature.Messages, + includedUsage: 500, + balance: 500, + usage: 0, + }); + + // Verify Stripe subscription + await expectSubToBeCorrect({ + db: ctx.db, + customerId, + org: ctx.org, + env: ctx.env, + shouldBeCanceled: false, + }); +}); + +// =============================================================================== +// TEST 5: Uncancel + custom items + invoice mode +// =============================================================================== + +/** + * User is on Pro (canceling), uncancels with custom items using invoice mode. + * Verifies: invoice created and paid, product updated, scheduled product deleted. + */ +test.concurrent(`${chalk.yellowBright("uncancel + items + invoice mode")}`, async () => { + const customerId = "uncancel-invoice-mode"; + const messagesItem = items.monthlyMessages({ includedUsage: 100 }); + const freeMessagesItem = items.monthlyMessages({ includedUsage: 10 }); + + // products.pro adds $20/month price + const pro = products.pro({ items: [messagesItem] }); + const free = constructProduct({ + id: "free", + items: [freeMessagesItem], + type: "free", + isDefault: true, + }); + + const { autumnV1, ctx } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [pro, free] }), + ], + actions: [s.attach({ productId: pro.id }), s.cancel({ productId: pro.id })], + }); + + // Verify canceling + const customerAfterCancel = + await autumnV1.customers.get(customerId); + await expectProductCanceling({ + customer: customerAfterCancel, + productId: pro.id, + }); + await expectProductScheduled({ + customer: customerAfterCancel, + productId: free.id, + }); + + // Custom items: more features + higher price ($40) + const customMessagesItem = items.monthlyMessages({ includedUsage: 200 }); + const customPriceItem = items.monthlyPrice({ price: 40 }); + + // Preview + const preview = await autumnV1.subscriptions.previewUpdate({ + customer_id: customerId, + product_id: pro.id, + cancel: null, + items: [customMessagesItem, customPriceItem], + invoice: true, + finalize_invoice: true, + }); + + // Should charge prorated difference ($40 - $20 = $20 prorated) + expect(preview.total).toBeGreaterThan(0); + + // Execute uncancel + items with invoice mode + const updateResult = await autumnV1.subscriptions.update({ + customer_id: customerId, + product_id: pro.id, + cancel: null, + items: [customMessagesItem, customPriceItem], + invoice: true, + finalize_invoice: true, + }); + + // Should return invoice info (finalized but awaiting payment) + expect(updateResult.invoice).toBeDefined(); + expect(updateResult.invoice?.status).toBe("open"); + + // Verify pro is active with custom items + const customerAfterUpdate = + await autumnV1.customers.get(customerId); + await expectProductActive({ + customer: customerAfterUpdate, + productId: pro.id, + }); + + // Scheduled free should be deleted + await expectProductNotPresent({ + customer: customerAfterUpdate, + productId: free.id, + }); + + // Should have custom features + expectCustomerFeatureCorrect({ + customer: customerAfterUpdate, + featureId: TestFeature.Messages, + includedUsage: 200, + balance: 200, + usage: 0, + }); + + // Verify invoices (don't assert latestTotal - proration varies based on timing) + expectCustomerInvoiceCorrect({ + customer: customerAfterUpdate, + count: 2, + }); + + // Verify Stripe subscription + await expectSubToBeCorrect({ + db: ctx.db, + customerId, + org: ctx.org, + env: ctx.env, + shouldBeCanceled: false, + }); +}); diff --git a/server/tests/integration/billing/update-subscription/uncancel/uncancel-entities.test.ts b/server/tests/integration/billing/update-subscription/uncancel/uncancel-entities.test.ts new file mode 100644 index 000000000..24a3deaa6 --- /dev/null +++ b/server/tests/integration/billing/update-subscription/uncancel/uncancel-entities.test.ts @@ -0,0 +1,293 @@ +import { expect, test } from "bun:test"; +import type { ApiCustomerV3 } from "@autumn/shared"; +import { + expectProductActive, + expectProductCanceling, + expectProductNotPresent, + expectProductScheduled, +} from "@tests/integration/billing/utils/expectCustomerProductCorrect"; +import { expectSubToBeCorrect } from "@tests/merged/mergeUtils/expectSubCorrect"; +import { TestFeature } from "@tests/setup/v2Features"; +import { items } from "@tests/utils/fixtures/items"; +import { products } from "@tests/utils/fixtures/products"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario"; +import chalk from "chalk"; +import { constructProduct } from "@/utils/scriptUtils/createTestProducts"; + +/** + * Uncancel Entity Tests + * + * Tests for uncanceling entity-scoped products. + */ + +// ═══════════════════════════════════════════════════════════════════════════════ +// TEST 1: Uncancel single entity while other entity is active +// ═══════════════════════════════════════════════════════════════════════════════ + +test.concurrent(`${chalk.yellowBright("uncancel entity: other entity active")}`, async () => { + const customerId = "uncancel-entity-other-active"; + const messagesItem = items.monthlyMessages({ includedUsage: 100 }); + + const pro = products.pro({ items: [messagesItem] }); + + const { autumnV1, ctx, entities } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [pro] }), + s.entities({ count: 2, featureId: TestFeature.Users }), + ], + actions: [ + s.attach({ productId: pro.id, entityIndex: 0 }), + s.attach({ productId: pro.id, entityIndex: 1 }), + ], + }); + + // Cancel only entity 1's product + await autumnV1.cancel({ + customer_id: customerId, + product_id: pro.id, + entity_id: entities[0].id, + }); + + // Verify entity 1 is canceling, entity 2 is active + const entity1AfterCancel = await autumnV1.entities.get( + customerId, + entities[0].id, + ); + const entity2AfterCancel = await autumnV1.entities.get( + customerId, + entities[1].id, + ); + await expectProductCanceling({ + customer: entity1AfterCancel, + productId: pro.id, + }); + await expectProductActive({ + customer: entity2AfterCancel, + productId: pro.id, + }); + + // Uncancel entity 1 + await autumnV1.subscriptions.update({ + customer_id: customerId, + product_id: pro.id, + entity_id: entities[0].id, + cancel: null, + }); + + // Verify both entities are now active + const entity1AfterUncancel = await autumnV1.entities.get( + customerId, + entities[0].id, + ); + const entity2AfterUncancel = await autumnV1.entities.get( + customerId, + entities[1].id, + ); + await expectProductActive({ + customer: entity1AfterUncancel, + productId: pro.id, + }); + await expectProductActive({ + customer: entity2AfterUncancel, + productId: pro.id, + }); + + // Verify balances for both entities + expect(entity1AfterUncancel.features?.[TestFeature.Messages]?.balance).toBe( + 100, + ); + expect(entity2AfterUncancel.features?.[TestFeature.Messages]?.balance).toBe( + 100, + ); + + // Verify Stripe subscription + await expectSubToBeCorrect({ + db: ctx.db, + customerId, + org: ctx.org, + env: ctx.env, + shouldBeCanceled: false, + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════════ +// TEST 2: Uncancel all entities +// ═══════════════════════════════════════════════════════════════════════════════ + +test.concurrent(`${chalk.yellowBright("uncancel: all entities")}`, async () => { + const customerId = "uncancel-all-entities"; + const messagesItem = items.monthlyMessages({ includedUsage: 100 }); + + const pro = products.pro({ items: [messagesItem] }); + + const { autumnV1, ctx, entities } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [pro] }), + s.entities({ count: 2, featureId: TestFeature.Users }), + ], + actions: [ + s.attach({ productId: pro.id, entityIndex: 0 }), + s.attach({ productId: pro.id, entityIndex: 1 }), + ], + }); + + // Cancel both entities + await autumnV1.cancel({ + customer_id: customerId, + product_id: pro.id, + entity_id: entities[0].id, + }); + await autumnV1.cancel({ + customer_id: customerId, + product_id: pro.id, + entity_id: entities[1].id, + }); + + // Verify both are canceling + const entity1AfterCancel = await autumnV1.entities.get( + customerId, + entities[0].id, + ); + const entity2AfterCancel = await autumnV1.entities.get( + customerId, + entities[1].id, + ); + await expectProductCanceling({ + customer: entity1AfterCancel, + productId: pro.id, + }); + await expectProductCanceling({ + customer: entity2AfterCancel, + productId: pro.id, + }); + + // Uncancel both entities + await autumnV1.subscriptions.update({ + customer_id: customerId, + product_id: pro.id, + entity_id: entities[0].id, + cancel: null, + }); + await autumnV1.subscriptions.update({ + customer_id: customerId, + product_id: pro.id, + entity_id: entities[1].id, + cancel: null, + }); + + // Verify both are active + const entity1AfterUncancel = await autumnV1.entities.get( + customerId, + entities[0].id, + ); + const entity2AfterUncancel = await autumnV1.entities.get( + customerId, + entities[1].id, + ); + await expectProductActive({ + customer: entity1AfterUncancel, + productId: pro.id, + }); + await expectProductActive({ + customer: entity2AfterUncancel, + productId: pro.id, + }); + + // Verify Stripe subscription + await expectSubToBeCorrect({ + db: ctx.db, + customerId, + org: ctx.org, + env: ctx.env, + shouldBeCanceled: false, + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════════ +// TEST 3: Uncancel entity with scheduled default product +// ═══════════════════════════════════════════════════════════════════════════════ + +test.concurrent(`${chalk.yellowBright("uncancel entity: with scheduled default")}`, async () => { + const customerId = "uncancel-entity-default"; + const messagesItem = items.monthlyMessages({ includedUsage: 100 }); + const freeMessagesItem = items.monthlyMessages({ includedUsage: 10 }); + + const pro = products.pro({ items: [messagesItem] }); + const free = constructProduct({ + id: "free", + items: [freeMessagesItem], + type: "free", + isDefault: true, + }); + + const { autumnV1, ctx, entities } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [pro, free] }), + s.entities({ count: 1, featureId: TestFeature.Users }), + ], + actions: [s.attach({ productId: pro.id, entityIndex: 0 })], + }); + + // Cancel entity's pro - should schedule free default + await autumnV1.cancel({ + customer_id: customerId, + product_id: pro.id, + entity_id: entities[0].id, + }); + + // Verify pro is canceling and free is scheduled + const entityAfterCancel = await autumnV1.entities.get( + customerId, + entities[0].id, + ); + await expectProductCanceling({ + customer: entityAfterCancel, + productId: pro.id, + }); + await expectProductScheduled({ + customer: entityAfterCancel, + productId: free.id, + }); + + // Uncancel the entity's pro + await autumnV1.subscriptions.update({ + customer_id: customerId, + product_id: pro.id, + entity_id: entities[0].id, + cancel: null, + }); + + // Verify pro is active and free is deleted + const entityAfterUncancel = await autumnV1.entities.get( + customerId, + entities[0].id, + ); + await expectProductActive({ + customer: entityAfterUncancel, + productId: pro.id, + }); + await expectProductNotPresent({ + customer: entityAfterUncancel, + productId: free.id, + }); + + // Verify balance + expect(entityAfterUncancel.features?.[TestFeature.Messages]?.balance).toBe( + 100, + ); + + // Verify Stripe subscription + await expectSubToBeCorrect({ + db: ctx.db, + customerId, + org: ctx.org, + env: ctx.env, + shouldBeCanceled: false, + }); +}); diff --git a/server/tests/scenarios/README.md b/server/tests/scenarios/README.md new file mode 100644 index 000000000..685bc24a0 --- /dev/null +++ b/server/tests/scenarios/README.md @@ -0,0 +1,5 @@ +This folder contains shared test scenarios for efficient setup and teardown of common test cases. + +This is NOT intended to be used for unit or integration tests. + +We keep the `.test.ts` postfix so that we can easily run a setup with the test runner. e.g. `ctrl + enter` to run the scenario. \ No newline at end of file diff --git a/server/tests/scenarios/update-subscription/uncancel-scenario.test.ts b/server/tests/scenarios/update-subscription/uncancel-scenario.test.ts new file mode 100644 index 000000000..eae018297 --- /dev/null +++ b/server/tests/scenarios/update-subscription/uncancel-scenario.test.ts @@ -0,0 +1,36 @@ +import { test } from "bun:test"; +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"; + +/** + * Uncancel Tests (cancel: null) + * + * Tests the uncancel functionality which removes a scheduled cancellation + * from a subscription via the update subscription API. + * + * Usage: subscriptions.update({ customer_id, product_id, cancel: null }) + */ + +test(`${chalk.yellowBright("uncancel: basic - canceling product → uncancel → active")}`, async () => { + const customerId = "uncancel-basic"; + const messagesItem = items.monthlyMessages({ includedUsage: 100 }); + const pro = products.pro({ items: [messagesItem] }); + + const { autumnV1 } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [pro] }), + ], + actions: [s.attach({ productId: pro.id }), s.cancel({ productId: pro.id })], + }); + + // Uncancel via subscriptions.update with cancel: null + // await autumnV1.subscriptions.update({ + // customer_id: customerId, + // product_id: pro.id, + // cancel: null, + // }); +});