From 3027832b68c650f53b29304f1d960f06c4ebf0ea Mon Sep 17 00:00:00 2001 From: John Yeo Date: Tue, 2 Dec 2025 19:06:54 +0000 Subject: [PATCH] fix: cancel while downgrading --- scripts/testGroups/g3.sh | 1 + .../scheduleFlow/handleScheduleFlow2.ts | 8 + .../customers/cancel/handleCancelProduct.ts | 44 ++++-- .../customers/cusProducts/AttachParams.ts | 1 + server/tests/_temp/temp1.test.ts | 48 +++--- server/tests/attach/basic/sharedProducts.ts | 20 +++ .../attach/migrations/migration5.test.ts | 146 ++++++++++++++++++ server/tests/billing/cancel/cancel6.test.ts | 123 +++++++++++++++ server/tests/billing/cancel/cancel7.test.ts | 89 +++++++++++ server/tests/billing/cancel/cancel8.test.ts | 131 ++++++++++++++++ server/tests/billing/cancel/cancel9.test.ts | 107 +++++++++++++ .../merged/mergeUtils/expectSubCorrect.ts | 29 ++++ .../expectUtils/expectProductAttached.ts | 21 +++ 13 files changed, 732 insertions(+), 36 deletions(-) create mode 100644 server/tests/attach/migrations/migration5.test.ts create mode 100644 server/tests/billing/cancel/cancel6.test.ts create mode 100644 server/tests/billing/cancel/cancel7.test.ts create mode 100644 server/tests/billing/cancel/cancel8.test.ts create mode 100644 server/tests/billing/cancel/cancel9.test.ts diff --git a/scripts/testGroups/g3.sh b/scripts/testGroups/g3.sh index 4db3cc6e3..35540289b 100755 --- a/scripts/testGroups/g3.sh +++ b/scripts/testGroups/g3.sh @@ -24,5 +24,6 @@ BUN_PARALLEL_COMPACT \ 'server/tests/attach/response' \ 'server/tests/interval/upgrade' \ 'server/tests/interval/multiSub' \ + 'server/tests/billing/cancel' \ --max=6 diff --git a/server/src/internal/customers/attach/attachFunctions/scheduleFlow/handleScheduleFlow2.ts b/server/src/internal/customers/attach/attachFunctions/scheduleFlow/handleScheduleFlow2.ts index db02865d9..4dd5989fa 100644 --- a/server/src/internal/customers/attach/attachFunctions/scheduleFlow/handleScheduleFlow2.ts +++ b/server/src/internal/customers/attach/attachFunctions/scheduleFlow/handleScheduleFlow2.ts @@ -16,6 +16,7 @@ import { isFreeProduct, } from "@/internal/products/productUtils.js"; import type { AutumnContext } from "../../../../../honoUtils/HonoEnv.js"; +import { addSubIdToCache } from "../../../cusCache/subCacheUtils.js"; import { attachParamsToCurCusProduct, getCustomerSchedule, @@ -139,6 +140,13 @@ export const handleScheduleFunction2 = async ({ } } else { logger.info(`SCHEDULE FLOW: no schedule, creating new schedule`); + + // Add sub ID to upstash so renew isn't being handled... + await addSubIdToCache({ + subId: curSub.id, + scenario: AttachScenario.Renew, + }); + schedule = await subToNewSchedule({ ctx, sub: curSub, diff --git a/server/src/internal/customers/cancel/handleCancelProduct.ts b/server/src/internal/customers/cancel/handleCancelProduct.ts index 3e62388c1..caa4e3374 100644 --- a/server/src/internal/customers/cancel/handleCancelProduct.ts +++ b/server/src/internal/customers/cancel/handleCancelProduct.ts @@ -1,14 +1,12 @@ import { AttachBranch, CusProductStatus, - cusProductToPrices, cusProductToProduct, type EntitlementWithFeature, type FullCusProduct, type FullCustomer, type Price, ProrationBehavior, - RecaseError, } from "@autumn/shared"; import { createStripeCli } from "@/external/connect/createStripeCli.js"; import { isFreeProduct, isOneOff } from "@/internal/products/productUtils.js"; @@ -17,6 +15,7 @@ import { handleRenewProduct } from "../attach/attachFunctions/handleRenewProduct import { handleScheduleFunction2 } from "../attach/attachFunctions/scheduleFlow/handleScheduleFlow2.js"; import { handleUpgradeFlow } from "../attach/attachFunctions/upgradeFlow/handleUpgradeFlow.js"; import { getDefaultAttachConfig } from "../attach/attachUtils/getAttachConfig.js"; +import { CusProductService } from "../cusProducts/CusProductService.js"; import { getExistingCusProducts } from "../cusProducts/cusProductUtils/getExistingCusProducts.js"; import { activateDefaultProduct, @@ -96,20 +95,39 @@ export const handleCancelProduct = async ({ const isFree = isFreeProduct(product.prices || []); if (isMain) { - if (cusProduct.canceled && !expireImmediately) { - throw new RecaseError({ - message: `Product ${cusProduct.product.name} is already about to cancel at the end of cycle.`, + // Delete scheduled product + const { curScheduledProduct } = getExistingCusProducts({ + product: product, + cusProducts: fullCus.customer_products, + internalEntityId: cusProduct.internal_entity_id, + }); + + console.log( + `Current scheduled product: ${curScheduledProduct?.product.name}`, + ); + + // Delete scheduled product. + if (curScheduledProduct) { + await CusProductService.delete({ + db: ctx.db, + cusProductId: curScheduledProduct?.id, }); } - if ( - curScheduledProduct && - !isFreeProduct(cusProductToPrices({ cusProduct: curScheduledProduct })) - ) { - throw new RecaseError({ - message: `Please delete scheduled product ${curScheduledProduct.product.name} first`, - }); - } + // if (cusProduct.canceled && !expireImmediately) { + // throw new RecaseError({ + // message: `Product ${cusProduct.product.name} is already about to cancel at the end of cycle.`, + // }); + // } + + // if ( + // curScheduledProduct && + // !isFreeProduct(cusProductToPrices({ cusProduct: curScheduledProduct })) + // ) { + // throw new RecaseError({ + // message: `Please delete scheduled product ${curScheduledProduct.product.name} first`, + // }); + // } } // 2. If expire at cycle end, just cancel subscriptions diff --git a/server/src/internal/customers/cusProducts/AttachParams.ts b/server/src/internal/customers/cusProducts/AttachParams.ts index 35d71ed86..5d05696fe 100644 --- a/server/src/internal/customers/cusProducts/AttachParams.ts +++ b/server/src/internal/customers/cusProducts/AttachParams.ts @@ -114,6 +114,7 @@ export type InsertCusProductParams = { isCustom?: boolean; disableFreeTrial?: boolean; features: Feature[]; + fromCancel?: boolean; entityId?: string; internalEntityId?: string; diff --git a/server/tests/_temp/temp1.test.ts b/server/tests/_temp/temp1.test.ts index f5d286f57..549e0fbed 100644 --- a/server/tests/_temp/temp1.test.ts +++ b/server/tests/_temp/temp1.test.ts @@ -5,34 +5,43 @@ import ctx from "@tests/utils/testInitUtils/createTestContext.js"; import chalk from "chalk"; import { AutumnInt } from "@/external/autumn/autumnCli.js"; import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js"; -import { - constructFeatureItem, - constructPrepaidItem, -} from "../../src/utils/scriptUtils/constructItem.js"; +import { constructFeatureItem } from "../../src/utils/scriptUtils/constructItem.js"; import { initCustomerV3 } from "../../src/utils/scriptUtils/testUtils/initCustomerV3.js"; import { initProductsV0 } from "../../src/utils/scriptUtils/testUtils/initProductsV0.js"; // UNCOMMENT FROM HERE +const premium = constructProduct({ + type: "premium", + isDefault: false, + + items: [ + constructFeatureItem({ + featureId: TestFeature.Messages, + includedUsage: 100, + }), + ], +}); + const pro = constructProduct({ type: "pro", isDefault: false, items: [ constructFeatureItem({ - featureId: TestFeature.Credits, - includedUsage: 250, + featureId: TestFeature.Messages, + includedUsage: 50, }), ], }); -const proEntity = constructProduct({ - type: "pro", - id: "pro-entity", - isDefault: false, + +const free = constructProduct({ + type: "free", + isDefault: true, items: [ - constructPrepaidItem({ - featureId: TestFeature.Credits, - includedUsage: 0, + constructFeatureItem({ + featureId: TestFeature.Messages, + includedUsage: 10, }), ], }); @@ -52,7 +61,7 @@ describe(`${chalk.yellowBright("temp: Testing entity prorated")}`, () => { await initProductsV0({ ctx, - products: [pro, proEntity], + products: [premium, pro, free], prefix: customerId, }); @@ -68,18 +77,11 @@ describe(`${chalk.yellowBright("temp: Testing entity prorated")}`, () => { test("should create a subscription with prepaid and prorated", async () => { await autumn.attach({ customer_id: customerId, - product_id: pro.id, + product_id: premium.id, }); await autumn.attach({ customer_id: customerId, - product_id: proEntity.id, - entity_id: "1", - options: [ - { - feature_id: TestFeature.Credits, - quantity: 300, - }, - ], + product_id: pro.id, }); }); }); diff --git a/server/tests/attach/basic/sharedProducts.ts b/server/tests/attach/basic/sharedProducts.ts index ad21ce708..154f053da 100644 --- a/server/tests/attach/basic/sharedProducts.ts +++ b/server/tests/attach/basic/sharedProducts.ts @@ -42,6 +42,26 @@ export const sharedProProduct = constructProduct({ }), ], }); +export const sharedPremiumProduct = constructProduct({ + id: "shared-premium-product", + isDefault: false, + type: "premium", + items: [ + constructFeatureItem({ + featureId: TestFeature.Dashboard, + isBoolean: true, + }), + constructFeatureItem({ + featureId: TestFeature.Messages, + includedUsage: 100, + interval: ProductItemInterval.Month, + }), + constructFeatureItem({ + featureId: TestFeature.Admin, + unlimited: true, + }), + ], +}); export const initBasicSharedProducts = async () => { await createSharedProducts({ diff --git a/server/tests/attach/migrations/migration5.test.ts b/server/tests/attach/migrations/migration5.test.ts new file mode 100644 index 000000000..308dec7e6 --- /dev/null +++ b/server/tests/attach/migrations/migration5.test.ts @@ -0,0 +1,146 @@ +import { beforeAll, describe, expect, test } from "bun:test"; +import { OnDecrease, OnIncrease } from "@autumn/shared"; +import { defaultApiVersion } from "@tests/constants.js"; +import { TestFeature } from "@tests/setup/v2Features.js"; +import ctx from "@tests/utils/testInitUtils/createTestContext.js"; +import chalk from "chalk"; +import { AutumnInt } from "@/external/autumn/autumnCli.js"; +import { + constructArrearProratedItem, + constructFeatureItem, + constructPrepaidItem, +} from "@/utils/scriptUtils/constructItem.js"; +import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js"; +import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js"; +import { initProductsV0 } from "@/utils/scriptUtils/testUtils/initProductsV0.js"; +import { expectSubToBeCorrect } from "../../merged/mergeUtils/expectSubCorrect.js"; +import { timeout } from "../../utils/genUtils.js"; +import { replaceItems } from "../../utils/testProductUtils/testProductUtils.js"; + +const prepaidSeats = constructPrepaidItem({ + featureId: TestFeature.Users, + billingUnits: 1, + price: 40, + includedUsage: 0, + config: { + on_increase: OnIncrease.ProrateNextCycle, + on_decrease: OnDecrease.ProrateNextCycle, + }, +}); + +const msgItem = constructFeatureItem({ + featureId: TestFeature.Messages, + includedUsage: 100, + entityFeatureId: TestFeature.Users, +}); + +const pro = constructProduct({ + id: "pro", + items: [prepaidSeats, msgItem], + type: "pro", + isDefault: false, +}); + +const testCase = "migrations5"; +const entities = [ + { + id: "1", + name: "Entity 1", + feature_id: TestFeature.Users, + }, + { + id: "2", + name: "Entity 2", + feature_id: TestFeature.Users, + }, + { + id: "3", + name: "Entity 3", + feature_id: TestFeature.Users, + }, + { + id: "4", + name: "Entity 4", + feature_id: TestFeature.Users, + }, +]; + +describe(`${chalk.yellowBright(`${testCase}: Testing migration for prepaid seats -> pay_per_use seats`)}`, () => { + const customerId = testCase; + const autumn: AutumnInt = new AutumnInt({ version: defaultApiVersion }); + + beforeAll(async () => { + await initProductsV0({ + ctx, + products: [pro], + prefix: testCase, + customerId, + }); + + await initCustomerV3({ + ctx, + customerId, + customerData: {}, + attachPm: "success", + withTestClock: true, + }); + }); + + test("should attach pro product", async () => { + await autumn.attach({ + customer_id: customerId, + product_id: pro.id, + options: [ + { + feature_id: TestFeature.Users, + quantity: 3, + }, + ], + }); + + await autumn.entities.create(customerId, entities); + }); + + test("should update product to new version", async () => { + await autumn.products.update(pro.id, { + items: replaceItems({ + items: pro.items, + featureId: TestFeature.Users, + newItem: constructArrearProratedItem({ + featureId: TestFeature.Users, + pricePerUnit: 40, + includedUsage: 1, + }), + }), + }); + + await autumn.migrate({ + from_product_id: pro.id, + to_product_id: pro.id, + from_version: 1, + to_version: 2, + }); + + await timeout(3000); + // await autumn.attach({ + // customer_id: customerId, + // product_id: pro.id, + // version: 2, + // }); + + // 1. Should have 3 seats + const customer = await autumn.customers.get(customerId); + const seats = customer.features[TestFeature.Users].balance; + expect(seats).toBe(-3); // 3 in overage + + const messages = customer.features[TestFeature.Messages].balance; + expect(messages).toBe(400); + + await expectSubToBeCorrect({ + db: ctx.db, + customerId, + org: ctx.org, + env: ctx.env, + }); + }); +}); diff --git a/server/tests/billing/cancel/cancel6.test.ts b/server/tests/billing/cancel/cancel6.test.ts new file mode 100644 index 000000000..b3868b848 --- /dev/null +++ b/server/tests/billing/cancel/cancel6.test.ts @@ -0,0 +1,123 @@ +import { beforeAll, describe, expect, test } from "bun:test"; +import { ApiVersion, CusProductStatus } from "@autumn/shared"; +import { TestFeature } from "@tests/setup/v2Features.js"; +import ctx from "@tests/utils/testInitUtils/createTestContext.js"; +import { ProductStatus } from "autumn-js"; +import chalk from "chalk"; +import { AutumnInt } from "@/external/autumn/autumnCli.js"; +import { constructFeatureItem } from "@/utils/scriptUtils/constructItem.js"; +import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js"; +import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js"; +import { initProductsV0 } from "@/utils/scriptUtils/testUtils/initProductsV0.js"; +import { expectSubToBeCorrect } from "../../merged/mergeUtils/expectSubCorrect"; +import { expectProductAttached } from "../../utils/expectUtils/expectProductAttached"; + +// UNCOMMENT FROM HERE +const premium = constructProduct({ + type: "premium", + isDefault: false, + + items: [ + constructFeatureItem({ + featureId: TestFeature.Messages, + includedUsage: 100, + }), + ], +}); + +const pro = constructProduct({ + type: "pro", + isDefault: false, + + items: [ + constructFeatureItem({ + featureId: TestFeature.Messages, + includedUsage: 50, + }), + ], +}); + +describe(`${chalk.yellowBright("cancel6: Downgrade from premium to pro, then cancel premium end of cycle")}`, () => { + const customerId = "cancel6"; + const autumn: AutumnInt = new AutumnInt({ version: ApiVersion.V1_2 }); + + beforeAll(async () => { + await initCustomerV3({ + ctx, + customerId, + customerData: {}, + attachPm: "success", + withTestClock: true, + }); + + await initProductsV0({ + ctx, + products: [premium, pro], + prefix: customerId, + }); + }); + + test("should attach premium and pro, then cancel premium end of cycle", async () => { + await autumn.attach({ + customer_id: customerId, + product_id: premium.id, + }); + await autumn.attach({ + customer_id: customerId, + product_id: pro.id, + }); + + await autumn.cancel({ + customer_id: customerId, + product_id: premium.id, + cancel_immediately: false, + }); + }); + + test("should have correct product and subscriptions after cancellation", async () => { + const customer = await autumn.customers.get(customerId); + + const premiumProduct = customer.products.find((p) => p.id === premium.id); + const proProduct = customer.products.find((p) => p.id === pro.id); + + expect(premiumProduct).toBeDefined(); + expect(premiumProduct?.canceled_at).toBeDefined(); + expect(premiumProduct?.status).toBe(ProductStatus.Active); + expect(proProduct).toBeUndefined(); + + await expectSubToBeCorrect({ + db: ctx.db, + customerId, + org: ctx.org, + env: ctx.env, + shouldBeCanceled: true, + }); + }); + + test("should attach pro again and have correct product and subscriptions", async () => { + await autumn.attach({ + customer_id: customerId, + product_id: pro.id, + }); + + const customer = await autumn.customers.get(customerId); + expectProductAttached({ + customer: customer, + product: premium, + status: CusProductStatus.Active, + }); + expectProductAttached({ + customer: customer, + product: pro, + status: CusProductStatus.Scheduled, + }); + + await expectSubToBeCorrect({ + db: ctx.db, + customerId, + org: ctx.org, + env: ctx.env, + shouldBeCanceled: false, + }); + }); +}); diff --git a/server/tests/billing/cancel/cancel7.test.ts b/server/tests/billing/cancel/cancel7.test.ts new file mode 100644 index 000000000..7ffc379c6 --- /dev/null +++ b/server/tests/billing/cancel/cancel7.test.ts @@ -0,0 +1,89 @@ +import { beforeAll, describe, expect, test } from "bun:test"; +import { ApiVersion } from "@autumn/shared"; +import { TestFeature } from "@tests/setup/v2Features.js"; +import ctx from "@tests/utils/testInitUtils/createTestContext.js"; +import chalk from "chalk"; +import { AutumnInt } from "@/external/autumn/autumnCli.js"; +import { constructFeatureItem } from "@/utils/scriptUtils/constructItem.js"; +import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js"; +import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js"; +import { initProductsV0 } from "@/utils/scriptUtils/testUtils/initProductsV0.js"; + +// UNCOMMENT FROM HERE +const premium = constructProduct({ + type: "premium", + isDefault: false, + + items: [ + constructFeatureItem({ + featureId: TestFeature.Words, + includedUsage: 100, + }), + ], +}); + +const pro = constructProduct({ + type: "pro", + isDefault: false, + + items: [ + constructFeatureItem({ + featureId: TestFeature.Words, + includedUsage: 50, + }), + ], +}); + +describe(`${chalk.yellowBright("cancel7: Downgrade from premium to pro, then cancel premium immediately")}`, () => { + const customerId = "cancel7"; + const autumn: AutumnInt = new AutumnInt({ version: ApiVersion.V1_2 }); + + beforeAll(async () => { + await initCustomerV3({ + ctx, + customerId, + customerData: {}, + attachPm: "success", + withTestClock: true, + }); + + await initProductsV0({ + ctx, + products: [premium, pro], + prefix: customerId, + }); + }); + + test("should attach premium and pro, then cancel premium immediately", async () => { + await autumn.attach({ + customer_id: customerId, + product_id: premium.id, + }); + await autumn.attach({ + customer_id: customerId, + product_id: pro.id, + }); + + await autumn.cancel({ + customer_id: customerId, + product_id: premium.id, + cancel_immediately: true, + }); + }); + + test("should have correct product and subscriptions after cancellation", async () => { + const customer = await autumn.customers.get(customerId); + + const premiumProduct = customer.products.find((p) => p.id === premium.id); + const proProduct = customer.products.find((p) => p.id === pro.id); + + expect(premiumProduct).toBeUndefined(); + expect(proProduct).toBeUndefined(); + + const stripeSubs = await ctx.stripeCli.subscriptions.list({ + customer: customer.stripe_id!, + }); + + expect(stripeSubs.data).toHaveLength(0); + }); +}); diff --git a/server/tests/billing/cancel/cancel8.test.ts b/server/tests/billing/cancel/cancel8.test.ts new file mode 100644 index 000000000..7cdf323b8 --- /dev/null +++ b/server/tests/billing/cancel/cancel8.test.ts @@ -0,0 +1,131 @@ +import { beforeAll, describe, expect, test } from "bun:test"; +import { ApiVersion, CusProductStatus } from "@autumn/shared"; +import { TestFeature } from "@tests/setup/v2Features.js"; +import ctx from "@tests/utils/testInitUtils/createTestContext.js"; +import chalk from "chalk"; +import { AutumnInt } from "@/external/autumn/autumnCli.js"; +import { constructFeatureItem } from "@/utils/scriptUtils/constructItem.js"; +import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js"; +import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js"; +import { initProductsV0 } from "@/utils/scriptUtils/testUtils/initProductsV0.js"; +import { expectSubToBeCorrect } from "../../merged/mergeUtils/expectSubCorrect"; +import { expectProductAttached } from "../../utils/expectUtils/expectProductAttached"; + +// UNCOMMENT FROM HERE +const premium = constructProduct({ + type: "premium", + isDefault: false, + + items: [ + constructFeatureItem({ + featureId: TestFeature.Messages, + includedUsage: 100, + }), + ], +}); + +const pro = constructProduct({ + type: "pro", + isDefault: false, + + items: [ + constructFeatureItem({ + featureId: TestFeature.Messages, + includedUsage: 50, + }), + ], +}); + +const free = constructProduct({ + type: "free", + isDefault: true, + + items: [], +}); + +describe(`${chalk.yellowBright("cancel8: Downgrade from premium to pro, then cancel premium immediately (with default product)")}`, () => { + const customerId = "cancel8"; + const autumn: AutumnInt = new AutumnInt({ version: ApiVersion.V1_2 }); + + beforeAll(async () => { + await initCustomerV3({ + ctx, + customerId, + customerData: {}, + attachPm: "success", + withTestClock: true, + }); + + await initProductsV0({ + ctx, + products: [premium, pro, free], + prefix: customerId, + }); + }); + + test("should attach premium and pro, then cancel premium immediately", async () => { + await autumn.attach({ + customer_id: customerId, + product_id: premium.id, + }); + await autumn.attach({ + customer_id: customerId, + product_id: pro.id, + }); + + await autumn.cancel({ + customer_id: customerId, + product_id: premium.id, + cancel_immediately: false, + }); + }); + + test("should have correct product and subscriptions after cancellation", async () => { + const customer = await autumn.customers.get(customerId); + + const proProduct = customer.products.find((p) => p.id === pro.id); + + expect(proProduct).toBeUndefined(); + + expectProductAttached({ + customer: customer, + product: premium, + status: CusProductStatus.Active, + }); + expectProductAttached({ + customer: customer, + product: free, + status: CusProductStatus.Scheduled, + }); + + await expectSubToBeCorrect({ + db: ctx.db, + customerId, + org: ctx.org, + env: ctx.env, + shouldBeCanceled: true, + }); + }); + + test("should attach pro again and have correct product and subscriptions", async () => { + await autumn.attach({ + customer_id: customerId, + product_id: pro.id, + }); + + const customer = await autumn.customers.get(customerId); + expectProductAttached({ + customer: customer, + product: pro, + status: CusProductStatus.Scheduled, + }); + + await expectSubToBeCorrect({ + db: ctx.db, + customerId, + org: ctx.org, + env: ctx.env, + shouldBeCanceled: false, + }); + }); +}); diff --git a/server/tests/billing/cancel/cancel9.test.ts b/server/tests/billing/cancel/cancel9.test.ts new file mode 100644 index 000000000..4a12fe07a --- /dev/null +++ b/server/tests/billing/cancel/cancel9.test.ts @@ -0,0 +1,107 @@ +import { beforeAll, describe, test } from "bun:test"; +import { ApiVersion, CusProductStatus } from "@autumn/shared"; +import { TestFeature } from "@tests/setup/v2Features.js"; +import ctx from "@tests/utils/testInitUtils/createTestContext.js"; +import chalk from "chalk"; +import { AutumnInt } from "@/external/autumn/autumnCli.js"; +import { constructFeatureItem } from "@/utils/scriptUtils/constructItem.js"; +import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js"; +import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js"; +import { initProductsV0 } from "@/utils/scriptUtils/testUtils/initProductsV0.js"; +import { expectSubCount } from "../../merged/mergeUtils/expectSubCorrect"; +import { + expectProductAttached, + expectProductGroupCount, +} from "../../utils/expectUtils/expectProductAttached"; + +// UNCOMMENT FROM HERE +const premium = constructProduct({ + type: "premium", + isDefault: false, + + items: [ + constructFeatureItem({ + featureId: TestFeature.Messages, + includedUsage: 100, + }), + ], +}); + +const pro = constructProduct({ + type: "pro", + isDefault: false, + + items: [ + constructFeatureItem({ + featureId: TestFeature.Messages, + includedUsage: 50, + }), + ], +}); + +const free = constructProduct({ + type: "free", + isDefault: true, + + items: [], +}); + +describe(`${chalk.yellowBright("cancel9: Downgrade from premium to pro, then cancel premium immediately (with default product)")}`, () => { + const customerId = "cancel9"; + const autumn: AutumnInt = new AutumnInt({ version: ApiVersion.V1_2 }); + + beforeAll(async () => { + await initCustomerV3({ + ctx, + customerId, + customerData: {}, + attachPm: "success", + withTestClock: true, + }); + + await initProductsV0({ + ctx, + products: [premium, pro, free], + prefix: customerId, + }); + }); + + test("should attach premium and pro, then cancel premium immediately (with default product)", async () => { + await autumn.attach({ + customer_id: customerId, + product_id: premium.id, + }); + await autumn.attach({ + customer_id: customerId, + product_id: pro.id, + }); + + await autumn.cancel({ + customer_id: customerId, + product_id: premium.id, + cancel_immediately: true, + }); + }); + + test("should have correct product and subscriptions after cancellation", async () => { + const customer = await autumn.customers.get(customerId); + + expectProductAttached({ + customer: customer, + product: free, + status: CusProductStatus.Active, + }); + + expectProductGroupCount({ + customer: customer, + group: premium.group!, + count: 1, + }); + + await expectSubCount({ + ctx, + customerId, + count: 0, + }); + }); +}); diff --git a/server/tests/merged/mergeUtils/expectSubCorrect.ts b/server/tests/merged/mergeUtils/expectSubCorrect.ts index 146ec9095..d49c81ade 100644 --- a/server/tests/merged/mergeUtils/expectSubCorrect.ts +++ b/server/tests/merged/mergeUtils/expectSubCorrect.ts @@ -33,6 +33,7 @@ import { } from "@/internal/products/prices/priceUtils.js"; import { isFreeProduct } from "@/internal/products/productUtils.js"; import { formatUnixToDateTime, nullish } from "@/utils/genUtils.js"; +import type { TestContext } from "../../utils/testInitUtils/createTestContext.js"; import { cusProductToSubIds } from "../mergeUtils.test.js"; const compareActualItems = async ({ @@ -492,3 +493,31 @@ export const expectSubToBeCorrect = async ({ // } else { // } }; + +export const expectSubCount = async ({ + ctx, + customerId, + count, +}: { + ctx: TestContext; + customerId: string; + count: number; +}) => { + const stripeCli = ctx.stripeCli; + const customer = await CusService.get({ + db: ctx.db, + idOrInternalId: customerId, + orgId: ctx.org.id, + env: ctx.env, + }); + + if (!customer?.processor?.id) { + throw new Error(`Customer ${customerId} has no processor`); + } + + const subs = await stripeCli.subscriptions.list({ + customer: customer?.processor?.id, + }); + + expect(subs.data.length).toBe(count); +}; diff --git a/server/tests/utils/expectUtils/expectProductAttached.ts b/server/tests/utils/expectUtils/expectProductAttached.ts index 707d2d4a2..5231b4d71 100644 --- a/server/tests/utils/expectUtils/expectProductAttached.ts +++ b/server/tests/utils/expectUtils/expectProductAttached.ts @@ -67,6 +67,27 @@ export const expectProductAttached = ({ } }; +export const expectProductGroupCount = ({ + customer, + group, + count, +}: { + customer: Customer; + group: string; + count: number; +}) => { + const productCount = customer.products.reduce((acc: number, product: any) => { + if (product.group === group) { + return acc + 1; + } else return acc; + }, 0); + + expect( + productCount, + `customer should have ${count} products in group ${group}`, + ).to.equal(count); +}; + export const expectScheduledApiSub = async ({ customerId, entityId,