From b6a37c67628e08315954574a138597e6ceaa119e Mon Sep 17 00:00:00 2001 From: John Yeo Date: Sun, 22 Feb 2026 22:48:16 +0000 Subject: [PATCH] chore: migrated legacy tests to legacy/attach folder --- server/src/external/autumn/autumnCli.ts | 14 +- server/tests/_groups/core.ts | 39 ++ server/tests/_groups/domains/advanced.ts | 9 + server/tests/_groups/domains/balances.ts | 8 + server/tests/_groups/domains/billing.ts | 9 + server/tests/_groups/domains/crud.ts | 8 + server/tests/_groups/domains/misc.ts | 9 + server/tests/_groups/domains/webhooks.ts | 9 + server/tests/_groups/index.ts | 96 ++++ server/tests/_groups/suites.ts | 14 + server/tests/_groups/types.ts | 18 + .../customInterval}/reset1.test.ts | 6 - .../interval/intervalUtils1.test.ts | 0 .../migrations/migration2.test.ts | 0 .../migrations/migration4.test.ts | 0 .../migrations/migration5.test.ts | 0 .../migrations/migration6.test.ts | 0 .../migrations/migration7.test.ts | 0 .../migrations/migration8.test.ts | 0 .../migrations/runMigrationTest.ts | 0 .../multiAttach/multiAttach1.test.ts | 0 .../multiAttach/multiAttach2.test.ts | 0 .../multiAttach/multiAttach3.test.ts | 0 .../multiAttach/multiAttach4.test.ts | 0 .../multiAttach/multiAttach5.test.ts | 0 .../multiAttach/multiAttach6.test.ts | 0 .../multiInvoice/multiInvoice1.test.ts | 0 .../multiReward/multiReward1.test.ts | 0 .../multiReward/multiReward2.test.ts | 0 .../multiReward/multiReward3.test.ts | 0 .../multiReward/multiRewardUtils.test.ts | 0 .../multiUpgrade/multiUpgrade1.test.ts | 0 .../multiUpgrade/multiUpgrade2.test.ts | 0 .../newVersion/newVersion1.test.ts | 0 .../newVersion/newVersion2.test.ts | 0 .../newVersion/newVersion3.test.ts | 0 .../newVersion/newVersion4.test.ts | 0 server/tests/attach/addOn/addOn1.test.ts | 137 ----- server/tests/attach/addOn/addOn2.test.ts | 92 --- server/tests/attach/basic/basic2.test.ts | 150 ----- server/tests/attach/basic/basic6.test.ts | 101 ---- server/tests/attach/basic/basic8.test.ts | 123 ---- server/tests/attach/basic/sharedProducts.ts | 74 --- .../tests/attach/checkout/checkout1.test.ts | 128 ----- .../tests/attach/checkout/checkout2.test.ts | 159 ----- .../tests/attach/checkout/checkout3.test.ts | 86 --- .../tests/attach/checkout/checkout4.test.ts | 86 --- .../tests/attach/checkout/checkout5.test.ts | 84 --- .../tests/attach/checkout/checkout6.test.ts | 120 ---- .../tests/attach/checkout/checkout7.test.ts | 113 ---- .../tests/attach/checkout/checkout8.test.ts | 95 --- .../tests/attach/downgrade/downgrade1.test.ts | 104 ---- .../tests/attach/downgrade/downgrade2.test.ts | 112 ---- .../tests/attach/downgrade/downgrade3.test.ts | 150 ----- .../tests/attach/downgrade/downgrade4.test.ts | 135 ----- .../tests/attach/downgrade/downgrade5.test.ts | 153 ----- .../tests/attach/downgrade/sharedProducts.ts | 78 --- server/tests/attach/entities/entity1.test.ts | 76 --- server/tests/attach/entities/entity2.test.ts | 138 ----- server/tests/attach/entities/entity3.test.ts | 120 ---- server/tests/attach/entities/entity4.test.ts | 190 ------ server/tests/attach/entities/entity5.test.ts | 161 ------ server/tests/attach/entities/entity6.test.ts | 90 --- server/tests/attach/free/free1.test.ts | 157 ----- .../attach/multiProduct/multiProduct1.test.ts | 133 ----- server/tests/attach/others/others1.test.ts | 108 ---- server/tests/attach/others/others10.test.ts | 94 --- server/tests/attach/others/others3.test.ts | 69 --- server/tests/attach/others/others6.test.ts | 122 ---- server/tests/attach/others/others7.test.ts | 61 -- server/tests/attach/others/others8.test.ts | 86 --- server/tests/attach/others/others9.test.ts | 74 --- .../attach/response/attach-response1.test.ts | 62 -- .../attach/response/attach-response2.test.ts | 98 ---- .../attach/response/attach-response3.test.ts | 90 --- .../attach/response/attach-response4.test.ts | 75 --- .../attach/response/attach-response5.test.ts | 77 --- .../attach/updateEnts/expectUpdateEnts.ts | 130 ----- .../attach/updateEnts/updateEnts1.test.ts | 155 ----- .../attach/updateEnts/updateEnts2.test.ts | 170 ------ .../attach/updateEnts/updateEnts3.test.ts | 186 ------ .../attach/updateEnts/updateEnts4.test.ts | 85 --- server/tests/attach/upgrade/upgrade1.test.ts | 134 ----- server/tests/attach/upgrade/upgrade2.test.ts | 140 ----- server/tests/attach/upgrade/upgrade3.test.ts | 187 ------ server/tests/attach/upgrade/upgrade4.test.ts | 168 ------ server/tests/attach/upgrade/upgrade5.test.ts | 122 ---- server/tests/attach/upgrade/upgrade7.test.ts | 80 --- .../tests/attach/upgradeOld/sharedProducts.ts | 128 ----- .../attach/upgradeOld/upgradeOld2.test.ts | 101 ---- .../attach/upgradeOld/upgradeOld3.test.ts | 139 ----- .../attach/upgradeOld/upgradeOld4.test.ts | 163 ------ server/tests/attach/utils.ts | 185 ------ .../revenuecat/revenuecat-migration.test.ts | 211 ------- .../revenuecat/revenuecat-webhooks.test.ts | 395 ------------- .../legacy-addon-merged.test.ts | 0 .../legacy/attach/addon/legacy-addon.test.ts | 244 ++++++++ .../legacy/attach/attach-edge-cases.test.ts | 164 +++++- .../billing/legacy/attach/attach-misc.test.ts | 74 ++- .../legacy/attach/attach-response.test.ts | 313 ++++++++++ .../checkout/legacy-checkout-advanced.test.ts | 424 ++++++++++++++ .../checkout/legacy-checkout-basic.test.ts | 276 +++++++++ .../attach/downgrade/legacy-downgrade.test.ts | 467 +++++++++++++++ .../entities/legacy-entities-basic.test.ts | 467 +++++++++++++++ ...egacy-attach-invoice-mode-advanced.test.ts | 366 ++++++++++++ .../attach/new/legacy-merge-interval.test.ts | 250 ++++++++ .../attach/new/legacy-new-merged.test.ts | 91 +++ .../legacy/attach/trial/legacy-trial.test.ts | 427 +++++++++++++- .../upgrade/legacy-upgrade-prepaid.test.ts | 541 ++++++++++++++++++ .../upgrade/legacy-upgrade-usage.test.ts | 523 +++++++++++++++++ .../attach/upgrade/legacy-upgrade.test.ts | 319 +++++++++-- .../attach/upgrade/upgrade-prepaid.test.ts | 273 --------- .../integration/billing/legacy/toDelete.md | 47 -- .../subscription-updated-past-due.test.ts | 80 +++ .../utils/expectCustomerProductCorrect.ts | 29 +- .../cross-version-list-customers.test.ts | 3 +- .../crud/customers/list-customers.test.ts | 462 --------------- .../features}/feature-rpc-crud.test.ts | 0 .../external-psps/revenuecat.test.ts | 393 +++++++++++++ .../utils/revenue-cat-webhook-client.ts | 0 .../utils}/revenuecatWebhooks.test.ts | 2 +- .../multiSub/multiSubInterval1.test.ts | 119 ---- .../multiSub/multiSubInterval2.test.ts | 133 ----- .../multiSub/multiSubInterval3.test.ts | 132 ----- .../tests/interval/upgrade/interval1.test.ts | 102 ---- .../tests/interval/upgrade/interval2.test.ts | 103 ---- .../tests/interval/upgrade/interval3.test.ts | 105 ---- .../tests/merged/separate/separate1.test.ts | 161 ------ .../tests/merged/separate/separate2.test.ts | 217 ------- server/tests/utils/fixtures/products.ts | 5 +- .../tests/utils/testInitUtils/initScenario.ts | 50 +- .../mappers/planParamsV1ToProductItems.ts | 4 - 132 files changed, 5677 insertions(+), 9168 deletions(-) create mode 100644 server/tests/_groups/core.ts create mode 100644 server/tests/_groups/domains/advanced.ts create mode 100644 server/tests/_groups/domains/balances.ts create mode 100644 server/tests/_groups/domains/billing.ts create mode 100644 server/tests/_groups/domains/crud.ts create mode 100644 server/tests/_groups/domains/misc.ts create mode 100644 server/tests/_groups/domains/webhooks.ts create mode 100644 server/tests/_groups/index.ts create mode 100644 server/tests/_groups/suites.ts create mode 100644 server/tests/_groups/types.ts rename server/tests/{core => advanced/customInterval}/reset1.test.ts (96%) rename server/tests/{ => archives}/interval/intervalUtils1.test.ts (100%) rename server/tests/{attach => archives}/migrations/migration2.test.ts (100%) rename server/tests/{attach => archives}/migrations/migration4.test.ts (100%) rename server/tests/{attach => archives}/migrations/migration5.test.ts (100%) rename server/tests/{attach => archives}/migrations/migration6.test.ts (100%) rename server/tests/{attach => archives}/migrations/migration7.test.ts (100%) rename server/tests/{attach => archives}/migrations/migration8.test.ts (100%) rename server/tests/{attach => archives}/migrations/runMigrationTest.ts (100%) rename server/tests/{core => archives}/multiAttach/multiAttach1.test.ts (100%) rename server/tests/{core => archives}/multiAttach/multiAttach2.test.ts (100%) rename server/tests/{core => archives}/multiAttach/multiAttach3.test.ts (100%) rename server/tests/{core => archives}/multiAttach/multiAttach4.test.ts (100%) rename server/tests/{core => archives}/multiAttach/multiAttach5.test.ts (100%) rename server/tests/{core => archives}/multiAttach/multiAttach6.test.ts (100%) rename server/tests/{core => archives}/multiAttach/multiInvoice/multiInvoice1.test.ts (100%) rename server/tests/{core => archives}/multiAttach/multiReward/multiReward1.test.ts (100%) rename server/tests/{core => archives}/multiAttach/multiReward/multiReward2.test.ts (100%) rename server/tests/{core => archives}/multiAttach/multiReward/multiReward3.test.ts (100%) rename server/tests/{core => archives}/multiAttach/multiReward/multiRewardUtils.test.ts (100%) rename server/tests/{core => archives}/multiAttach/multiUpgrade/multiUpgrade1.test.ts (100%) rename server/tests/{core => archives}/multiAttach/multiUpgrade/multiUpgrade2.test.ts (100%) rename server/tests/{attach => archives}/newVersion/newVersion1.test.ts (100%) rename server/tests/{attach => archives}/newVersion/newVersion2.test.ts (100%) rename server/tests/{attach => archives}/newVersion/newVersion3.test.ts (100%) rename server/tests/{attach => archives}/newVersion/newVersion4.test.ts (100%) delete mode 100644 server/tests/attach/addOn/addOn1.test.ts delete mode 100644 server/tests/attach/addOn/addOn2.test.ts delete mode 100644 server/tests/attach/basic/basic2.test.ts delete mode 100644 server/tests/attach/basic/basic6.test.ts delete mode 100644 server/tests/attach/basic/basic8.test.ts delete mode 100644 server/tests/attach/basic/sharedProducts.ts delete mode 100644 server/tests/attach/checkout/checkout1.test.ts delete mode 100644 server/tests/attach/checkout/checkout2.test.ts delete mode 100644 server/tests/attach/checkout/checkout3.test.ts delete mode 100644 server/tests/attach/checkout/checkout4.test.ts delete mode 100644 server/tests/attach/checkout/checkout5.test.ts delete mode 100644 server/tests/attach/checkout/checkout6.test.ts delete mode 100644 server/tests/attach/checkout/checkout7.test.ts delete mode 100644 server/tests/attach/checkout/checkout8.test.ts delete mode 100644 server/tests/attach/downgrade/downgrade1.test.ts delete mode 100644 server/tests/attach/downgrade/downgrade2.test.ts delete mode 100644 server/tests/attach/downgrade/downgrade3.test.ts delete mode 100644 server/tests/attach/downgrade/downgrade4.test.ts delete mode 100644 server/tests/attach/downgrade/downgrade5.test.ts delete mode 100644 server/tests/attach/downgrade/sharedProducts.ts delete mode 100644 server/tests/attach/entities/entity1.test.ts delete mode 100644 server/tests/attach/entities/entity2.test.ts delete mode 100644 server/tests/attach/entities/entity3.test.ts delete mode 100644 server/tests/attach/entities/entity4.test.ts delete mode 100644 server/tests/attach/entities/entity5.test.ts delete mode 100644 server/tests/attach/entities/entity6.test.ts delete mode 100644 server/tests/attach/free/free1.test.ts delete mode 100644 server/tests/attach/multiProduct/multiProduct1.test.ts delete mode 100644 server/tests/attach/others/others1.test.ts delete mode 100644 server/tests/attach/others/others10.test.ts delete mode 100644 server/tests/attach/others/others3.test.ts delete mode 100644 server/tests/attach/others/others6.test.ts delete mode 100644 server/tests/attach/others/others7.test.ts delete mode 100644 server/tests/attach/others/others8.test.ts delete mode 100644 server/tests/attach/others/others9.test.ts delete mode 100644 server/tests/attach/response/attach-response1.test.ts delete mode 100644 server/tests/attach/response/attach-response2.test.ts delete mode 100644 server/tests/attach/response/attach-response3.test.ts delete mode 100644 server/tests/attach/response/attach-response4.test.ts delete mode 100644 server/tests/attach/response/attach-response5.test.ts delete mode 100644 server/tests/attach/updateEnts/expectUpdateEnts.ts delete mode 100644 server/tests/attach/updateEnts/updateEnts1.test.ts delete mode 100644 server/tests/attach/updateEnts/updateEnts2.test.ts delete mode 100644 server/tests/attach/updateEnts/updateEnts3.test.ts delete mode 100644 server/tests/attach/updateEnts/updateEnts4.test.ts delete mode 100644 server/tests/attach/upgrade/upgrade1.test.ts delete mode 100644 server/tests/attach/upgrade/upgrade2.test.ts delete mode 100644 server/tests/attach/upgrade/upgrade3.test.ts delete mode 100644 server/tests/attach/upgrade/upgrade4.test.ts delete mode 100644 server/tests/attach/upgrade/upgrade5.test.ts delete mode 100644 server/tests/attach/upgrade/upgrade7.test.ts delete mode 100644 server/tests/attach/upgradeOld/sharedProducts.ts delete mode 100644 server/tests/attach/upgradeOld/upgradeOld2.test.ts delete mode 100644 server/tests/attach/upgradeOld/upgradeOld3.test.ts delete mode 100644 server/tests/attach/upgradeOld/upgradeOld4.test.ts delete mode 100644 server/tests/attach/utils.ts delete mode 100644 server/tests/external-psps/revenuecat/revenuecat-migration.test.ts delete mode 100644 server/tests/external-psps/revenuecat/revenuecat-webhooks.test.ts rename server/tests/integration/billing/legacy/attach/{new => addon}/legacy-addon-merged.test.ts (100%) create mode 100644 server/tests/integration/billing/legacy/attach/addon/legacy-addon.test.ts create mode 100644 server/tests/integration/billing/legacy/attach/attach-response.test.ts create mode 100644 server/tests/integration/billing/legacy/attach/checkout/legacy-checkout-advanced.test.ts create mode 100644 server/tests/integration/billing/legacy/attach/checkout/legacy-checkout-basic.test.ts create mode 100644 server/tests/integration/billing/legacy/attach/downgrade/legacy-downgrade.test.ts create mode 100644 server/tests/integration/billing/legacy/attach/entities/legacy-entities-basic.test.ts create mode 100644 server/tests/integration/billing/legacy/attach/invoice/legacy-attach-invoice-mode-advanced.test.ts create mode 100644 server/tests/integration/billing/legacy/attach/new/legacy-merge-interval.test.ts create mode 100644 server/tests/integration/billing/legacy/attach/upgrade/legacy-upgrade-prepaid.test.ts create mode 100644 server/tests/integration/billing/legacy/attach/upgrade/legacy-upgrade-usage.test.ts delete mode 100644 server/tests/integration/billing/legacy/attach/upgrade/upgrade-prepaid.test.ts delete mode 100644 server/tests/integration/billing/legacy/toDelete.md create mode 100644 server/tests/integration/billing/stripe-webhooks/subscription-updated/subscription-updated-past-due.test.ts rename server/tests/integration/{ => crud}/customers/cross-version-list-customers.test.ts (94%) delete mode 100644 server/tests/integration/crud/customers/list-customers.test.ts rename server/tests/integration/{features/rpc => crud/features}/feature-rpc-crud.test.ts (100%) create mode 100644 server/tests/integration/external-psps/revenuecat.test.ts rename server/tests/{external-psps/revenuecat => integration/external-psps}/utils/revenue-cat-webhook-client.ts (100%) rename server/tests/{external-psps/revenuecat => integration/external-psps/utils}/revenuecatWebhooks.test.ts (99%) delete mode 100644 server/tests/interval/multiSub/multiSubInterval1.test.ts delete mode 100644 server/tests/interval/multiSub/multiSubInterval2.test.ts delete mode 100644 server/tests/interval/multiSub/multiSubInterval3.test.ts delete mode 100644 server/tests/interval/upgrade/interval1.test.ts delete mode 100644 server/tests/interval/upgrade/interval2.test.ts delete mode 100644 server/tests/interval/upgrade/interval3.test.ts delete mode 100644 server/tests/merged/separate/separate1.test.ts delete mode 100644 server/tests/merged/separate/separate2.test.ts diff --git a/server/src/external/autumn/autumnCli.ts b/server/src/external/autumn/autumnCli.ts index dba226f55..f2cd18ab5 100644 --- a/server/src/external/autumn/autumnCli.ts +++ b/server/src/external/autumn/autumnCli.ts @@ -264,7 +264,12 @@ export class AutumnInt { { skipWebhooks, idempotencyKey, - }: { skipWebhooks?: boolean; idempotencyKey?: string } = {}, + timeout, + }: { + skipWebhooks?: boolean; + idempotencyKey?: string; + timeout?: number; + } = {}, ): Promise { const headers: Record = {}; if (skipWebhooks !== undefined) { @@ -280,6 +285,13 @@ export class AutumnInt { Object.keys(headers).length > 0 ? headers : undefined, ); + const concurrency = Number(process.env.TEST_FILE_CONCURRENCY || "0"); + const defaultTimeout = concurrency > 1 ? 5000 : 4000; + const finalTimeout = timeout ?? defaultTimeout; + if (finalTimeout) { + await new Promise((resolve) => setTimeout(resolve, finalTimeout)); + } + return data; } diff --git a/server/tests/_groups/core.ts b/server/tests/_groups/core.ts new file mode 100644 index 000000000..1d3ef30b2 --- /dev/null +++ b/server/tests/_groups/core.ts @@ -0,0 +1,39 @@ +import type { TestGroup } from "./types"; + +export const core: TestGroup = { + name: "core", + description: + "Critical flows that must pass: basic attach, balance operations, CRUD, unit tests", + tier: "core", + paths: [ + // ── Balance: Check ── + "integration/balances/check/check-basic.test.ts", + "balances/check/credit-systems", + "balances/check/send-event", + + // ── Balance: Track ── + "balances/track/basic", + "balances/track/concurrency", + "balances/track/credit-systems", + "balances/track/entity-balances", + "balances/track/entity-products", + "balances/track/negative", + "balances/track/paid-allocated", + "integration/balances/track/track-misc.test.ts", + + // ── Balance: Update ── + "integration/balances/update/balance/update-balance-basic.test.ts", + + // ── Legacy Attach ── + "integration/billing/legacy/attach/attach-new-billing-subscription.test.ts", + "integration/billing/legacy/attach/attach-misc.test.ts", + "integration/billing/legacy/attach/downgrade/legacy-downgrade-merged-schedule.test.ts", + "integration/billing/legacy/attach/group/legacy-group-merged.test.ts", + "integration/billing/legacy/attach/invoice/legacy-attach-invoice-mode.test.ts", + "integration/billing/legacy/attach/new/legacy-new-merged.test.ts", + "integration/billing/legacy/attach/separate/legacy-separate.test.ts", + "integration/billing/legacy/attach/trial/legacy-trial.test.ts", + "integration/billing/legacy/attach/update-quantity/legacy-update-quantity.test.ts", + "integration/billing/legacy/attach/upgrade/legacy-upgrade.test.ts", + ], +}; diff --git a/server/tests/_groups/domains/advanced.ts b/server/tests/_groups/domains/advanced.ts new file mode 100644 index 000000000..0f1b9dc1f --- /dev/null +++ b/server/tests/_groups/domains/advanced.ts @@ -0,0 +1,9 @@ +import type { TestGroup } from "../types"; + +export const advanced: TestGroup = { + name: "advanced", + description: + "Coupons, referrals, custom intervals, usage limits, multi-feature", + tier: "domain", + paths: [], +}; diff --git a/server/tests/_groups/domains/balances.ts b/server/tests/_groups/domains/balances.ts new file mode 100644 index 000000000..329979fec --- /dev/null +++ b/server/tests/_groups/domains/balances.ts @@ -0,0 +1,8 @@ +import type { TestGroup } from "../types"; + +export const balances: TestGroup = { + name: "balances", + description: "All balance check, track, set-usage, and cron tests", + tier: "domain", + paths: [], +}; diff --git a/server/tests/_groups/domains/billing.ts b/server/tests/_groups/domains/billing.ts new file mode 100644 index 000000000..5bb5281d0 --- /dev/null +++ b/server/tests/_groups/domains/billing.ts @@ -0,0 +1,9 @@ +import type { TestGroup } from "../types"; + +export const billing: TestGroup = { + name: "billing", + description: + "All attach, upgrade, downgrade, checkout, invoice, subscription, and legacy billing tests", + tier: "domain", + paths: [], +}; diff --git a/server/tests/_groups/domains/crud.ts b/server/tests/_groups/domains/crud.ts new file mode 100644 index 000000000..cde457125 --- /dev/null +++ b/server/tests/_groups/domains/crud.ts @@ -0,0 +1,8 @@ +import type { TestGroup } from "../types"; + +export const crud: TestGroup = { + name: "crud", + description: "Customer, plan, entity, and feature CRUD operations", + tier: "domain", + paths: [], +}; diff --git a/server/tests/_groups/domains/misc.ts b/server/tests/_groups/domains/misc.ts new file mode 100644 index 000000000..f50e0518b --- /dev/null +++ b/server/tests/_groups/domains/misc.ts @@ -0,0 +1,9 @@ +import type { TestGroup } from "../types"; + +export const misc: TestGroup = { + name: "misc", + description: + "Cron jobs, external PSPs, scenarios, rate limits, archived tests", + tier: "domain", + paths: [], +}; diff --git a/server/tests/_groups/domains/webhooks.ts b/server/tests/_groups/domains/webhooks.ts new file mode 100644 index 000000000..9ffb8f7dd --- /dev/null +++ b/server/tests/_groups/domains/webhooks.ts @@ -0,0 +1,9 @@ +import type { TestGroup } from "../types"; + +export const webhooks: TestGroup = { + name: "webhooks", + description: "Stripe and Autumn webhook handlers", + tier: "domain", + maxConcurrency: 3, + paths: [], +}; diff --git a/server/tests/_groups/index.ts b/server/tests/_groups/index.ts new file mode 100644 index 000000000..af1ab112c --- /dev/null +++ b/server/tests/_groups/index.ts @@ -0,0 +1,96 @@ +import { readdir } from "node:fs/promises"; +import { join, relative } from "node:path"; +import { core } from "./core"; +import { advanced } from "./domains/advanced"; +import { balances } from "./domains/balances"; +import { billing } from "./domains/billing"; +import { crud } from "./domains/crud"; +import { misc } from "./domains/misc"; +import { webhooks } from "./domains/webhooks"; +import { suites } from "./suites"; +import type { TestGroup, TestSuite } from "./types"; + +export type { TestGroup, TestSuite, TestTier } from "./types"; + +const allGroups: TestGroup[] = [ + core, + balances, + billing, + crud, + webhooks, + advanced, + misc, +]; + +export const getAllGroups = (): TestGroup[] => allGroups; + +export const getGroup = ({ name }: { name: string }): TestGroup | undefined => + allGroups.find((g) => g.name === name); + +export const getAllSuites = (): TestSuite[] => suites; + +export const getSuite = ({ name }: { name: string }): TestSuite | undefined => + suites.find((s) => s.name === name); + +/** Resolve a suite to its constituent test groups. */ +export const resolveSuite = ({ + name, +}: { + name: string; +}): TestGroup[] | undefined => { + const suite = getSuite({ name }); + if (!suite) return undefined; + + return suite.groups + .map((groupName) => getGroup({ name: groupName })) + .filter((g): g is TestGroup => g !== undefined); +}; + +/** Resolve a name to test paths -- checks groups first, then suites. */ +export const resolveTestPaths = ({ + name, +}: { + name: string; +}): string[] | undefined => { + const group = getGroup({ name }); + if (group) return group.paths; + + const suiteGroups = resolveSuite({ name }); + if (suiteGroups) { + const paths = suiteGroups.flatMap((g) => g.paths); + return [...new Set(paths)]; + } + + return undefined; +}; + +/** Recursively discover all *.test.ts files under a directory. */ +export const discoverAllTestFiles = async ({ + testsDir, +}: { + testsDir: string; +}): Promise => { + const results: string[] = []; + + const walk = async ({ dir }: { dir: string }) => { + const entries = await readdir(dir, { withFileTypes: true }); + for (const entry of entries) { + const fullPath = join(dir, entry.name); + if (entry.isDirectory()) { + if ( + entry.name.startsWith(".") || + entry.name.startsWith("_") || + entry.name === "node_modules" + ) { + continue; + } + await walk({ dir: fullPath }); + } else if (entry.name.endsWith(".test.ts")) { + results.push(relative(testsDir, fullPath)); + } + } + }; + + await walk({ dir: testsDir }); + return results.sort(); +}; diff --git a/server/tests/_groups/suites.ts b/server/tests/_groups/suites.ts new file mode 100644 index 000000000..911613805 --- /dev/null +++ b/server/tests/_groups/suites.ts @@ -0,0 +1,14 @@ +import type { TestSuite } from "./types"; + +export const suites: TestSuite[] = [ + { + name: "pre-merge", + description: "Run before merging any PR", + groups: ["core"], + }, + { + name: "all-domain", + description: "All domain-level test groups", + groups: ["balances", "billing", "crud", "webhooks", "advanced", "misc"], + }, +]; diff --git a/server/tests/_groups/types.ts b/server/tests/_groups/types.ts new file mode 100644 index 000000000..1a19777ae --- /dev/null +++ b/server/tests/_groups/types.ts @@ -0,0 +1,18 @@ +export type TestTier = "core" | "domain"; + +export type TestGroup = { + name: string; + description: string; + tier: TestTier; + /** Directory paths relative to server/tests/. Resolved recursively for all *.test.ts files. */ + paths: string[]; + /** Override the default concurrency for this group. */ + maxConcurrency?: number; +}; + +export type TestSuite = { + name: string; + description: string; + /** Group names to include when running this suite. */ + groups: string[]; +}; diff --git a/server/tests/core/reset1.test.ts b/server/tests/advanced/customInterval/reset1.test.ts similarity index 96% rename from server/tests/core/reset1.test.ts rename to server/tests/advanced/customInterval/reset1.test.ts index 3efd98a54..42149dfff 100644 --- a/server/tests/core/reset1.test.ts +++ b/server/tests/advanced/customInterval/reset1.test.ts @@ -8,7 +8,6 @@ import { ProductItemInterval, } from "@autumn/shared"; import { resetAndGetCusEnt } from "@tests/balances/track/rollovers/rolloverTestUtils.js"; -import { addPrefixToProducts } from "@tests/attach/utils.js"; import { TestFeature } from "@tests/setup/v2Features.js"; import ctx from "@tests/utils/testInitUtils/createTestContext.js"; import chalk from "chalk"; @@ -60,11 +59,6 @@ describe(`${chalk.yellowBright(`${testCase}: Testing custom reset intervals`)}`, stripeCli = ctx.stripeCli; - addPrefixToProducts({ - products: [free], - prefix: testCase, - }); - await initProductsV0({ ctx, products: [free], diff --git a/server/tests/interval/intervalUtils1.test.ts b/server/tests/archives/interval/intervalUtils1.test.ts similarity index 100% rename from server/tests/interval/intervalUtils1.test.ts rename to server/tests/archives/interval/intervalUtils1.test.ts diff --git a/server/tests/attach/migrations/migration2.test.ts b/server/tests/archives/migrations/migration2.test.ts similarity index 100% rename from server/tests/attach/migrations/migration2.test.ts rename to server/tests/archives/migrations/migration2.test.ts diff --git a/server/tests/attach/migrations/migration4.test.ts b/server/tests/archives/migrations/migration4.test.ts similarity index 100% rename from server/tests/attach/migrations/migration4.test.ts rename to server/tests/archives/migrations/migration4.test.ts diff --git a/server/tests/attach/migrations/migration5.test.ts b/server/tests/archives/migrations/migration5.test.ts similarity index 100% rename from server/tests/attach/migrations/migration5.test.ts rename to server/tests/archives/migrations/migration5.test.ts diff --git a/server/tests/attach/migrations/migration6.test.ts b/server/tests/archives/migrations/migration6.test.ts similarity index 100% rename from server/tests/attach/migrations/migration6.test.ts rename to server/tests/archives/migrations/migration6.test.ts diff --git a/server/tests/attach/migrations/migration7.test.ts b/server/tests/archives/migrations/migration7.test.ts similarity index 100% rename from server/tests/attach/migrations/migration7.test.ts rename to server/tests/archives/migrations/migration7.test.ts diff --git a/server/tests/attach/migrations/migration8.test.ts b/server/tests/archives/migrations/migration8.test.ts similarity index 100% rename from server/tests/attach/migrations/migration8.test.ts rename to server/tests/archives/migrations/migration8.test.ts diff --git a/server/tests/attach/migrations/runMigrationTest.ts b/server/tests/archives/migrations/runMigrationTest.ts similarity index 100% rename from server/tests/attach/migrations/runMigrationTest.ts rename to server/tests/archives/migrations/runMigrationTest.ts diff --git a/server/tests/core/multiAttach/multiAttach1.test.ts b/server/tests/archives/multiAttach/multiAttach1.test.ts similarity index 100% rename from server/tests/core/multiAttach/multiAttach1.test.ts rename to server/tests/archives/multiAttach/multiAttach1.test.ts diff --git a/server/tests/core/multiAttach/multiAttach2.test.ts b/server/tests/archives/multiAttach/multiAttach2.test.ts similarity index 100% rename from server/tests/core/multiAttach/multiAttach2.test.ts rename to server/tests/archives/multiAttach/multiAttach2.test.ts diff --git a/server/tests/core/multiAttach/multiAttach3.test.ts b/server/tests/archives/multiAttach/multiAttach3.test.ts similarity index 100% rename from server/tests/core/multiAttach/multiAttach3.test.ts rename to server/tests/archives/multiAttach/multiAttach3.test.ts diff --git a/server/tests/core/multiAttach/multiAttach4.test.ts b/server/tests/archives/multiAttach/multiAttach4.test.ts similarity index 100% rename from server/tests/core/multiAttach/multiAttach4.test.ts rename to server/tests/archives/multiAttach/multiAttach4.test.ts diff --git a/server/tests/core/multiAttach/multiAttach5.test.ts b/server/tests/archives/multiAttach/multiAttach5.test.ts similarity index 100% rename from server/tests/core/multiAttach/multiAttach5.test.ts rename to server/tests/archives/multiAttach/multiAttach5.test.ts diff --git a/server/tests/core/multiAttach/multiAttach6.test.ts b/server/tests/archives/multiAttach/multiAttach6.test.ts similarity index 100% rename from server/tests/core/multiAttach/multiAttach6.test.ts rename to server/tests/archives/multiAttach/multiAttach6.test.ts diff --git a/server/tests/core/multiAttach/multiInvoice/multiInvoice1.test.ts b/server/tests/archives/multiAttach/multiInvoice/multiInvoice1.test.ts similarity index 100% rename from server/tests/core/multiAttach/multiInvoice/multiInvoice1.test.ts rename to server/tests/archives/multiAttach/multiInvoice/multiInvoice1.test.ts diff --git a/server/tests/core/multiAttach/multiReward/multiReward1.test.ts b/server/tests/archives/multiAttach/multiReward/multiReward1.test.ts similarity index 100% rename from server/tests/core/multiAttach/multiReward/multiReward1.test.ts rename to server/tests/archives/multiAttach/multiReward/multiReward1.test.ts diff --git a/server/tests/core/multiAttach/multiReward/multiReward2.test.ts b/server/tests/archives/multiAttach/multiReward/multiReward2.test.ts similarity index 100% rename from server/tests/core/multiAttach/multiReward/multiReward2.test.ts rename to server/tests/archives/multiAttach/multiReward/multiReward2.test.ts diff --git a/server/tests/core/multiAttach/multiReward/multiReward3.test.ts b/server/tests/archives/multiAttach/multiReward/multiReward3.test.ts similarity index 100% rename from server/tests/core/multiAttach/multiReward/multiReward3.test.ts rename to server/tests/archives/multiAttach/multiReward/multiReward3.test.ts diff --git a/server/tests/core/multiAttach/multiReward/multiRewardUtils.test.ts b/server/tests/archives/multiAttach/multiReward/multiRewardUtils.test.ts similarity index 100% rename from server/tests/core/multiAttach/multiReward/multiRewardUtils.test.ts rename to server/tests/archives/multiAttach/multiReward/multiRewardUtils.test.ts diff --git a/server/tests/core/multiAttach/multiUpgrade/multiUpgrade1.test.ts b/server/tests/archives/multiAttach/multiUpgrade/multiUpgrade1.test.ts similarity index 100% rename from server/tests/core/multiAttach/multiUpgrade/multiUpgrade1.test.ts rename to server/tests/archives/multiAttach/multiUpgrade/multiUpgrade1.test.ts diff --git a/server/tests/core/multiAttach/multiUpgrade/multiUpgrade2.test.ts b/server/tests/archives/multiAttach/multiUpgrade/multiUpgrade2.test.ts similarity index 100% rename from server/tests/core/multiAttach/multiUpgrade/multiUpgrade2.test.ts rename to server/tests/archives/multiAttach/multiUpgrade/multiUpgrade2.test.ts diff --git a/server/tests/attach/newVersion/newVersion1.test.ts b/server/tests/archives/newVersion/newVersion1.test.ts similarity index 100% rename from server/tests/attach/newVersion/newVersion1.test.ts rename to server/tests/archives/newVersion/newVersion1.test.ts diff --git a/server/tests/attach/newVersion/newVersion2.test.ts b/server/tests/archives/newVersion/newVersion2.test.ts similarity index 100% rename from server/tests/attach/newVersion/newVersion2.test.ts rename to server/tests/archives/newVersion/newVersion2.test.ts diff --git a/server/tests/attach/newVersion/newVersion3.test.ts b/server/tests/archives/newVersion/newVersion3.test.ts similarity index 100% rename from server/tests/attach/newVersion/newVersion3.test.ts rename to server/tests/archives/newVersion/newVersion3.test.ts diff --git a/server/tests/attach/newVersion/newVersion4.test.ts b/server/tests/archives/newVersion/newVersion4.test.ts similarity index 100% rename from server/tests/attach/newVersion/newVersion4.test.ts rename to server/tests/archives/newVersion/newVersion4.test.ts diff --git a/server/tests/attach/addOn/addOn1.test.ts b/server/tests/attach/addOn/addOn1.test.ts deleted file mode 100644 index 3e30d0697..000000000 --- a/server/tests/attach/addOn/addOn1.test.ts +++ /dev/null @@ -1,137 +0,0 @@ -import { beforeAll, describe, expect, test } from "bun:test"; -import { LegacyVersion } from "@autumn/shared"; -import { TestFeature } from "@tests/setup/v2Features.js"; -import { attachAndExpectCorrect } from "@tests/utils/expectUtils/expectAttach.js"; -import { expectProductAttached } from "@tests/utils/expectUtils/expectProductAttached.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, - constructRawProduct, -} from "@/utils/scriptUtils/createTestProducts.js"; -import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js"; -import { initProductsV0 } from "@/utils/scriptUtils/testUtils/initProductsV0.js"; -import { expectFeaturesCorrect } from "../../utils/expectUtils/expectFeaturesCorrect.js"; -import { replaceItems } from "../utils.js"; - -export const pro = constructProduct({ - items: [ - constructFeatureItem({ - featureId: TestFeature.Messages, - includedUsage: 100, - }), - ], - type: "pro", -}); - -export const addOn = constructRawProduct({ - id: "add_on_1", - items: [ - constructFeatureItem({ - featureId: TestFeature.Messages, - includedUsage: 200, - }), - ], - isAddOn: true, -}); - -const testCase = "addOn1"; - -describe(`${chalk.yellowBright(`${testCase}: Testing free add on, and updating free add on`)}`, () => { - const customerId = testCase; - const autumn: AutumnInt = new AutumnInt({ version: LegacyVersion.v1_2 }); - - beforeAll(async () => { - await initCustomerV3({ - ctx, - customerId, - attachPm: "success", - withTestClock: true, - }); - await initProductsV0({ - ctx, - products: [pro, addOn], - prefix: testCase, - }); - }); - - test("should should attach pro product, then add on product", async () => { - await attachAndExpectCorrect({ - autumn, - db: ctx.db, - org: ctx.org, - env: ctx.env, - stripeCli: ctx.stripeCli, - customerId, - product: pro, - }); - }); - - test("should should attach add on product", async () => { - const preview = await autumn.attachPreview({ - customer_id: customerId, - product_id: addOn.id, - }); - - await autumn.attach({ - customer_id: customerId, - product_id: addOn.id, - }); - - const customer = await autumn.customers.get(customerId); - - expect(customer.products.length).toBe(2); - expectProductAttached({ - customer, - product: addOn, - }); - expectProductAttached({ - customer, - product: pro, - }); - }); - - const customItems = replaceItems({ - items: addOn.items, - featureId: TestFeature.Messages, - newItem: constructFeatureItem({ - featureId: TestFeature.Messages, - includedUsage: 400, - }), - }); - - test("should attach new free add on product", async () => { - const preview = await autumn.attachPreview({ - customer_id: customerId, - product_id: addOn.id, - is_custom: true, - items: customItems, - }); - - await autumn.attach({ - customer_id: customerId, - product_id: addOn.id, - is_custom: true, - items: customItems, - }); - - const customer = await autumn.customers.get(customerId); - - expect(customer.products.length).toBe(2); - expectProductAttached({ - customer, - product: addOn, - }); - - expectFeaturesCorrect({ - customer, - product: { - ...addOn, - items: customItems, - }, - otherProducts: [pro, addOn], - }); - }); -}); diff --git a/server/tests/attach/addOn/addOn2.test.ts b/server/tests/attach/addOn/addOn2.test.ts deleted file mode 100644 index d6c20c4b3..000000000 --- a/server/tests/attach/addOn/addOn2.test.ts +++ /dev/null @@ -1,92 +0,0 @@ -import { beforeAll, describe, test } from "bun:test"; -import { type AppEnv, LegacyVersion, type Organization } from "@autumn/shared"; -import { TestFeature } from "@tests/setup/v2Features.js"; -import { attachAndExpectCorrect } from "@tests/utils/expectUtils/expectAttach.js"; -import { expectFeaturesCorrect } from "@tests/utils/expectUtils/expectFeaturesCorrect.js"; -import { expectProductAttached } from "@tests/utils/expectUtils/expectProductAttached.js"; -import ctx from "@tests/utils/testInitUtils/createTestContext.js"; -import chalk from "chalk"; -import type Stripe from "stripe"; -import type { DrizzleCli } from "@/db/initDrizzle.js"; -import { AutumnInt } from "@/external/autumn/autumnCli.js"; -import { constructFeatureItem } from "@/utils/scriptUtils/constructItem.js"; -import { - constructProduct, - constructRawProduct, -} from "@/utils/scriptUtils/createTestProducts.js"; -import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js"; -import { initProductsV0 } from "@/utils/scriptUtils/testUtils/initProductsV0.js"; - -export const pro = constructProduct({ - type: "pro", - items: [], -}); - -export const addOn = constructRawProduct({ - id: "addOn", - isAddOn: true, - items: [ - constructFeatureItem({ - featureId: TestFeature.Credits, - }), - ], -}); - -const testCase = "addOn2"; - -describe(`${chalk.yellowBright(`${testCase}: Testing attach free add on twice (should be treated as one off?)`)}`, () => { - const customerId = testCase; - const autumn: AutumnInt = new AutumnInt({ version: LegacyVersion.v1_4 }); - let db: DrizzleCli, org: Organization, env: AppEnv; - let stripeCli: Stripe; - - beforeAll(async () => { - db = ctx.db; - org = ctx.org; - env = ctx.env; - stripeCli = ctx.stripeCli; - - await initCustomerV3({ - ctx, - customerId, - customerData: { fingerprint: "test" }, - attachPm: "success", - withTestClock: true, - }); - - await initProductsV0({ - ctx, - products: [pro, addOn], - prefix: testCase, - }); - }); - - test("should attach pro product and free add on", async () => { - await attachAndExpectCorrect({ - autumn, - customerId, - product: pro, - stripeCli, - db, - org, - env, - skipSubCheck: true, - }); - - await autumn.attach({ - customer_id: customerId, - product_id: addOn.id, - }); - - const customer = await autumn.customers.get(customerId); - expectProductAttached({ - customer, - product: addOn, - }); - - expectFeaturesCorrect({ - customer, - product: addOn, - }); - }); -}); diff --git a/server/tests/attach/basic/basic2.test.ts b/server/tests/attach/basic/basic2.test.ts deleted file mode 100644 index 717e317b8..000000000 --- a/server/tests/attach/basic/basic2.test.ts +++ /dev/null @@ -1,150 +0,0 @@ -import { beforeAll, describe, expect, test } from "bun:test"; -import { ApiVersion, ProductItemInterval } from "@autumn/shared"; -import type { ApiCustomerV1 } from "@shared/api/customers/previousVersions/apiCustomerV1.js"; -import { AutumnCli } from "@tests/cli/AutumnCli.js"; -import { TestFeature } from "@tests/setup/v2Features.js"; -import { expectCustomerV0Correct } from "@tests/utils/expectUtils/expectCustomerV0Correct.js"; -import ctx from "@tests/utils/testInitUtils/createTestContext.js"; -import chalk from "chalk"; -import { AutumnInt } from "@/external/autumn/autumnCli.js"; -import { - constructFeatureItem, - constructPrepaidItem, -} from "@/utils/scriptUtils/constructItem.js"; -import { - constructProduct, - constructRawProduct, -} from "@/utils/scriptUtils/createTestProducts.js"; -import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js"; -import { initProductsV0 } from "@/utils/scriptUtils/testUtils/initProductsV0.js"; - -// Pro product (matches global products.pro) -const proProd = constructProduct({ - type: "pro", - items: [ - constructFeatureItem({ - featureId: TestFeature.Dashboard, - isBoolean: true, - }), - constructFeatureItem({ - featureId: TestFeature.Messages, - includedUsage: 10, - interval: ProductItemInterval.Month, - }), - constructFeatureItem({ - featureId: TestFeature.Admin, - unlimited: true, - }), - ], -}); - -// Monthly add-on product (matches global products.monthlyAddOnMetered1) -// - Prepaid monthly add-on -// - 0 base allowance, customer specifies quantity -const monthlyAddOn = constructRawProduct({ - id: "monthly-add-on-metered-1", - isAddOn: true, - items: [ - constructPrepaidItem({ - featureId: TestFeature.Messages, - price: 9, - billingUnits: 250, - includedUsage: 0, - }), - ], -}); - -const testCase = "basic2"; -const customerId = testCase; - -describe(`${chalk.yellowBright("basic2: Testing attach monthly add on")}`, () => { - const autumnV1 = new AutumnInt({ - secretKey: ctx.orgSecretKey, - version: ApiVersion.V1_2, - }); - - beforeAll(async () => { - // Create products FIRST before customer creation - await initProductsV0({ - ctx, - products: [proProd, monthlyAddOn], - prefix: testCase, - customerId, - }); - - // Then create customer with payment method - await initCustomerV3({ - ctx, - customerId, - attachPm: "success", - withTestClock: true, - }); - }); - - test("should attach pro", async () => { - await autumnV1.attach({ - customer_id: customerId, - product_id: proProd.id, - }); - - const res = await AutumnCli.getCustomer(customerId); - await expectCustomerV0Correct({ - sent: proProd, - cusRes: res, - }); - }); - - const monthlyQuantity = 500; - - test("should attach monthly add on", async () => { - await AutumnCli.attach({ - customerId: customerId, - productId: monthlyAddOn.id, - forceCheckout: false, - options: [ - { - feature_id: TestFeature.Messages, - quantity: monthlyQuantity, - }, - ], - }); - }); - - test("should have correct product & entitlements", async () => { - const cusRes = await AutumnCli.getCustomer(customerId); - - // Pro gives 10 Messages - const proMetered1 = 10; - - const monthlyMetered1Balance = cusRes.entitlements.find( - (e: ApiCustomerV1["entitlements"][number]) => - e.feature_id === TestFeature.Messages && e.interval === "month", - ); - - expect(monthlyMetered1Balance?.balance).toBe(proMetered1 + monthlyQuantity); - - expect(cusRes.add_ons).toHaveLength(1); - const monthlyAddOnId = cusRes.add_ons.find( - (a: any) => a.id === monthlyAddOn.id, - ); - - expect(monthlyAddOnId).toBeDefined(); - expect(cusRes.invoices.length).toBe(2); - }); - - test("should have correct /check result for metered1", async () => { - const res: any = await AutumnCli.entitled(customerId, TestFeature.Messages); - - const metered1Balance = res!.balances.find( - (b: any) => b.feature_id === TestFeature.Messages, - ); - - // Pro gives 10, monthly add-on gives monthlyQuantity - const proMetered1Amt = 10; - const monthlyAddOnMetered1Amt = monthlyQuantity; - - expect(metered1Balance!.balance).toBe( - proMetered1Amt + monthlyAddOnMetered1Amt, - ); - }); -}); diff --git a/server/tests/attach/basic/basic6.test.ts b/server/tests/attach/basic/basic6.test.ts deleted file mode 100644 index b399f5db1..000000000 --- a/server/tests/attach/basic/basic6.test.ts +++ /dev/null @@ -1,101 +0,0 @@ -import { beforeAll, describe, expect, test } from "bun:test"; -import { - ApiVersion, - CusProductStatus, - type Customer, - ProductItemInterval, -} from "@autumn/shared"; -import { AutumnCli } from "@tests/cli/AutumnCli.js"; -import { TestFeature } from "@tests/setup/v2Features.js"; -import { advanceToNextInvoice } from "@tests/utils/testAttachUtils/testAttachUtils"; -import ctx from "@tests/utils/testInitUtils/createTestContext.js"; -import chalk from "chalk"; -import type Stripe from "stripe"; -import { AutumnInt } from "@/external/autumn/autumnCli.js"; -import { attachFailedPaymentMethod } from "@/external/stripe/stripeCusUtils"; -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"; - -// Pro product (matches global products.pro) -const proProd = constructProduct({ - type: "pro", - items: [ - constructFeatureItem({ - featureId: TestFeature.Dashboard, - isBoolean: true, - }), - constructFeatureItem({ - featureId: TestFeature.Messages, - includedUsage: 10, - interval: ProductItemInterval.Month, - }), - constructFeatureItem({ - featureId: TestFeature.Admin, - unlimited: true, - }), - ], -}); - -const testCase = "basic6"; -const customerId = testCase; - -describe(`${chalk.yellowBright("basic6: Testing subscription past_due")}`, () => { - const autumnV1 = new AutumnInt({ - secretKey: ctx.orgSecretKey, - version: ApiVersion.V1_2, - }); - - let stripeCli: Stripe; - let testClockId: string; - let customer: Customer; - - beforeAll(async () => { - stripeCli = ctx.stripeCli; - - // Create products FIRST before customer creation - await initProductsV0({ - ctx, - products: [proProd], - prefix: testCase, - customerId, - }); - - // Then create customer with payment method - const result = await initCustomerV3({ - ctx, - customerId, - attachPm: "success", - withTestClock: true, - }); - testClockId = result.testClockId; - customer = result.customer; - }); - - test("should attach pro product and switch to failed payment method", async () => { - const res = await autumnV1.attach({ - customer_id: customerId, - product_id: proProd.id, - }); - - await attachFailedPaymentMethod({ - stripeCli, - customer, - }); - }); - - test("should advance to next cycle", async () => { - await advanceToNextInvoice({ - stripeCli, - testClockId, - }); - }); - - test("should have pro product in past due status", async () => { - const cusRes: any = await AutumnCli.getCustomer(customerId); - const proProduct = cusRes.products.find((p: any) => p.id === proProd.id); - expect(proProduct).toBeDefined(); - expect(proProduct.status).toBe(CusProductStatus.PastDue); - }); -}); diff --git a/server/tests/attach/basic/basic8.test.ts b/server/tests/attach/basic/basic8.test.ts deleted file mode 100644 index 0f735eb2f..000000000 --- a/server/tests/attach/basic/basic8.test.ts +++ /dev/null @@ -1,123 +0,0 @@ -import { beforeAll, describe, expect, test } from "bun:test"; -import { - ApiVersion, - CusProductStatus, - FreeTrialDuration, - ProductItemInterval, -} from "@autumn/shared"; -import { AutumnCli } from "@tests/cli/AutumnCli.js"; -import { TestFeature } from "@tests/setup/v2Features.js"; -import { expectCustomerV0Correct } from "@tests/utils/expectUtils/expectCustomerV0Correct.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"; - -// Pro product with trial (matches global products.proWithTrial) -const proWithTrial = constructProduct({ - type: "pro", - items: [ - constructFeatureItem({ - featureId: TestFeature.Dashboard, - isBoolean: true, - }), - constructFeatureItem({ - featureId: TestFeature.Messages, - includedUsage: 10, - interval: ProductItemInterval.Month, - }), - constructFeatureItem({ - featureId: TestFeature.Admin, - unlimited: true, - }), - ], - freeTrial: { - length: 7, - duration: FreeTrialDuration.Day, - unique_fingerprint: true, - card_required: true, - }, -}); - -const testCase = "basic8"; -const customerId = testCase; -const customerId2 = `${testCase}2`; - -describe(`${chalk.yellowBright("basic8: Testing trial duplicates (same fingerprint)")}`, () => { - const autumnV1 = new AutumnInt({ - secretKey: ctx.orgSecretKey, - version: ApiVersion.V1_2, - }); - - const randFingerprint = Math.random().toString(36).substring(2, 15); - - beforeAll(async () => { - // Create products FIRST before customer creation - await initProductsV0({ - ctx, - products: [proWithTrial], - prefix: testCase, - customerIds: [customerId, customerId2], - }); - - // Create first customer with fingerprint - await initCustomerV3({ - ctx, - customerId, - customerData: { fingerprint: randFingerprint }, - attachPm: "success", - withTestClock: true, - }); - - // Create second customer with same fingerprint - await initCustomerV3({ - ctx, - customerId: customerId2, - customerData: { fingerprint: randFingerprint }, - attachPm: "success", - withTestClock: true, - }); - }); - - test("should attach pro with trial and have correct product & invoice", async () => { - await autumnV1.attach({ - customer_id: customerId, - product_id: proWithTrial.id, - }); - - const customer = await AutumnCli.getCustomer(customerId); - - await expectCustomerV0Correct({ - sent: proWithTrial, - cusRes: customer, - status: CusProductStatus.Trialing, - }); - - const invoices = customer.invoices; - expect(invoices.length).toBe(1); - expect(invoices[0].total).toBe(0); - }); - - test("should attach pro with trial to second customer and have correct product & invoice (pro with trial, full price)", async () => { - await autumnV1.attach({ - customer_id: customerId2, - product_id: proWithTrial.id, - }); - - const customer = await AutumnCli.getCustomer(customerId2); - console.log(JSON.stringify(customer, null, 2)); - - await expectCustomerV0Correct({ - sent: proWithTrial, - cusRes: customer, - status: CusProductStatus.Active, - }); - - const invoices = customer.invoices; - expect(invoices.length).toBe(1); - expect(invoices[0].total).toBe(20); - }); -}); diff --git a/server/tests/attach/basic/sharedProducts.ts b/server/tests/attach/basic/sharedProducts.ts deleted file mode 100644 index 154f053da..000000000 --- a/server/tests/attach/basic/sharedProducts.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { ProductItemInterval } from "@autumn/shared"; -import { TestFeature } from "@tests/setup/v2Features.js"; -import ctx from "@tests/utils/testInitUtils/createTestContext.js"; -import { constructFeatureItem } from "@/utils/scriptUtils/constructItem.js"; -import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js"; -import { createSharedProducts } from "@/utils/scriptUtils/testUtils/createSharedProduct.js"; -/** - * Shared default product for basic test group - * Used by multiple tests (basic1, basic3) to avoid conflicts - * ID is NOT prefixed - shared across all tests in this group - */ -export const sharedDefaultFree = constructProduct({ - id: "shared-default-free", - type: "free", - isDefault: true, - items: [ - constructFeatureItem({ - featureId: TestFeature.Messages, - includedUsage: 5, - interval: ProductItemInterval.Month, - }), - ], -}); - -export const sharedProProduct = constructProduct({ - id: "shared-pro-product", - isDefault: false, - type: "pro", - items: [ - constructFeatureItem({ - featureId: TestFeature.Dashboard, - isBoolean: true, - }), - constructFeatureItem({ - featureId: TestFeature.Messages, - includedUsage: 10, - interval: ProductItemInterval.Month, - }), - constructFeatureItem({ - featureId: TestFeature.Admin, - unlimited: true, - }), - ], -}); -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({ - ctx, - products: [sharedDefaultFree, sharedProProduct], - }); -}; - -// Auto-init on import (backwards compat) -await initBasicSharedProducts(); diff --git a/server/tests/attach/checkout/checkout1.test.ts b/server/tests/attach/checkout/checkout1.test.ts deleted file mode 100644 index 292814822..000000000 --- a/server/tests/attach/checkout/checkout1.test.ts +++ /dev/null @@ -1,128 +0,0 @@ -import { beforeAll, describe, expect, test } from "bun:test"; -import { - ApiVersion, - type CheckResponseV0, - type Entitlement, - ProductItemInterval, -} from "@autumn/shared"; -import { AutumnCli } from "@tests/cli/AutumnCli.js"; -import { TestFeature } from "@tests/setup/v2Features.js"; -import { expectCustomerV0Correct } from "@tests/utils/expectUtils/expectCustomerV0Correct.js"; -import { timeout } from "@tests/utils/genUtils.js"; -import { completeStripeCheckoutFormV2 as completeCheckoutForm } from "@tests/utils/browserPool/completeStripeCheckoutFormV2"; -import ctx from "@tests/utils/testInitUtils/createTestContext.js"; -import chalk from "chalk"; - -import { AutumnInt } from "@/external/autumn/autumnCli.js"; -import { convertProductV2ToV1 } from "@/internal/products/productUtils/productV2Utils/convertProductV2ToV1.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"; - -// Pro product with boolean, metered, and unlimited features -const proProd = constructProduct({ - type: "pro", - items: [ - constructFeatureItem({ - featureId: TestFeature.Dashboard, - isBoolean: true, - }), - constructFeatureItem({ - featureId: TestFeature.Messages, - includedUsage: 10, - interval: ProductItemInterval.Month, - }), - // Unlimited feature (maps to global products.pro.infinite1) - constructFeatureItem({ - featureId: TestFeature.Admin, - unlimited: true, - }), - ], -}); - -const testCase = "checkout1"; -const customerId = testCase; - -describe(`${chalk.yellowBright("checkout1: Testing attach basic product through checkout")}`, () => { - const autumnV1 = new AutumnInt({ - secretKey: ctx.orgSecretKey, - version: ApiVersion.V1_2, - }); - - beforeAll(async () => { - await initProductsV0({ - ctx, - products: [proProd], - prefix: testCase, - customerId, - }); - - // Then create customer - await initCustomerV3({ - ctx, - customerId, - customerData: { fingerprint: "test" }, - withTestClock: true, - }); - }); - - test("should attach pro through checkout", async () => { - const { checkout_url } = await autumnV1.attach({ - customer_id: customerId, - product_id: proProd.id, - }); - - await completeCheckoutForm({ url: checkout_url }); - await timeout(12000); - }); - - test("should have correct product & entitlements", async () => { - const res = await AutumnCli.getCustomer(customerId); - - await expectCustomerV0Correct({ - sent: proProd, - cusRes: res, - }); - expect(res.invoices.length).toBeGreaterThan(0); - }); - - test("should have correct result when calling /check", async () => { - // Convert ProductV2 to V1 to get reference entitlements (what we SENT) - const proProdV1 = convertProductV2ToV1({ - productV2: proProd, - orgId: ctx.org.id, - features: ctx.features, - }); - const proEntitlements = proProdV1.entitlements; - - // Iterate through reference product's entitlements and verify check responses - for (const entitlement of Object.values(proEntitlements) as Entitlement[]) { - const allowance = entitlement.allowance; - - const res = (await AutumnCli.entitled( - customerId, - entitlement.feature_id!, - )) as CheckResponseV0; - - const entBalance = res.balances.find( - (b) => b.feature_id === entitlement.feature_id, - ); - - expect( - res.allowed, - `Allowed for ${entitlement.feature_id} is not true`, - ).toBe(true); - expect( - entBalance, - `Entitlement ${entitlement.feature_id} balance not found`, - ).toBeDefined(); - if (entitlement.allowance) { - expect( - entBalance?.balance, - `Entitlement ${entitlement.feature_id} balance does not match expected balance.`, - ).toBe(allowance); - } - } - }); -}); diff --git a/server/tests/attach/checkout/checkout2.test.ts b/server/tests/attach/checkout/checkout2.test.ts deleted file mode 100644 index 078cfcf60..000000000 --- a/server/tests/attach/checkout/checkout2.test.ts +++ /dev/null @@ -1,159 +0,0 @@ -import { beforeAll, describe, expect, test } from "bun:test"; -import { - ApiVersion, - type CheckResponseV0, - type LimitedItem, - ProductItemInterval, -} from "@autumn/shared"; -import type { ApiCustomerV1 } from "@shared/api/customers/previousVersions/apiCustomerV1.js"; -import { AutumnCli } from "@tests/cli/AutumnCli.js"; -import { TestFeature } from "@tests/setup/v2Features.js"; -import { expectCustomerV0Correct } from "@tests/utils/expectUtils/expectCustomerV0Correct.js"; -import { timeout } from "@tests/utils/genUtils.js"; -import { completeStripeCheckoutFormV2 as completeCheckoutForm } from "@tests/utils/browserPool/completeStripeCheckoutFormV2"; -import ctx from "@tests/utils/testInitUtils/createTestContext.js"; -import chalk from "chalk"; -import { AutumnInt } from "@/external/autumn/autumnCli.js"; -import { - constructFeatureItem, - constructPrepaidItem, -} from "@/utils/scriptUtils/constructItem.js"; -import { - constructProduct, - constructRawProduct, -} from "@/utils/scriptUtils/createTestProducts.js"; -import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js"; -import { initProductsV0 } from "@/utils/scriptUtils/testUtils/initProductsV0.js"; - -// Pro product (matches global products.pro) -const proProd = constructProduct({ - type: "pro", - items: [ - constructFeatureItem({ - featureId: TestFeature.Dashboard, - isBoolean: true, - }), - constructFeatureItem({ - featureId: TestFeature.Messages, - includedUsage: 10, - interval: ProductItemInterval.Month, - }), - constructFeatureItem({ - featureId: TestFeature.Admin, - unlimited: true, - }), - ], -}); - -// One-time add-on product -const oneTimeItem = constructPrepaidItem({ - featureId: TestFeature.Messages, - price: 9, - billingUnits: 250, - isOneOff: true, -}) as LimitedItem; - -const oneTime = constructRawProduct({ - id: "one_off", - items: [oneTimeItem], - isAddOn: true, -}); - -const testCase = "checkout2"; -const customerId = testCase; - -describe(`${chalk.yellowBright("checkout2: Testing attach one time add ons (through checkout)")}`, () => { - const autumnV1 = new AutumnInt({ - secretKey: ctx.orgSecretKey, - version: ApiVersion.V1_2, - }); - - beforeAll(async () => { - // Create products FIRST before customer creation - await initProductsV0({ - ctx, - products: [proProd, oneTime], - prefix: testCase, - customerId, - }); - - // Then create customer with payment method - await initCustomerV3({ - ctx, - customerId, - attachPm: "success", - withTestClock: true, - }); - }); - - test("should attach pro", async () => { - await autumnV1.attach({ - customer_id: customerId, - product_id: proProd.id, - }); - - const res = await AutumnCli.getCustomer(customerId); - await expectCustomerV0Correct({ - sent: proProd, - cusRes: res, - }); - }); - - const oneTimeQuantity = 500; - const oneTimeBillingUnits = oneTimeItem.billing_units; - const oneTimePurchaseCount = 2; - - test("should attach one time add on twice, force checkout", async () => { - for (let i = 0; i < 2; i++) { - const res = await autumnV1.attach({ - customer_id: customerId, - product_id: oneTime.id, - force_checkout: true, - }); - - await completeCheckoutForm({ - url: res.checkout_url, - overrideQuantity: oneTimeQuantity / (oneTimeBillingUnits ?? 1), - }); - await timeout(15000); - } - }); - - test("should have correct product & entitlements", async () => { - const cusRes = await AutumnCli.getCustomer(customerId); - - // Find the add-on balance for Messages with lifetime interval (one-time purchase) - const addOnBalance = cusRes.entitlements.find( - (e: ApiCustomerV1["entitlements"][number]) => - e.feature_id === TestFeature.Messages && e.interval === "lifetime", - ); - - const expectedAmt = oneTimeQuantity * oneTimePurchaseCount; - - expect(addOnBalance?.balance).toBe(expectedAmt); - - expect(cusRes.add_ons).toHaveLength(1); - expect(cusRes.add_ons[0].id).toBe(oneTime.id); - expect(cusRes.invoices.length).toBe(1 + oneTimePurchaseCount); - }); - - test("should have correct /check result for metered1", async () => { - const res = (await AutumnCli.entitled( - customerId, - TestFeature.Messages, - )) as CheckResponseV0; - - expect(res.allowed).toBe(true); - - // Pro product gives 10 Messages per month - const proMetered1Amt = 10; - const addOnBalance = res.balances.find( - (b: CheckResponseV0["balances"][number]) => - b.feature_id === TestFeature.Messages, - ); - - expect(addOnBalance?.balance).toBe( - proMetered1Amt + oneTimeQuantity * oneTimePurchaseCount, - ); - }); -}); diff --git a/server/tests/attach/checkout/checkout3.test.ts b/server/tests/attach/checkout/checkout3.test.ts deleted file mode 100644 index 25421742f..000000000 --- a/server/tests/attach/checkout/checkout3.test.ts +++ /dev/null @@ -1,86 +0,0 @@ -import { beforeAll, describe, test } from "bun:test"; -import { LegacyVersion } from "@autumn/shared"; -import { TestFeature } from "@tests/setup/v2Features.js"; -import { expectFeaturesCorrect } from "@tests/utils/expectUtils/expectFeaturesCorrect.js"; -import { expectProductAttached } from "@tests/utils/expectUtils/expectProductAttached.js"; -import { completeStripeCheckoutFormV2 as completeCheckoutForm } from "@tests/utils/browserPool/completeStripeCheckoutFormV2"; -import ctx from "@tests/utils/testInitUtils/createTestContext.js"; -import chalk from "chalk"; -import { AutumnInt } from "@/external/autumn/autumnCli.js"; -import { timeout } from "@/utils/genUtils.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"; - -export const pro = constructProduct({ - items: [ - constructFeatureItem({ - featureId: TestFeature.Messages, - includedUsage: 100, - }), - ], - type: "pro", -}); - -export const oneOff = constructProduct({ - items: [ - constructFeatureItem({ - featureId: TestFeature.Users, - includedUsage: 5, - }), - ], - type: "one_off", - isAddOn: true, -}); - -const testCase = "checkout3"; -describe(`${chalk.yellowBright(`${testCase}: Testing multi attach checkout, pro + one off`)}`, () => { - const customerId = testCase; - const autumn: AutumnInt = new AutumnInt({ version: LegacyVersion.v1_4 }); - - beforeAll(async () => { - await initCustomerV3({ - ctx, - customerId, - withTestClock: true, - }); - - await initProductsV0({ - ctx, - products: [pro, oneOff], - prefix: testCase, - }); - }); - - test("should attach pro and one off product", async () => { - const res = await autumn.attach({ - customer_id: customerId, - product_ids: [pro.id, oneOff.id], - }); - - await completeCheckoutForm({ url: res.checkout_url }); - await timeout(10000); - - const customer = await autumn.customers.get(customerId); - - expectProductAttached({ - customer, - product: pro, - }); - expectProductAttached({ - customer, - product: oneOff, - }); - - expectFeaturesCorrect({ - customer, - product: pro, - }); - - expectFeaturesCorrect({ - customer, - product: oneOff, - }); - }); -}); diff --git a/server/tests/attach/checkout/checkout4.test.ts b/server/tests/attach/checkout/checkout4.test.ts deleted file mode 100644 index 0a3d915fc..000000000 --- a/server/tests/attach/checkout/checkout4.test.ts +++ /dev/null @@ -1,86 +0,0 @@ -import { beforeAll, describe, expect, test } from "bun:test"; -import { RewardType } from "@autumn/shared"; -import { TestFeature } from "@tests/setup/v2Features.js"; -import { completeStripeCheckoutFormV2 as completeStripeCheckoutForm } from "@tests/utils/browserPool/completeStripeCheckoutFormV2"; -import { expectProductAttached } from "@tests/utils/expectUtils/expectProductAttached.js"; -import { createReward } from "@tests/utils/productUtils.js"; -import ctx from "@tests/utils/testInitUtils/createTestContext.js"; -import { getBasePrice } from "@tests/utils/testProductUtils/testProductUtils.js"; -import chalk from "chalk"; -import { AutumnInt } from "@/external/autumn/autumnCli.js"; -import { timeout } from "@/utils/genUtils.js"; -import { constructFeatureItem } from "@/utils/scriptUtils/constructItem.js"; -import { - constructCoupon, - constructProduct, -} from "@/utils/scriptUtils/createTestProducts.js"; -import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js"; -import { initProductsV0 } from "@/utils/scriptUtils/testUtils/initProductsV0.js"; - -const pro = constructProduct({ - items: [ - constructFeatureItem({ - featureId: TestFeature.Messages, - includedUsage: 100, - }), - ], - type: "pro", -}); - -const reward = constructCoupon({ - id: "checkout4", - promoCode: "checkout4code", - discountType: RewardType.PercentageDiscount, - discountValue: 50, -}); - -const testCase = "checkout4"; -describe(`${chalk.yellowBright(`${testCase}: Testing attach coupon`)}`, () => { - const customerId = testCase; - const autumn: AutumnInt = new AutumnInt(); - - beforeAll(async () => { - await initCustomerV3({ - ctx, - customerId, - withTestClock: true, - }); - - await initProductsV0({ - ctx, - products: [pro], - prefix: testCase, - }); - - await createReward({ - orgId: ctx.org.id, - env: ctx.env, - db: ctx.db, - autumn, - reward, - productId: pro.id, - }); - }); - - test("should attach pro and one off product", async () => { - const res = await autumn.attach({ - customer_id: customerId, - product_id: pro.id, - reward: reward.id, - }); - - await completeStripeCheckoutForm({ url: res.checkout_url }); - await timeout(10000); - - const customer = await autumn.customers.get(customerId); - - expectProductAttached({ - customer, - product: pro, - }); - - expect(customer.invoices.length).toBe(1); - const totalPrice = getBasePrice({ product: pro }); - expect(customer.invoices[0].total).toBe(totalPrice * 0.5); - }); -}); diff --git a/server/tests/attach/checkout/checkout5.test.ts b/server/tests/attach/checkout/checkout5.test.ts deleted file mode 100644 index 9ff9d6f97..000000000 --- a/server/tests/attach/checkout/checkout5.test.ts +++ /dev/null @@ -1,84 +0,0 @@ -import { beforeAll, describe, expect, test } from "bun:test"; -import { LegacyVersion } from "@autumn/shared"; -import { TestFeature } from "@tests/setup/v2Features.js"; -import { expectFeaturesCorrect } from "@tests/utils/expectUtils/expectFeaturesCorrect.js"; -import { expectProductAttached } from "@tests/utils/expectUtils/expectProductAttached.js"; -import { completeInvoiceCheckoutV2 as completeInvoiceCheckout } from "@tests/utils/browserPool/completeInvoiceCheckoutV2"; -import ctx from "@tests/utils/testInitUtils/createTestContext.js"; -import { getBasePrice } from "@tests/utils/testProductUtils/testProductUtils.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"; - -export const pro = constructProduct({ - items: [ - constructFeatureItem({ - featureId: TestFeature.Messages, - includedUsage: 100, - }), - ], - type: "pro", -}); - -const testCase = "checkout5"; -describe(`${chalk.yellowBright(`${testCase}: Testing invoice checkout, no product till paid`)}`, () => { - const customerId = testCase; - const autumn: AutumnInt = new AutumnInt({ version: LegacyVersion.v1_2 }); - - beforeAll(async () => { - await initCustomerV3({ - ctx, - customerId, - withTestClock: true, - }); - await initProductsV0({ - ctx, - products: [pro], - prefix: testCase, - }); - }); - - test("should attach pro product", async () => { - const res = await autumn.attach({ - customer_id: customerId, - product_id: pro.id, - invoice: true, - }); - - const customer = await autumn.customers.get(customerId); - - const invoice = customer.invoices?.[0]; - - expect(invoice).toBeDefined(); - expect(invoice.total).toBe(getBasePrice({ product: pro })); - expect(invoice.status).toBe("open"); - - const product = customer.products.find((p) => p.id === pro.id); - expect(product).toBeUndefined(); - - await completeInvoiceCheckout({ - url: res.checkout_url, - }); - - const customer2 = await autumn.customers.get(customerId); - - const invoice2 = customer2.invoices?.[0]; - - expect(customer2.invoices.length).toBe(1); - expect(invoice2).toBeDefined(); - expect(invoice2.status).toBe("paid"); - - expectProductAttached({ - customer: customer2, - product: pro, - }); - - expectFeaturesCorrect({ - customer: customer2, - product: pro, - }); - }); -}); diff --git a/server/tests/attach/checkout/checkout6.test.ts b/server/tests/attach/checkout/checkout6.test.ts deleted file mode 100644 index 56efb0288..000000000 --- a/server/tests/attach/checkout/checkout6.test.ts +++ /dev/null @@ -1,120 +0,0 @@ -import { beforeAll, describe, expect, test } from "bun:test"; -import { LegacyVersion } from "@autumn/shared"; -import { TestFeature } from "@tests/setup/v2Features.js"; -import { expectFeaturesCorrect } from "@tests/utils/expectUtils/expectFeaturesCorrect.js"; -import { expectProductAttached } from "@tests/utils/expectUtils/expectProductAttached.js"; -import { completeInvoiceCheckoutV2 as completeInvoiceCheckout } from "@tests/utils/browserPool/completeInvoiceCheckoutV2"; -import ctx from "@tests/utils/testInitUtils/createTestContext.js"; -import { getBasePrice } from "@tests/utils/testProductUtils/testProductUtils.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"; - -const pro = constructProduct({ - items: [ - constructFeatureItem({ - featureId: TestFeature.Messages, - includedUsage: 100, - }), - ], - type: "pro", -}); - -const premium = constructProduct({ - items: [ - constructFeatureItem({ - featureId: TestFeature.Messages, - includedUsage: 250, - }), - ], - type: "premium", -}); - -const testCase = "checkout6"; -describe(`${chalk.yellowBright(`${testCase}: Testing invoice checkout via checkout endpoint`)}`, () => { - const customerId = testCase; - const autumn: AutumnInt = new AutumnInt({ version: LegacyVersion.v1_2 }); - - beforeAll(async () => { - await initCustomerV3({ - ctx, - customerId, - attachPm: "success", - withTestClock: true, - }); - - await initProductsV0({ - ctx, - products: [pro, premium], - prefix: testCase, - }); - }); - - test("should attach pro product via invoice checkout", async () => { - const res = await autumn.checkout({ - customer_id: customerId, - product_id: pro.id, - invoice: true, - }); - - expect(res.url).toBeDefined(); - - await completeInvoiceCheckout({ - url: res.url!, - }); - - const customer = await autumn.customers.get(customerId); - - expectProductAttached({ - customer, - product: pro, - }); - - expectFeaturesCorrect({ - customer, - product: pro, - }); - }); - - // test("should have no URL returned if try to attach premium (with invoice true)", async () => { - // const res = await autumn.attach({ - // customer_id: customerId, - // product_id: premium.id, - // invoice: true, - // }); - - // expect(res.url).toBeUndefined(); - // }); - - test("should attach premium product via invoice enable immediately", async () => { - const res = await autumn.attach({ - customer_id: customerId, - product_id: premium.id, - invoice: true, - enable_product_immediately: true, - finalize_invoice: false, - }); - - const customer = await autumn.customers.get(customerId); - - expectProductAttached({ - customer, - product: premium, - }); - - expectFeaturesCorrect({ - customer, - product: premium, - }); - - const invoices = customer.invoices; - expect(invoices.length).toBe(2); - expect(invoices[0].status).toBe("draft"); - expect(invoices[0].total).toBe( - getBasePrice({ product: premium }) - getBasePrice({ product: pro }), - ); // proration... - }); -}); diff --git a/server/tests/attach/checkout/checkout7.test.ts b/server/tests/attach/checkout/checkout7.test.ts deleted file mode 100644 index 2ed4007e2..000000000 --- a/server/tests/attach/checkout/checkout7.test.ts +++ /dev/null @@ -1,113 +0,0 @@ -import { beforeAll, describe, expect, test } from "bun:test"; -import { LegacyVersion } from "@autumn/shared"; -import { TestFeature } from "@tests/setup/v2Features.js"; -import { expectFeaturesCorrect } from "@tests/utils/expectUtils/expectFeaturesCorrect.js"; -import { expectProductAttached } from "@tests/utils/expectUtils/expectProductAttached.js"; -import { completeInvoiceCheckoutV2 as completeInvoiceCheckout } from "@tests/utils/browserPool/completeInvoiceCheckoutV2"; -import ctx from "@tests/utils/testInitUtils/createTestContext.js"; -import chalk from "chalk"; -import { AutumnInt } from "@/external/autumn/autumnCli.js"; -import { - constructFeatureItem, - constructPrepaidItem, -} from "@/utils/scriptUtils/constructItem.js"; -import { - constructProduct, - constructRawProduct, -} from "@/utils/scriptUtils/createTestProducts.js"; -import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js"; -import { initProductsV0 } from "@/utils/scriptUtils/testUtils/initProductsV0.js"; - -export const pro = constructProduct({ - items: [ - constructFeatureItem({ - featureId: TestFeature.Messages, - includedUsage: 100, - }), - ], - type: "pro", -}); - -export const addOn = constructRawProduct({ - id: "addOn", - items: [ - constructPrepaidItem({ - featureId: TestFeature.Messages, - billingUnits: 100, - price: 10, - isOneOff: true, - }), - ], -}); - -const testCase = "checkout7"; -describe(`${chalk.yellowBright(`${testCase}: Testing invoice checkout with one off product`)}`, () => { - const customerId = testCase; - const autumn: AutumnInt = new AutumnInt({ version: LegacyVersion.v1_2 }); - - beforeAll(async () => { - await initCustomerV3({ - ctx, - customerId, - attachPm: "success", - withTestClock: true, - }); - - await initProductsV0({ - ctx, - products: [pro, addOn], - prefix: testCase, - }); - }); - - test("should attach pro product, then add on product via invoice checkout", async () => { - await autumn.attach({ - customer_id: customerId, - product_id: pro.id, - }); - - const options = [ - { - quantity: 200, - feature_id: TestFeature.Messages, - }, - ]; - - const res2 = await autumn.checkout({ - customer_id: customerId, - product_id: addOn.id, - invoice: true, - options, - }); - - expect(res2.url).toBeDefined(); - - await completeInvoiceCheckout({ - url: res2.url!, - }); - - const customer = await autumn.customers.get(customerId); - - expectProductAttached({ - customer, - product: addOn, - }); - - expectFeaturesCorrect({ - customer, - product: addOn, - otherProducts: [pro], - options, - }); - }); - - // it("should have no URL returned if try to attach add on (with invoice true)", async function () { - // const res = await autumn.checkout({ - // customer_id: customerId, - // product_id: addOn.id, - // invoice: true, - // }); - - // expect(res.url).to.not.exist; - // }); -}); diff --git a/server/tests/attach/checkout/checkout8.test.ts b/server/tests/attach/checkout/checkout8.test.ts deleted file mode 100644 index d9b32e835..000000000 --- a/server/tests/attach/checkout/checkout8.test.ts +++ /dev/null @@ -1,95 +0,0 @@ -import { beforeAll, describe, test } from "bun:test"; -import { ApiVersion } from "@autumn/shared"; -import { AutumnCli } from "@tests/cli/AutumnCli.js"; -import { TestFeature } from "@tests/setup/v2Features.js"; -import { completeStripeCheckoutFormV2 as completeStripeCheckoutForm } from "@tests/utils/browserPool/completeStripeCheckoutFormV2"; -import { expectCustomerV0Correct } from "@tests/utils/expectUtils/expectCustomerV0Correct.js"; -import { timeout } from "@tests/utils/genUtils.js"; -import ctx from "@tests/utils/testInitUtils/createTestContext.js"; -import chalk from "chalk"; -import { AutumnInt } from "@/external/autumn/autumnCli.js"; -import { 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"; - -// Monthly with one-time prepaid product (matches global products.monthlyWithOneTime) -// Has both monthly and one-time prepaid items -const monthlyWithOneTime = constructProduct({ - type: "pro", - items: [ - constructPrepaidItem({ - featureId: TestFeature.Messages, - price: 5, - billingUnits: 100, - includedUsage: 0, - isOneOff: true, - }), - constructPrepaidItem({ - featureId: TestFeature.Words, - price: 10, - billingUnits: 100, - includedUsage: 0, - isOneOff: true, - }), - ], -}); - -const testCase = "checkout8"; -const customerId = testCase; - -describe(`${chalk.yellowBright("checkout8: attach monthly with one time prepaid, and quantity = 0")}`, () => { - const autumnV1 = new AutumnInt({ - secretKey: ctx.orgSecretKey, - version: ApiVersion.V1_2, - }); - - const options = [ - { - feature_id: TestFeature.Messages, - quantity: 0, - }, - { - feature_id: TestFeature.Words, - quantity: 4, - }, - ]; - - beforeAll(async () => { - // Create products FIRST before customer creation - await initProductsV0({ - ctx, - products: [monthlyWithOneTime], - prefix: testCase, - customerId, - }); - - // Then create customer - await initCustomerV3({ - ctx, - customerId, - withTestClock: true, - }); - }); - - test("should attach monthly with one time", async () => { - const res = await autumnV1.attach({ - customer_id: customerId, - product_id: monthlyWithOneTime.id, - options, - }); - - await completeStripeCheckoutForm({ url: res.checkout_url }); - await timeout(12000); - }); - - test("should have correct main product and entitlements", async () => { - const cusRes = await AutumnCli.getCustomer(customerId); - - await expectCustomerV0Correct({ - sent: monthlyWithOneTime, - cusRes, - optionsList: options, - }); - }); -}); diff --git a/server/tests/attach/downgrade/downgrade1.test.ts b/server/tests/attach/downgrade/downgrade1.test.ts deleted file mode 100644 index 8f117cdc9..000000000 --- a/server/tests/attach/downgrade/downgrade1.test.ts +++ /dev/null @@ -1,104 +0,0 @@ -import { beforeAll, describe, test } from "bun:test"; -import { type AppEnv, LegacyVersion, type Organization } from "@autumn/shared"; -import { TestFeature } from "@tests/setup/v2Features.js"; -import { attachAndExpectCorrect } from "@tests/utils/expectUtils/expectAttach.js"; -import { - expectDowngradeCorrect, - expectNextCycleCorrect, -} from "@tests/utils/expectUtils/expectScheduleUtils.js"; -import ctx from "@tests/utils/testInitUtils/createTestContext.js"; -import chalk from "chalk"; -import type Stripe from "stripe"; -import type { DrizzleCli } from "@/db/initDrizzle.js"; -import { AutumnInt } from "@/external/autumn/autumnCli.js"; -import { constructArrearItem } 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"; - -const testCase = "downgrade1"; - -const pro = constructProduct({ - items: [constructArrearItem({ featureId: TestFeature.Words })], - type: "pro", -}); - -const premium = constructProduct({ - items: [constructArrearItem({ featureId: TestFeature.Words })], - type: "premium", -}); - -describe(`${chalk.yellowBright(`${testCase}: Testing downgrade from premium -> pro`)}`, () => { - const customerId = testCase; - const autumn: AutumnInt = new AutumnInt({ version: LegacyVersion.v1_4 }); - let testClockId: string; - let db: DrizzleCli, org: Organization, env: AppEnv; - let stripeCli: Stripe; - - beforeAll(async () => { - db = ctx.db; - org = ctx.org; - env = ctx.env; - stripeCli = ctx.stripeCli; - - const { testClockId: testClockId1 } = await initCustomerV3({ - ctx, - customerId, - customerData: {}, - attachPm: "success", - withTestClock: true, - }); - - await initProductsV0({ - ctx, - products: [pro, premium], - prefix: testCase, - }); - - testClockId = testClockId1!; - }); - - test("should attach premium product", async () => { - await attachAndExpectCorrect({ - autumn, - customerId, - product: premium, - stripeCli, - db, - org, - env, - }); - }); - - // let nextCycle = Date.now(); - let preview = null; - - test("should downgrade to pro", async () => { - const { preview: preview_ } = await expectDowngradeCorrect({ - autumn, - customerId, - curProduct: premium, - newProduct: pro, - stripeCli, - db, - org, - env, - }); - - preview = preview_; - }); - - test("should have pro attached on next cycle", async () => { - await expectNextCycleCorrect({ - preview: preview!, - autumn, - stripeCli, - customerId, - testClockId, - product: pro, - db, - org, - env, - }); - }); -}); diff --git a/server/tests/attach/downgrade/downgrade2.test.ts b/server/tests/attach/downgrade/downgrade2.test.ts deleted file mode 100644 index f9386106c..000000000 --- a/server/tests/attach/downgrade/downgrade2.test.ts +++ /dev/null @@ -1,112 +0,0 @@ -import { beforeAll, describe, test } from "bun:test"; -import { type AppEnv, LegacyVersion, type Organization } from "@autumn/shared"; -import { TestFeature } from "@tests/setup/v2Features.js"; -import { attachAndExpectCorrect } from "@tests/utils/expectUtils/expectAttach.js"; -import { - expectDowngradeCorrect, - expectNextCycleCorrect, -} from "@tests/utils/expectUtils/expectScheduleUtils.js"; -import ctx from "@tests/utils/testInitUtils/createTestContext.js"; -import chalk from "chalk"; -import type Stripe from "stripe"; -import type { DrizzleCli } from "@/db/initDrizzle.js"; -import { AutumnInt } from "@/external/autumn/autumnCli.js"; -import { constructFeatureItem } from "@/internal/products/product-items/productItemUtils.js"; -import { constructArrearItem } 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"; - -const testCase = "downgrade2"; - -const free = constructProduct({ - items: [ - constructFeatureItem({ - feature_id: TestFeature.Words, - included_usage: 100, - }), - ], - type: "free", - isDefault: false, -}); - -const premium = constructProduct({ - items: [constructArrearItem({ featureId: TestFeature.Words })], - type: "premium", -}); - -describe(`${chalk.yellowBright(`${testCase}: Testing downgrade from premium -> free`)}`, () => { - const customerId = testCase; - const autumn: AutumnInt = new AutumnInt({ version: LegacyVersion.v1_4 }); - let testClockId: string; - let db: DrizzleCli, org: Organization, env: AppEnv; - let stripeCli: Stripe; - - const curUnix = new Date().getTime(); - - beforeAll(async () => { - db = ctx.db; - org = ctx.org; - env = ctx.env; - stripeCli = ctx.stripeCli; - - const { testClockId: testClockId1 } = await initCustomerV3({ - ctx, - customerId, - customerData: {}, - attachPm: "success", - withTestClock: true, - }); - - await initProductsV0({ - ctx, - products: [free, premium], - prefix: testCase, - }); - - testClockId = testClockId1!; - }); - - test("should attach premium product", async () => { - await attachAndExpectCorrect({ - autumn, - customerId, - product: premium, - stripeCli, - db, - org, - env, - }); - }); - - // let nextCycle = Date.now(); - let preview = null; - test("should downgrade to free", async () => { - const { preview: preview_ } = await expectDowngradeCorrect({ - autumn, - customerId, - curProduct: premium, - newProduct: free, - stripeCli, - db, - org, - env, - }); - - preview = preview_; - }); - - test("should have pro attached on next cycle", async () => { - await expectNextCycleCorrect({ - preview: preview!, - autumn, - stripeCli, - customerId, - testClockId, - product: free, - db, - org, - env, - }); - }); -}); diff --git a/server/tests/attach/downgrade/downgrade3.test.ts b/server/tests/attach/downgrade/downgrade3.test.ts deleted file mode 100644 index 18c6fa97b..000000000 --- a/server/tests/attach/downgrade/downgrade3.test.ts +++ /dev/null @@ -1,150 +0,0 @@ -import { beforeAll, describe, test } from "bun:test"; -import { - type AppEnv, - type Customer, - LegacyVersion, - type Organization, -} from "@autumn/shared"; -import { TestFeature } from "@tests/setup/v2Features.js"; -import { attachAndExpectCorrect } from "@tests/utils/expectUtils/expectAttach.js"; -import { expectProductAttached } from "@tests/utils/expectUtils/expectProductAttached.js"; -import { expectDowngradeCorrect } from "@tests/utils/expectUtils/expectScheduleUtils.js"; -import ctx from "@tests/utils/testInitUtils/createTestContext.js"; -import chalk from "chalk"; -import type Stripe from "stripe"; -import type { DrizzleCli } from "@/db/initDrizzle.js"; -import { AutumnInt } from "@/external/autumn/autumnCli.js"; -import { constructFeatureItem } from "@/internal/products/product-items/productItemUtils.js"; -import { constructArrearItem } 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"; - -const testCase = "downgrade3"; - -const free = constructProduct({ - items: [ - constructFeatureItem({ - feature_id: TestFeature.Words, - included_usage: 100, - }), - ], - type: "free", - isDefault: false, -}); - -const pro = constructProduct({ - items: [constructArrearItem({ featureId: TestFeature.Words })], - type: "pro", -}); - -const premium = constructProduct({ - items: [constructArrearItem({ featureId: TestFeature.Words })], - type: "premium", -}); - -describe(`${chalk.yellowBright(`${testCase}: Testing downgrade: premium -> pro -> free -> pro -> premium`)}`, () => { - const customerId = testCase; - const autumn: AutumnInt = new AutumnInt({ version: LegacyVersion.v1_4 }); - let customer: Customer; - let testClockId: string; - let db: DrizzleCli, org: Organization, env: AppEnv; - let stripeCli: Stripe; - - const curUnix = new Date().getTime(); - - beforeAll(async () => { - db = ctx.db; - org = ctx.org; - env = ctx.env; - stripeCli = ctx.stripeCli; - - const { testClockId: testClockId1, customer: customer_ } = - await initCustomerV3({ - ctx, - customerId, - customerData: {}, - attachPm: "success", - withTestClock: true, - }); - - await initProductsV0({ - ctx, - products: [free, pro, premium], - prefix: testCase, - }); - - testClockId = testClockId1!; - customer = customer_!; - }); - - test("should attach premium product", async () => { - await attachAndExpectCorrect({ - autumn, - customerId, - product: premium, - stripeCli, - db, - org, - env, - }); - }); - - // let nextCycle = Date.now(); - let preview = null; - test("should downgrade to pro", async () => { - const { preview: preview_ } = await expectDowngradeCorrect({ - autumn, - customerId, - curProduct: premium, - newProduct: pro, - stripeCli, - db, - org, - env, - }); - - preview = preview_; - }); - - test("should downgrade to free", async () => { - const { preview: preview_ } = await expectDowngradeCorrect({ - autumn, - customerId, - curProduct: premium, - newProduct: free, - stripeCli, - db, - org, - env, - }); - - preview = preview_; - }); - - test("should change downgrade to pro", async () => { - const { preview: preview_ } = await expectDowngradeCorrect({ - autumn, - customerId, - curProduct: premium, - newProduct: pro, - stripeCli, - db, - org, - env, - }); - }); - - test("should renew premium", async () => { - await autumn.attach({ - customer_id: customerId, - product_id: premium.id, - }); - - const customer = await autumn.customers.get(customerId); - expectProductAttached({ - customer, - product: premium, - }); - }); -}); diff --git a/server/tests/attach/downgrade/downgrade4.test.ts b/server/tests/attach/downgrade/downgrade4.test.ts deleted file mode 100644 index 7d5dd955e..000000000 --- a/server/tests/attach/downgrade/downgrade4.test.ts +++ /dev/null @@ -1,135 +0,0 @@ -import { beforeAll, describe, test } from "bun:test"; -import { - type AppEnv, - BillingInterval, - LegacyVersion, - type Organization, -} from "@autumn/shared"; -import { TestFeature } from "@tests/setup/v2Features.js"; -import { attachAndExpectCorrect } from "@tests/utils/expectUtils/expectAttach.js"; -import { - expectDowngradeCorrect, - expectNextCycleCorrect, -} from "@tests/utils/expectUtils/expectScheduleUtils.js"; -import { advanceMonths } from "@tests/utils/stripeUtils.js"; -import ctx from "@tests/utils/testInitUtils/createTestContext.js"; -import chalk from "chalk"; -import type Stripe from "stripe"; -import type { DrizzleCli } from "@/db/initDrizzle.js"; -import { AutumnInt } from "@/external/autumn/autumnCli.js"; -import { timeout } from "@/utils/genUtils.js"; -import { constructArrearItem } 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"; - -const testCase = "downgrade4"; - -const proQuarter = constructProduct({ - items: [constructArrearItem({ featureId: TestFeature.Words })], - type: "pro", - interval: BillingInterval.Quarter, -}); - -const pro = constructProduct({ - items: [constructArrearItem({ featureId: TestFeature.Words })], - type: "pro", -}); - -const premium = constructProduct({ - items: [constructArrearItem({ featureId: TestFeature.Words })], - type: "premium", -}); - -describe(`${chalk.yellowBright(`${testCase}: Testing downgrade: pro-quarter -> premium -> pro`)}`, () => { - const customerId = testCase; - const autumn: AutumnInt = new AutumnInt({ version: LegacyVersion.v1_4 }); - let testClockId: string; - let db: DrizzleCli, org: Organization, env: AppEnv; - let stripeCli: Stripe; - - beforeAll(async () => { - db = ctx.db; - org = ctx.org; - env = ctx.env; - stripeCli = ctx.stripeCli; - - const { testClockId: testClockId1 } = await initCustomerV3({ - ctx, - customerId, - customerData: {}, - attachPm: "success", - withTestClock: true, - }); - - await initProductsV0({ - ctx, - products: [proQuarter, pro, premium], - prefix: testCase, - }); - - testClockId = testClockId1!; - }); - - test("should attach pro quarterly product", async () => { - await attachAndExpectCorrect({ - autumn, - customerId, - product: proQuarter, - stripeCli, - db, - org, - env, - }); - }); - - test("should downgrade to premium", async () => { - await expectDowngradeCorrect({ - autumn, - customerId, - curProduct: proQuarter, - newProduct: premium, - stripeCli, - db, - org, - env, - }); - }); - - let preview = null; - - test("should downgrade to pro", async () => { - const { preview: preview_ } = await expectDowngradeCorrect({ - autumn, - customerId, - curProduct: proQuarter, - newProduct: pro, - stripeCli, - db, - org, - env, - }); - - preview = preview_; - }); - - test("should have correct invoice after cycle", async () => { - await advanceMonths({ stripeCli, testClockId, numberOfMonths: 3 }); - - await timeout(10000); - - await expectNextCycleCorrect({ - preview: preview!, - autumn, - stripeCli, - customerId, - testClockId, - product: pro, - db, - org, - env, - }); - }); - - return; -}); diff --git a/server/tests/attach/downgrade/downgrade5.test.ts b/server/tests/attach/downgrade/downgrade5.test.ts deleted file mode 100644 index a8b77797a..000000000 --- a/server/tests/attach/downgrade/downgrade5.test.ts +++ /dev/null @@ -1,153 +0,0 @@ -import { beforeAll, describe, expect, test } from "bun:test"; -import { CusProductStatus, ProductItemInterval } from "@autumn/shared"; -import { AutumnCli } from "@tests/cli/AutumnCli.js"; -import { TestFeature } from "@tests/setup/v2Features.js"; -import { hoursToFinalizeInvoice } from "@tests/utils/constants.js"; -import { expectCustomerV0Correct } from "@tests/utils/expectUtils/expectCustomerV0Correct.js"; -import ctx from "@tests/utils/testInitUtils/createTestContext.js"; -import chalk from "chalk"; -import { addHours, addMonths } from "date-fns"; -import type Stripe from "stripe"; -import { constructFeatureItem } from "@/utils/scriptUtils/constructItem.js"; -import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js"; -import { advanceTestClock } from "@/utils/scriptUtils/testClockUtils.js"; -import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js"; -import { initProductsV0 } from "@/utils/scriptUtils/testUtils/initProductsV0.js"; - -// Inline product definitions for downgrade5 test -const proProduct = constructProduct({ - type: "pro", - - items: [ - constructFeatureItem({ - featureId: TestFeature.Dashboard, - isBoolean: true, - }), - constructFeatureItem({ - featureId: TestFeature.Messages, - includedUsage: 10, - interval: ProductItemInterval.Month, - }), - constructFeatureItem({ - featureId: TestFeature.Admin, - unlimited: true, - }), - ], -}); - -const premiumProduct = constructProduct({ - type: "premium", - - items: [ - constructFeatureItem({ - featureId: TestFeature.Messages, - includedUsage: 100, - interval: ProductItemInterval.Month, - }), - ], -}); - -const testCase = "downgrade5"; -describe(`${chalk.yellowBright(`${testCase}: testing basic downgrade (paid to paid)`)}`, () => { - const customerId = testCase; - let testClockId: string; - let stripeCli: Stripe; - - beforeAll(async () => { - stripeCli = ctx.stripeCli; - - // Initialize products for this test - await initProductsV0({ - ctx, - products: [proProduct, premiumProduct], - prefix: testCase, - customerId, - }); - - const { testClockId: testClockId_ } = await initCustomerV3({ - ctx, - customerId, - customerData: {}, - attachPm: "success", - withTestClock: true, - }); - - testClockId = testClockId_; - }); - - test("should attach premium", async () => { - await AutumnCli.attach({ - customerId: customerId, - productId: premiumProduct.id, - }); - }); - - test("should attach pro", async () => { - await AutumnCli.attach({ - customerId: customerId, - productId: proProduct.id, - }); - }); - - test("should have correct product and entitlements for scheduled pro", async () => { - const res = await AutumnCli.getCustomer(customerId); - - expectCustomerV0Correct({ - sent: premiumProduct, - cusRes: res, - }); - - const { products: resProducts } = res; - - const resPro = resProducts.find( - (p: any) => - p.id === proProduct.id && p.status === CusProductStatus.Scheduled, - ); - - expect(resPro).toBeDefined(); - }); - - test("should attach premium and remove scheduled pro", async () => { - await AutumnCli.attach({ - customerId: customerId, - productId: premiumProduct.id, - }); - - const res = await AutumnCli.getCustomer(customerId); - const resPro = res.products.find( - (p: any) => - p.id === proProduct.id && p.status === CusProductStatus.Scheduled, - ); - - expect(resPro).toBeUndefined(); - - expectCustomerV0Correct({ - sent: premiumProduct, - cusRes: res, - }); - }); - - // Advance time 1 month - test("should attach pro, advance stripe clock and have pro is attached", async () => { - await AutumnCli.attach({ - customerId: customerId, - productId: proProduct.id, - }); - - await advanceTestClock({ - stripeCli, - testClockId, - advanceTo: addHours( - addMonths(new Date(), 1), - hoursToFinalizeInvoice, - ).getTime(), - waitForSeconds: 15, - }); - - const res = await AutumnCli.getCustomer(customerId); - expectCustomerV0Correct({ - sent: proProduct, - cusRes: res, - }); - }); -}); diff --git a/server/tests/attach/downgrade/sharedProducts.ts b/server/tests/attach/downgrade/sharedProducts.ts deleted file mode 100644 index 1fe1a1a37..000000000 --- a/server/tests/attach/downgrade/sharedProducts.ts +++ /dev/null @@ -1,78 +0,0 @@ -// import { BillingInterval, ProductItemInterval } from "@autumn/shared"; -// import { TestFeature } from "@tests/setup/v2Features.js"; -// import ctx from "@tests/utils/testInitUtils/createTestContext.js"; -// import { constructPriceItem } from "@/internal/products/product-items/productItemUtils.js"; -// import { constructFeatureItem } from "@/utils/scriptUtils/constructItem.js"; -// import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js"; -// import { createSharedProducts } from "@/utils/scriptUtils/testUtils/createSharedProduct.js"; - -// /** -// * Shared products for downgrade test group -// * Matches global products.free, products.pro, products.premium -// */ - -// export const sharedFreeProduct = constructProduct({ -// id: "shared-downgrade-free", -// type: "free", -// isDefault: true, -// excludeBase: true, -// items: [ -// constructFeatureItem({ -// featureId: TestFeature.Messages, -// includedUsage: 5, -// interval: ProductItemInterval.Month, -// }), -// ], -// }); - -// export const sharedProProduct = constructProduct({ -// id: "shared-downgrade-pro", -// type: "pro", -// excludeBase: true, -// items: [ -// constructFeatureItem({ -// featureId: TestFeature.Dashboard, -// isBoolean: true, -// }), -// constructFeatureItem({ -// featureId: TestFeature.Messages, -// includedUsage: 10, -// interval: ProductItemInterval.Month, -// }), -// constructFeatureItem({ -// featureId: TestFeature.Admin, -// unlimited: true, -// }), -// constructPriceItem({ -// price: 2000, -// interval: BillingInterval.Month, -// }), -// ], -// }); - -// export const sharedPremiumProduct = constructProduct({ -// id: "shared-downgrade-premium", -// type: "premium", -// excludeBase: true, -// items: [ -// constructFeatureItem({ -// featureId: TestFeature.Messages, -// includedUsage: 100, -// interval: ProductItemInterval.Month, -// }), -// constructPriceItem({ -// price: 5000, -// interval: BillingInterval.Month, -// }), -// ], -// }); - -// export const initDowngradeSharedProducts = async () => { -// await createSharedProducts({ -// ctx, -// products: [sharedFreeProduct, sharedProProduct, sharedPremiumProduct], -// }); -// }; - -// // Auto-init on import (backwards compat) -// await initDowngradeSharedProducts(); diff --git a/server/tests/attach/entities/entity1.test.ts b/server/tests/attach/entities/entity1.test.ts deleted file mode 100644 index 62d8c5435..000000000 --- a/server/tests/attach/entities/entity1.test.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { beforeAll, describe, test } from "bun:test"; -import { LegacyVersion } from "@autumn/shared"; -import { TestFeature } from "@tests/setup/v2Features.js"; -import { attachAndExpectCorrect } from "@tests/utils/expectUtils/expectAttach.js"; -import { expectProductAttached } from "@tests/utils/expectUtils/expectProductAttached.js"; -import ctx from "@tests/utils/testInitUtils/createTestContext.js"; -import chalk from "chalk"; -import { AutumnInt } from "@/external/autumn/autumnCli.js"; -import { constructArrearItem } 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"; - -const testCase = "aentity1"; - -const pro = constructProduct({ - items: [ - constructArrearItem({ - featureId: TestFeature.Words, - includedUsage: 1500, - }), - ], - type: "pro", -}); - -describe(`${chalk.yellowBright(`attach/${testCase}: Testing attach to entity via checkout`)}`, () => { - const customerId = testCase; - const autumn: AutumnInt = new AutumnInt({ version: LegacyVersion.v1_4 }); - - beforeAll(async () => { - await initCustomerV3({ - ctx, - customerId, - withTestClock: true, - }); - - await initProductsV0({ - ctx, - products: [pro], - prefix: testCase, - }); - }); - - const newEntities = [ - { - id: "1", - name: "Entity 1", - feature_id: TestFeature.Users, - }, - ]; - - test("should attach pro product to entity 1", async () => { - await autumn.entities.create(customerId, newEntities); - const entityId = newEntities[0].id; - - await attachAndExpectCorrect({ - autumn, - customerId, - product: pro, - stripeCli: ctx.stripeCli, - db: ctx.db, - org: ctx.org, - env: ctx.env, - entityId, - }); - - const customer = await autumn.customers.get(customerId); - // console.log("customer products:", customer.products); - - expectProductAttached({ - customer, - product: pro, - entityId, - }); - }); -}); diff --git a/server/tests/attach/entities/entity2.test.ts b/server/tests/attach/entities/entity2.test.ts deleted file mode 100644 index 915dbc968..000000000 --- a/server/tests/attach/entities/entity2.test.ts +++ /dev/null @@ -1,138 +0,0 @@ -import { beforeAll, describe, test } from "bun:test"; -import { defaultApiVersion } from "@tests/constants.js"; -import { TestFeature } from "@tests/setup/v2Features.js"; -import { attachAndExpectCorrect } from "@tests/utils/expectUtils/expectAttach.js"; -import { expectFeaturesCorrect } from "@tests/utils/expectUtils/expectFeaturesCorrect.js"; -import { expectInvoiceAfterUsage } from "@tests/utils/expectUtils/expectSingleUse/expectUsageInvoice.js"; -import ctx from "@tests/utils/testInitUtils/createTestContext.js"; -import chalk from "chalk"; -import { AutumnInt } from "@/external/autumn/autumnCli.js"; -import { timeout } from "@/utils/genUtils.js"; -import { constructArrearItem } 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 { advanceToNextInvoice } from "../../utils/testAttachUtils/testAttachUtils"; - -const testCase = "aentity2"; - -export const proAnnual = constructProduct({ - items: [ - constructArrearItem({ - featureId: TestFeature.Words, - includedUsage: 1500, - }), - ], - type: "pro", - isAnnual: true, -}); - -describe(`${chalk.yellowBright(`attach/${testCase}: Testing attach pro annual to entity via checkout`)}`, () => { - const customerId = testCase; - const autumn: AutumnInt = new AutumnInt({ version: defaultApiVersion }); - let testClockId: string; - - let curUnix = Date.now(); - - beforeAll(async () => { - const result = await initCustomerV3({ - ctx, - customerId, - withTestClock: true, - }); - - testClockId = result.testClockId!; - - await initProductsV0({ - ctx, - products: [proAnnual], - prefix: testCase, - }); - }); - - const newEntities = [ - { - id: "1", - name: "Entity 1", - feature_id: TestFeature.Users, - }, - ]; - - let entityId = newEntities[0].id; - test("should attach pro annual product to entity 2", async () => { - await autumn.entities.create(customerId, newEntities); - entityId = newEntities[0].id; - - await attachAndExpectCorrect({ - autumn, - customerId, - product: proAnnual, - stripeCli: ctx.stripeCli, - db: ctx.db, - org: ctx.org, - env: ctx.env, - entityId, - }); - }); - - const usage = 1250130; - test("should track usage", async () => { - await autumn.track({ - customer_id: customerId, - entity_id: entityId, - feature_id: TestFeature.Words, - value: usage, - }); - - const entity = await autumn.entities.get(customerId, entityId); - - expectFeaturesCorrect({ - customer: entity, - product: proAnnual, - usage: [ - { - featureId: TestFeature.Words, - value: usage, - }, - ], - }); - - await timeout(2000); - const nonCachedEntity = await autumn.entities.get(customerId, entityId, { - skip_cache: "true", - }); - - expectFeaturesCorrect({ - customer: nonCachedEntity, - product: proAnnual, - usage: [ - { - featureId: TestFeature.Words, - value: usage, - }, - ], - }); - }); - - test("should have correct invoice after cycle", async () => { - curUnix = await advanceToNextInvoice({ - stripeCli: ctx.stripeCli, - testClockId, - withPause: true, - }); - - await expectInvoiceAfterUsage({ - autumn, - customerId, - entityId, - featureId: TestFeature.Words, - product: proAnnual, - usage, - stripeCli: ctx.stripeCli, - db: ctx.db, - org: ctx.org, - env: ctx.env, - numInvoices: 2, - }); - }); -}); diff --git a/server/tests/attach/entities/entity3.test.ts b/server/tests/attach/entities/entity3.test.ts deleted file mode 100644 index e7f214e57..000000000 --- a/server/tests/attach/entities/entity3.test.ts +++ /dev/null @@ -1,120 +0,0 @@ -import { beforeAll, describe, test } from "bun:test"; -import { defaultApiVersion } from "@tests/constants.js"; -import { TestFeature } from "@tests/setup/v2Features.js"; -import { hoursToFinalizeInvoice } from "@tests/utils/constants.js"; -import { attachAndExpectCorrect } from "@tests/utils/expectUtils/expectAttach.js"; -import { expectInvoiceAfterUsage } from "@tests/utils/expectUtils/expectSingleUse/expectUsageInvoice.js"; -import { advanceTestClock } from "@tests/utils/stripeUtils.js"; -import ctx from "@tests/utils/testInitUtils/createTestContext.js"; -import chalk from "chalk"; -import { addHours, addMonths } from "date-fns"; -import { AutumnInt } from "@/external/autumn/autumnCli.js"; -import { timeout } from "@/utils/genUtils.js"; -import { constructArrearItem } 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"; - -const testCase = "aentity3"; - -export const proAnnual = constructProduct({ - items: [ - constructArrearItem({ - featureId: TestFeature.Words, - includedUsage: 1500, - }), - ], - type: "pro", -}); - -describe(`${chalk.yellowBright(`attach/${testCase}: Attach pro annual to entity and cancel`)}`, () => { - const customerId = testCase; - const autumn: AutumnInt = new AutumnInt({ version: defaultApiVersion }); - let testClockId: string; - - let curUnix = Date.now(); - - beforeAll(async () => { - const result = await initCustomerV3({ - ctx, - customerId, - attachPm: "success", - withTestClock: true, - }); - - testClockId = result.testClockId!; - - await initProductsV0({ - ctx, - products: [proAnnual], - prefix: testCase, - }); - }); - - const newEntities = [ - { - id: "1", - name: "Entity 1", - feature_id: TestFeature.Users, - }, - ]; - - let entityId = newEntities[0].id; - test("should attach pro annual to entity", async () => { - await autumn.entities.create(customerId, newEntities); - entityId = newEntities[0].id; - - await attachAndExpectCorrect({ - autumn, - customerId, - product: proAnnual, - stripeCli: ctx.stripeCli, - db: ctx.db, - org: ctx.org, - env: ctx.env, - entityId, - }); - }); - - const nextUsage = 1032100; - test("should cancel and have correct final invoice", async () => { - await autumn.track({ - customer_id: customerId, - entity_id: entityId, - feature_id: TestFeature.Words, - value: nextUsage, - }); - - await autumn.cancel({ - customer_id: customerId, - product_id: proAnnual.id, - entity_id: entityId, - }); - - await timeout(5000); - - curUnix = await advanceTestClock({ - stripeCli: ctx.stripeCli, - testClockId, - advanceTo: addHours( - addMonths(curUnix, 1), - hoursToFinalizeInvoice, - ).getTime(), - }); - - await expectInvoiceAfterUsage({ - autumn, - customerId, - entityId, - featureId: TestFeature.Words, - product: proAnnual, - usage: nextUsage, - stripeCli: ctx.stripeCli, - db: ctx.db, - org: ctx.org, - env: ctx.env, - numInvoices: 2, - expectExpired: true, - }); - }); -}); diff --git a/server/tests/attach/entities/entity4.test.ts b/server/tests/attach/entities/entity4.test.ts deleted file mode 100644 index 910339f07..000000000 --- a/server/tests/attach/entities/entity4.test.ts +++ /dev/null @@ -1,190 +0,0 @@ -import { beforeAll, describe, test } from "bun:test"; -import { defaultApiVersion } from "@tests/constants.js"; -import { TestFeature } from "@tests/setup/v2Features.js"; -import { attachAndExpectCorrect } from "@tests/utils/expectUtils/expectAttach.js"; -import { expectFeaturesCorrect } from "@tests/utils/expectUtils/expectFeaturesCorrect.js"; -import ctx from "@tests/utils/testInitUtils/createTestContext.js"; -import chalk from "chalk"; -import { AutumnInt } from "@/external/autumn/autumnCli.js"; -import { constructArrearItem } 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 { timeout } from "../../utils/genUtils.js"; - -const testCase = "aentity4"; - -export const pro = constructProduct({ - items: [ - constructArrearItem({ - featureId: TestFeature.Words, - includedUsage: 1500, - }), - ], - type: "pro", -}); - -describe(`${chalk.yellowBright(`attach/${testCase}: Testing attach pro diff entities and testing track / check`)}`, () => { - const customerId = testCase; - const autumn: AutumnInt = new AutumnInt({ version: defaultApiVersion }); - - beforeAll(async () => { - await initCustomerV3({ - ctx, - customerId, - attachPm: "success", - withTestClock: true, - }); - - await initProductsV0({ - ctx, - products: [pro], - prefix: testCase, - }); - }); - - const newEntities = [ - { - id: "1", - name: "Entity 1", - feature_id: TestFeature.Users, - }, - { - id: "2", - name: "Entity 2", - feature_id: TestFeature.Users, - }, - ]; - - const entity1 = newEntities[0]; - const entity2 = newEntities[1]; - - test("should attach pro product to entity 1", async () => { - await autumn.entities.create(customerId, newEntities); - - await attachAndExpectCorrect({ - autumn, - customerId, - product: pro, - stripeCli: ctx.stripeCli, - db: ctx.db, - org: ctx.org, - env: ctx.env, - entityId: entity1.id, - numSubs: 1, - }); - }); - - test("should attach pro product to entity 2", async () => { - await attachAndExpectCorrect({ - autumn, - customerId, - product: pro, - stripeCli: ctx.stripeCli, - db: ctx.db, - org: ctx.org, - env: ctx.env, - entityId: entity2.id, - numSubs: 2, - }); - - // wait for webhooks to clear cache - await timeout(4000); - }); - - const entity1Usage = Math.random() * 1000000; - test("should track usage on entity 1", async () => { - await autumn.track({ - customer_id: customerId, - entity_id: entity1.id, - feature_id: TestFeature.Words, - value: entity1Usage, - }); - - const entity1Res = await autumn.entities.get(customerId, entity1.id); - const entity2Res = await autumn.entities.get(customerId, entity2.id); - - expectFeaturesCorrect({ - customer: entity1Res, - product: pro, - usage: [ - { - featureId: TestFeature.Words, - value: entity1Usage, - }, - ], - }); - - expectFeaturesCorrect({ - customer: entity2Res, - product: pro, - }); - - await timeout(2000); - const entity1ResUncached = await autumn.entities.get( - customerId, - entity1.id, - { - skip_cache: "true", - }, - ); - const entity2ResUncached = await autumn.entities.get( - customerId, - entity2.id, - { - skip_cache: "true", - }, - ); - - expectFeaturesCorrect({ - customer: entity1ResUncached, - product: pro, - usage: [ - { - featureId: TestFeature.Words, - value: entity1Usage, - }, - ], - }); - - expectFeaturesCorrect({ - customer: entity2ResUncached, - product: pro, - }); - }); - - const entity2Usage = Math.random() * 1000000; - test("should track usage on entity 2", async () => { - await autumn.track({ - customer_id: customerId, - entity_id: entity2.id, - feature_id: TestFeature.Words, - value: entity2Usage, - }); - - const entity1Res = await autumn.entities.get(customerId, entity1.id); - const entity2Res = await autumn.entities.get(customerId, entity2.id); - - expectFeaturesCorrect({ - customer: entity1Res, - product: pro, - usage: [ - { - featureId: TestFeature.Words, - value: entity1Usage, - }, - ], - }); - - expectFeaturesCorrect({ - customer: entity2Res, - product: pro, - usage: [ - { - featureId: TestFeature.Words, - value: entity2Usage, - }, - ], - }); - }); -}); diff --git a/server/tests/attach/entities/entity5.test.ts b/server/tests/attach/entities/entity5.test.ts deleted file mode 100644 index 34c52bc26..000000000 --- a/server/tests/attach/entities/entity5.test.ts +++ /dev/null @@ -1,161 +0,0 @@ -import { beforeAll, describe, expect, test } from "bun:test"; -import { CusProductStatus } from "@autumn/shared"; -import { defaultApiVersion } from "@tests/constants.js"; -import { TestFeature } from "@tests/setup/v2Features.js"; -import { hoursToFinalizeInvoice } from "@tests/utils/constants.js"; -import { attachAndExpectCorrect } from "@tests/utils/expectUtils/expectAttach.js"; -import { advanceTestClock } from "@tests/utils/stripeUtils.js"; -import ctx from "@tests/utils/testInitUtils/createTestContext.js"; -import chalk from "chalk"; -import { addHours, addMonths } from "date-fns"; -import { AutumnInt } from "@/external/autumn/autumnCli.js"; -import { constructArrearItem } 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 { - expectProductAttached, - expectScheduledApiSub, -} from "../../utils/expectUtils/expectProductAttached"; - -const testCase = "aentity5"; - -export const pro = constructProduct({ - items: [ - constructArrearItem({ - featureId: TestFeature.Words, - includedUsage: 1500, - }), - ], - type: "pro", -}); -export const premium = constructProduct({ - items: [ - constructArrearItem({ - featureId: TestFeature.Words, - includedUsage: 1500, - }), - ], - type: "premium", -}); - -describe(`${chalk.yellowBright(`attach/${testCase}: Testing downgrade entity product`)}`, () => { - const customerId = testCase; - const autumn: AutumnInt = new AutumnInt({ version: defaultApiVersion }); - let testClockId: string; - - beforeAll(async () => { - const result = await initCustomerV3({ - ctx, - customerId, - attachPm: "success", - withTestClock: true, - }); - - testClockId = result.testClockId!; - - await initProductsV0({ - ctx, - products: [pro, premium], - prefix: testCase, - }); - }); - - const newEntities = [ - { - id: "1", - name: "Entity 1", - feature_id: TestFeature.Users, - }, - { - id: "2", - name: "Entity 2", - feature_id: TestFeature.Users, - }, - ]; - - const entity1 = newEntities[0]; - const entity2 = newEntities[1]; - - test("should attach premium product to entity 1", async () => { - await autumn.entities.create(customerId, newEntities); - - await attachAndExpectCorrect({ - autumn, - customerId, - product: premium, - stripeCli: ctx.stripeCli, - db: ctx.db, - org: ctx.org, - env: ctx.env, - entityId: entity1.id, - numSubs: 1, - }); - }); - - test("should attach premium product to entity 2", async () => { - await attachAndExpectCorrect({ - autumn, - customerId, - product: premium, - stripeCli: ctx.stripeCli, - db: ctx.db, - org: ctx.org, - env: ctx.env, - entityId: entity2.id, - numSubs: 2, - }); - }); - - test("should attach pro product to entity 1", async () => { - await autumn.attach({ - customer_id: customerId, - entity_id: entity1.id, - product_id: pro.id, - }); - - const entity = await autumn.entities.get(customerId, entity1.id); - - expectProductAttached({ - customer: entity, - product: pro, - status: CusProductStatus.Scheduled, - }); - - await expectScheduledApiSub({ - customerId, - entityId: entity1.id, - productId: pro.id, - }); - // const entity = await autumn.entities.get(customerId, entity1.id); - // const proProd = entity.products.find((p: any) => p.id === pro.id); - // expect(proProd).toBeDefined(); - // expect(proProd.status).toBe(CusProductStatus.Scheduled); - }); - - test("should advance test clock and have pro attached to entity 1", async () => { - await advanceTestClock({ - stripeCli: ctx.stripeCli, - testClockId, - advanceTo: addHours( - addMonths(new Date(), 1), - hoursToFinalizeInvoice, - ).getTime(), - waitForSeconds: 30, - }); - - const entity = await autumn.entities.get(customerId, entity1.id); - const proProd = entity.products?.find((p: any) => p.id === pro.id); - expect(proProd).toBeDefined(); - expect(proProd?.status).toBe(CusProductStatus.Active); - expect(entity.products?.length).toBe(1); - - const entity2Res = await autumn.entities.get(customerId, entity2.id); - const premiumProd = entity2Res.products?.find( - (p: any) => p.id === premium.id, - ); - expect(premiumProd).toBeDefined(); - expect(premiumProd?.status).toBe(CusProductStatus.Active); - expect(entity2Res.products?.length).toBe(1); - }); -}); diff --git a/server/tests/attach/entities/entity6.test.ts b/server/tests/attach/entities/entity6.test.ts deleted file mode 100644 index f90c6d92e..000000000 --- a/server/tests/attach/entities/entity6.test.ts +++ /dev/null @@ -1,90 +0,0 @@ -// import { beforeAll, describe, expect } from "bun:test"; -// import { -// ApiVersion, -// CusProductStatus, -// FreeTrialDuration, -// } 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"; -// import { initProductsV0 } from "@/utils/scriptUtils/testUtils/initProductsV0.js"; - -// const pro = constructProduct({ -// type: "pro", -// freeTrial: { -// length: 7, -// duration: FreeTrialDuration.Day, -// unique_fingerprint: true, -// card_required: false, -// }, -// items: [ -// constructFeatureItem({ -// featureId: TestFeature.Messages, -// includedUsage: 300, -// entityFeatureId: TestFeature.Users, -// }), -// ], -// }); - -// const testCase = "entity6"; - -// describe(`${chalk.yellowBright("entity6: Testing two entities with a free trial")}`, () => { -// const customerId = testCase; - -// const autumnV1: AutumnInt = new AutumnInt({ version: ApiVersion.V1_2 }); - -// beforeAll(async () => { -// await initCustomerV3({ -// ctx, -// customerId, -// withTestClock: true, -// attachPm: "success", -// }); - -// await autumnV1.entities.create(customerId, [ -// { -// id: "entity1", -// name: "Entity 1", -// feature_id: TestFeature.Users, -// }, -// { -// id: "entity2", -// name: "Entity 2", -// feature_id: TestFeature.Users, -// }, -// ]); - -// await initProductsV0({ -// ctx, -// products: [pro], -// prefix: testCase, -// }); - -// await autumnV1.attach({ -// customer_id: customerId, -// product_id: pro.id, -// entity_id: "entity1", -// }); - -// const entity1 = await autumnV1.entities.get(customerId, "entity1"); - -// await autumnV1.attach({ -// customer_id: customerId, -// product_id: pro.id, -// entity_id: "entity2", -// }); - -// const entity2 = await autumnV1.entities.get(customerId, "entity2"); - -// expect(entity1.products.length).toBe(1); -// expect(entity2.products.length).toBe(1); -// expect(entity1.products[0].id).toBe(pro.id); -// expect(entity2.products[0].id).toBe(pro.id); -// expect(entity1.products[0].status).toBe(CusProductStatus.Trialing); -// expect(entity2.products[0].status).toBe(CusProductStatus.Trialing); -// }); -// }); diff --git a/server/tests/attach/free/free1.test.ts b/server/tests/attach/free/free1.test.ts deleted file mode 100644 index d2d5c3f5d..000000000 --- a/server/tests/attach/free/free1.test.ts +++ /dev/null @@ -1,157 +0,0 @@ -import { beforeAll, describe, expect, test } from "bun:test"; -import { - type ApiCusProductV3, - type ApiCustomerV3, - CreateFreeTrialSchema, - CusProductStatus, - FreeTrialDuration, - LegacyVersion, -} from "@autumn/shared"; -import { TestFeature } from "@tests/setup/v2Features.js"; -import ctx from "@tests/utils/testInitUtils/createTestContext.js"; -import chalk from "chalk"; -import { addDays } from "date-fns"; -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"; - -const testCase = "free1"; - -const trial1 = CreateFreeTrialSchema.parse({ - length: 7, - duration: FreeTrialDuration.Day, -}); - -export const free = constructProduct({ - items: [ - constructFeatureItem({ - featureId: TestFeature.Messages, - includedUsage: 100, - }), - ], - isDefault: false, - freeTrial: trial1, - type: "free", - id: "enterprise_trial", -}); -export const addOn = constructProduct({ - items: [ - constructFeatureItem({ - featureId: TestFeature.Credits, - includedUsage: 1000, - }), - ], - isDefault: false, - type: "free", - isAddOn: true, - id: "add_on", -}); - -describe(`${chalk.yellowBright(`${testCase}: Testing free product with trial and attaching add on`)}`, () => { - const customerId = testCase; - const autumn: AutumnInt = new AutumnInt({ - version: LegacyVersion.v1_4, - orgConfig: { multiple_trials: true }, - }); - let testClockId: string; - - beforeAll(async () => { - const result = await initCustomerV3({ - ctx, - customerId, - attachPm: "success", - withTestClock: true, - }); - - testClockId = result.testClockId!; - - await initProductsV0({ - ctx, - products: [free, addOn], - prefix: testCase, - }); - }); - - const approximateDiff = 1000 * 60 * 80; // 60 minutes - test("should attach free product with trial", async () => { - const attachPreview = await autumn.attachPreview({ - customer_id: customerId, - product_id: free.id, - }); - - const attach = await autumn.attach({ - customer_id: customerId, - product_id: free.id, - }); - - const customer = await autumn.customers.get(customerId); - const freeProduct = customer.products.find( - (p) => p.id === free.id, - ) as ApiCusProductV3; - - expect(freeProduct).toBeDefined(); - expect(freeProduct?.status).toBe(CusProductStatus.Trialing); - expect(freeProduct?.current_period_end).toBeGreaterThanOrEqual( - addDays(Date.now(), trial1.length).getTime() - approximateDiff, - ); - expect(freeProduct?.current_period_end).toBeLessThanOrEqual( - addDays(Date.now(), trial1.length).getTime() + approximateDiff, - ); - }); - - const trial2 = CreateFreeTrialSchema.parse({ - length: 14, - duration: FreeTrialDuration.Day, - }); - - test("should update free product's trial end date", async () => { - const attachPreview = await autumn.attachPreview({ - customer_id: customerId, - product_id: free.id, - free_trial: trial2, - is_custom: true, - }); - - const attach = await autumn.attach({ - customer_id: customerId, - product_id: free.id, - free_trial: trial2, - is_custom: true, - }); - - const customer = await autumn.customers.get(customerId); - const freeProduct = customer.products.find( - (p) => p.id === free.id, - ) as ApiCusProductV3; - - expect(freeProduct?.status).toBe(CusProductStatus.Trialing); - expect(freeProduct?.current_period_end).toBeGreaterThanOrEqual( - addDays(Date.now(), trial2.length).getTime() - approximateDiff, - ); - expect(freeProduct?.current_period_end).toBeLessThanOrEqual( - addDays(Date.now(), trial2.length).getTime() + approximateDiff, - ); - }); - - test("should attach add on product", async () => { - const attachPreview = await autumn.attachPreview({ - customer_id: customerId, - product_id: addOn.id, - }); - - const attach = await autumn.attach({ - customer_id: customerId, - product_id: addOn.id, - }); - - const customer = (await autumn.customers.get(customerId)) as ApiCustomerV3; - const addOnProduct = customer.products.find((p) => p.id === addOn.id); - const freeProduct = customer.products.find((p) => p.id === free.id); - - expect(addOnProduct).toBeDefined(); - expect(addOnProduct?.status).toBe("active"); - expect(freeProduct?.status).toBe("trialing"); - }); -}); diff --git a/server/tests/attach/multiProduct/multiProduct1.test.ts b/server/tests/attach/multiProduct/multiProduct1.test.ts deleted file mode 100644 index 9c1d4f99d..000000000 --- a/server/tests/attach/multiProduct/multiProduct1.test.ts +++ /dev/null @@ -1,133 +0,0 @@ -import { beforeAll, describe, test } from "bun:test"; -import { AutumnCli } from "@tests/cli/AutumnCli.js"; -import { TestFeature } from "@tests/setup/v2Features.js"; -import { expectCustomerV0Correct } from "@tests/utils/expectUtils/expectCustomerV0Correct.js"; -import ctx from "@tests/utils/testInitUtils/createTestContext.js"; -import chalk from "chalk"; -import { constructArrearItem } 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"; - -/* -FLOW: -1. Attach pro group 1 & pro group 2 at once -> should have both products as main -2. Upgrade pro group 1 -> premium group 1 -3. Upgrade pro group 2 -> premium group 2 -*/ - -const testCase = "multiProduct1"; - -// Group 1 products (use Messages feature) -const proGroup1 = constructProduct({ - id: "proGroup1", - group: `${testCase}-g1`, - type: "pro", - items: [ - constructArrearItem({ - includedUsage: 10, - featureId: TestFeature.Messages, - price: 100, // $1.00 per unit (100 cents per billing unit of 1) - billingUnits: 1, - }), - ], -}); - -const premiumGroup1 = constructProduct({ - id: "premiumGroup1", - group: `${testCase}-g1`, - type: "premium", - items: [ - constructArrearItem({ - includedUsage: 100, - featureId: TestFeature.Messages, - price: 200, // $2.00 per unit (200 cents per billing unit of 1) - billingUnits: 1, - }), - ], -}); - -// Group 2 products (use Words feature) -const proGroup2 = constructProduct({ - id: "proGroup2", - group: `${testCase}-g2`, - type: "pro", - items: [ - constructArrearItem({ - includedUsage: 10, - featureId: TestFeature.Words, - price: 60, // $0.60 per unit (60 cents per billing unit of 1) - billingUnits: 1, - }), - ], -}); - -const premiumGroup2 = constructProduct({ - id: "premiumGroup2", - group: `${testCase}-g2`, - type: "premium", - items: [ - constructArrearItem({ - includedUsage: 10, - featureId: TestFeature.Words, - price: 90, // $0.90 per unit (90 cents per billing unit of 1) - billingUnits: 1, - }), - ], -}); - -describe( - chalk.yellowBright(`${testCase}: Testing multi product attach, and upgrade`), - () => { - const customerId = testCase; - beforeAll(async () => { - await initProductsV0({ - ctx, - products: [proGroup1, proGroup2, premiumGroup1, premiumGroup2], - // prefix: testCase, - customerId, - }); - - await initCustomerV3({ - ctx, - customerId, - customerData: {}, - attachPm: "success", - withTestClock: true, - }); - }); - - test("should attach pro group 1 and pro group 2", async () => { - await AutumnCli.attach({ - customerId: customerId, - productIds: [proGroup1.id, proGroup2.id], - }); - - const cusRes = await AutumnCli.getCustomer(customerId); - await expectCustomerV0Correct({ sent: proGroup1, cusRes }); - await expectCustomerV0Correct({ sent: proGroup2, cusRes }); - }); - - test("should upgrade to premium group 1", async () => { - await AutumnCli.attach({ - customerId: customerId, - productId: premiumGroup1.id, - }); - - // 1. Compare main product - const cusRes = await AutumnCli.getCustomer(customerId); - await expectCustomerV0Correct({ sent: premiumGroup1, cusRes }); - }); - - test("should upgrade to premium group 2", async () => { - await AutumnCli.attach({ - customerId: customerId, - productId: premiumGroup2.id, - }); - - // 1. Compare main product - const cusRes = await AutumnCli.getCustomer(customerId); - await expectCustomerV0Correct({ sent: premiumGroup2, cusRes }); - }); - }, -); diff --git a/server/tests/attach/others/others1.test.ts b/server/tests/attach/others/others1.test.ts deleted file mode 100644 index 80bfdabf5..000000000 --- a/server/tests/attach/others/others1.test.ts +++ /dev/null @@ -1,108 +0,0 @@ -import { beforeAll, describe, test } from "bun:test"; -import { LegacyVersion } from "@autumn/shared"; -import { attachAndExpectCorrect } from "@tests/utils/expectUtils/expectAttach.js"; -import { - expectDowngradeCorrect, - expectNextCycleCorrect, -} from "@tests/utils/expectUtils/expectScheduleUtils.js"; -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 { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js"; -import { initProductsV0 } from "@/utils/scriptUtils/testUtils/initProductsV0.js"; - -const testCase = "others1"; - -export const free = constructProduct({ - items: [], - type: "free", - isDefault: false, -}); - -export const pro = constructProduct({ - items: [], - type: "pro", - trial: true, -}); - -export const premium = constructProduct({ - items: [], - type: "premium", - trial: true, -}); - -describe(`${chalk.yellowBright(`${testCase}: Testing trials: pro with trial -> premium with trial -> free`)}`, () => { - const customerId = testCase; - const autumn: AutumnInt = new AutumnInt({ version: LegacyVersion.v1_4 }); - let testClockId: string; - - const curUnix = new Date().getTime(); - - beforeAll(async () => { - await initProductsV0({ - ctx, - products: [free, pro, premium], - prefix: testCase, - customerId, - }); - - const { testClockId: testClockId1 } = await initCustomerV3({ - ctx, - customerId, - customerData: {}, - attachPm: "success", - withTestClock: true, - }); - - testClockId = testClockId1!; - }); - - test("should attach pro product (with trial)", async () => { - await attachAndExpectCorrect({ - autumn, - stripeCli: ctx.stripeCli, - customerId, - product: pro, - db: ctx.db, - org: ctx.org, - env: ctx.env, - }); - }); - - test("should attach premium product (with trial)", async () => { - await attachAndExpectCorrect({ - autumn, - stripeCli: ctx.stripeCli, - customerId, - product: premium, - db: ctx.db, - org: ctx.org, - env: ctx.env, - }); - }); - - test("should attach free product at the end of the trial", async () => { - const { preview } = await expectDowngradeCorrect({ - autumn, - stripeCli: ctx.stripeCli, - customerId, - curProduct: premium, - newProduct: free, - db: ctx.db, - org: ctx.org, - env: ctx.env, - }); - expectNextCycleCorrect({ - autumn, - preview, - stripeCli: ctx.stripeCli, - customerId, - testClockId, - product: free, - db: ctx.db, - org: ctx.org, - env: ctx.env, - }); - }); -}); diff --git a/server/tests/attach/others/others10.test.ts b/server/tests/attach/others/others10.test.ts deleted file mode 100644 index 5d8466c26..000000000 --- a/server/tests/attach/others/others10.test.ts +++ /dev/null @@ -1,94 +0,0 @@ -import { beforeAll, describe, expect, test } from "bun:test"; -import { ApiVersion, ErrCode } from "@autumn/shared"; -import { TestFeature } from "@tests/setup/v2Features.js"; -import ctx from "@tests/utils/testInitUtils/createTestContext.js"; -import chalk from "chalk"; -import AutumnError, { AutumnInt } from "@/external/autumn/autumnCli.js"; -import { generateId } from "@/utils/genUtils"; -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"; - -const pro = constructProduct({ - type: "pro", - items: [ - constructFeatureItem({ - featureId: TestFeature.Credits, - includedUsage: 500, - }), - ], -}); - -const testCase = "others10"; - -describe(`${chalk.yellowBright(`${testCase}/idempotency: idempotency key already exists`)}`, () => { - const customerId = testCase; - const autumnV1: AutumnInt = new AutumnInt({ version: ApiVersion.V1_2 }); - - let results: PromiseSettledResult< - Awaited> - >[]; - - beforeAll(async () => { - await initCustomerV3({ - ctx, - customerId, - withTestClock: true, - attachPm: "success", - }); - - await initProductsV0({ - ctx, - products: [pro], - prefix: testCase, - }); - - const idempotencyKey = generateId("it"); - - results = await Promise.allSettled([ - autumnV1.attach( - { - customer_id: customerId, - product_id: pro.id, - }, - { - idempotencyKey, - }, - ), - autumnV1.attach( - { - customer_id: customerId, - product_id: pro.id, - }, - { - idempotencyKey, - }, - ), - ]); - }); - - test("should reject duplicate idempotency key with 409", async () => { - // Exactly one request should succeed - const fulfilled = results.filter((r) => r.status === "fulfilled"); - const rejected = results.filter((r) => r.status === "rejected"); - - expect(fulfilled).toHaveLength(1); - expect(rejected).toHaveLength(1); - - // The successful request should have attached the product - const successResult = fulfilled[0] as PromiseFulfilledResult< - Awaited> - >; - expect(successResult.value.success).toBe(true); - expect(successResult.value.customer_id).toBe(customerId); - expect(successResult.value.product_ids).toContain(pro.id); - - // The rejected request should have the duplicate idempotency key error - const rejectedResult = rejected[0] as PromiseRejectedResult; - expect(rejectedResult.reason).toBeInstanceOf(AutumnError); - expect((rejectedResult.reason as AutumnError).code).toBe( - ErrCode.DuplicateIdempotencyKey, - ); - }); -}); diff --git a/server/tests/attach/others/others3.test.ts b/server/tests/attach/others/others3.test.ts deleted file mode 100644 index 220da93ae..000000000 --- a/server/tests/attach/others/others3.test.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { beforeAll, describe, expect, test } from "bun:test"; -import { type AppEnv, LegacyVersion, type Organization } from "@autumn/shared"; -import ctx from "@tests/utils/testInitUtils/createTestContext.js"; -import chalk from "chalk"; -import type Stripe from "stripe"; -import type { DrizzleCli } from "@/db/initDrizzle.js"; -import { AutumnInt } from "@/external/autumn/autumnCli.js"; -import { attachFailedPaymentMethod } from "@/external/stripe/stripeCusUtils.js"; -import { CusService } from "@/internal/customers/CusService.js"; -import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js"; -import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js"; -import { initProductsV0 } from "@/utils/scriptUtils/testUtils/initProductsV0.js"; - -const testCase = "others3"; - -export const pro = constructProduct({ - type: "pro", - items: [], -}); - -describe(`${chalk.yellowBright(`${testCase}: Testing attach payment failure`)}`, () => { - const customerId = testCase; - const autumn: AutumnInt = new AutumnInt({ version: LegacyVersion.v1_4 }); - let testClockId: string; - - const curUnix = new Date().getTime(); - - beforeAll(async () => { - await initProductsV0({ - ctx, - products: [pro], - prefix: testCase, - }); - - const { testClockId: testClockId1 } = await initCustomerV3({ - ctx, - customerId, - customerData: {}, - attachPm: "success", - withTestClock: true, - }); - - testClockId = testClockId1!; - }); - - // Payment failure - test("should handle payment failure", async () => { - const customer = await CusService.get({ - db: ctx.db, - idOrInternalId: customerId, - orgId: ctx.org.id, - env: ctx.env, - }); - - await attachFailedPaymentMethod({ - stripeCli: ctx.stripeCli, - customer: customer!, - }); - - const res = await autumn.attach({ - customer_id: customerId, - product_id: pro.id, - }); - - // console.log(res); - - expect(res.checkout_url).toBeDefined(); - }); -}); diff --git a/server/tests/attach/others/others6.test.ts b/server/tests/attach/others/others6.test.ts deleted file mode 100644 index 76df58341..000000000 --- a/server/tests/attach/others/others6.test.ts +++ /dev/null @@ -1,122 +0,0 @@ -import { beforeAll, describe, expect, test } from "bun:test"; -import { LegacyVersion } from "@autumn/shared"; -import { TestFeature } from "@tests/setup/v2Features.js"; -import { expectAttachCorrect } from "@tests/utils/expectUtils/expectAttach.js"; -import ctx from "@tests/utils/testInitUtils/createTestContext.js"; -import chalk from "chalk"; -import { AutumnInt } from "@/external/autumn/autumnCli.js"; -import { CusService } from "@/internal/customers/CusService.js"; -import { constructArrearItem } from "@/utils/scriptUtils/constructItem.js"; -import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js"; -import { initProductsV0 } from "@/utils/scriptUtils/testUtils/initProductsV0.js"; -import { timeout } from "../../utils/genUtils"; - -export const pro = constructProduct({ - items: [constructArrearItem({ featureId: TestFeature.Words })], - type: "pro", -}); - -const testCase = "others6"; - -describe(`${chalk.yellowBright(`${testCase}: Testing attach with customer ID and entity ID null`)}`, () => { - const customerId = testCase; - const autumn: AutumnInt = new AutumnInt({ version: LegacyVersion.v1_4 }); - let testClockId: string; - - const email = `${customerId}@test.com`; - beforeAll(async () => { - const customer = await CusService.getByEmail({ - db: ctx.db, - orgId: ctx.org.id, - env: ctx.env, - email, - }); - - if (customer.length > 0) { - await autumn.customers.delete(customer[0].internal_id); - } - - await initProductsV0({ - ctx, - products: [pro], - prefix: testCase, - }); - }); - - let internalCustomerId = ""; - let internalEntityId = ""; - const entityId = "1"; - test("should attach create customer with no ID", async () => { - const customer = await autumn.customers.create({ - id: null, - email: `${customerId}@test.com`, - name: customerId, - withAutumnId: true, - }); - - expect(customer.autumn_id).toBeDefined(); - - internalCustomerId = customer.autumn_id; - - const data = await autumn.entities.create(internalCustomerId, { - id: null, - feature_id: TestFeature.Users, - }); - - internalEntityId = data.autumn_id; - - expect(internalEntityId).toBeDefined(); - }); - - test("should be able to attach pro product, invoice only", async () => { - await autumn.attach({ - customer_id: internalCustomerId, - entity_id: internalEntityId, - product_id: pro.id, - invoice: true, - enable_product_immediately: true, - }); - - const customer = await autumn.customers.get(internalCustomerId); - - expectAttachCorrect({ - customer, - product: pro, - }); - - expect(customer.invoices!.length).toBe(1); - expect(customer.invoices![0].status).toBe("draft"); - }); - - test("should create customer with ID, and attach pro product", async () => { - const customer = await autumn.customers.create({ - id: customerId, - email: `${customerId}@test.com`, - }); - - expect(customer.autumn_id).toBe(internalCustomerId); - - const entity = await autumn.entities.create(customer.id, { - id: entityId, - feature_id: TestFeature.Users, - }); - - internalEntityId = entity.autumn_id; - - await timeout(2000); - - const customer2 = await autumn.customers.get(customerId); - - expectAttachCorrect({ - customer: customer2, - product: pro, - }); - - const entity2 = await autumn.entities.get(customerId, entityId); - - expectAttachCorrect({ - customer: entity2, - product: pro, - }); - }); -}); diff --git a/server/tests/attach/others/others7.test.ts b/server/tests/attach/others/others7.test.ts deleted file mode 100644 index 404dca297..000000000 --- a/server/tests/attach/others/others7.test.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { beforeAll, describe, expect, test } from "bun:test"; -import { type AppEnv, LegacyVersion, type Organization } from "@autumn/shared"; -import { TestFeature } from "@tests/setup/v2Features.js"; -import { expectAttachCorrect } from "@tests/utils/expectUtils/expectAttach.js"; -import ctx from "@tests/utils/testInitUtils/createTestContext.js"; -import { getBasePrice } from "@tests/utils/testProductUtils/testProductUtils.js"; -import chalk from "chalk"; -import type Stripe from "stripe"; -import type { DrizzleCli } from "@/db/initDrizzle.js"; -import { AutumnInt } from "@/external/autumn/autumnCli.js"; -import { constructArrearItem } 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"; - -export const pro = constructProduct({ - items: [constructArrearItem({ featureId: TestFeature.Words })], - type: "pro", - trial: true, -}); - -const testCase = "others7"; - -describe(`${chalk.yellowBright(`${testCase}: Testing attach with free_trial=False`)}`, () => { - const customerId = testCase; - const autumn: AutumnInt = new AutumnInt({ version: LegacyVersion.v1_4 }); - - beforeAll(async () => { - await initCustomerV3({ - ctx, - customerId, - customerData: { fingerprint: "test" }, - attachPm: "success", - withTestClock: true, - }); - - await initProductsV0({ - ctx, - products: [pro], - prefix: testCase, - }); - }); - - test("should attach pro product with free_trial=False", async () => { - await autumn.attach({ - customer_id: customerId, - product_id: pro.id, - free_trial: false, - }); - - const customer = await autumn.customers.get(customerId); - - expectAttachCorrect({ - customer, - product: pro, - }); - - expect(customer.invoices.length).toBe(1); - expect(customer.invoices[0].total).toBe(getBasePrice({ product: pro })); - }); -}); diff --git a/server/tests/attach/others/others8.test.ts b/server/tests/attach/others/others8.test.ts deleted file mode 100644 index 34edde01a..000000000 --- a/server/tests/attach/others/others8.test.ts +++ /dev/null @@ -1,86 +0,0 @@ -import { beforeAll, describe, expect, test } from "bun:test"; -import { type AppEnv, LegacyVersion, type Organization } from "@autumn/shared"; -import { TestFeature } from "@tests/setup/v2Features.js"; -import ctx from "@tests/utils/testInitUtils/createTestContext.js"; -import { getBasePrice } from "@tests/utils/testProductUtils/testProductUtils.js"; -import chalk from "chalk"; -import type Stripe from "stripe"; -import type { DrizzleCli } from "@/db/initDrizzle.js"; -import { AutumnInt } from "@/external/autumn/autumnCli.js"; -import { - 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"; - -export const pro = constructProduct({ - items: [ - constructFeatureItem({ - featureId: TestFeature.Words, - }), - constructPrepaidItem({ - isOneOff: true, - featureId: TestFeature.Users, - billingUnits: 1, - price: 100, - }), - ], - isAnnual: true, - type: "pro", -}); - -const testCase = "others8"; - -describe(`${chalk.yellowBright(`${testCase}: Testing annual pro with one off prepaid`)}`, () => { - const customerId = testCase; - const autumn: AutumnInt = new AutumnInt({ version: LegacyVersion.v1_4 }); - - beforeAll(async () => { - await initCustomerV3({ - ctx, - customerId, - customerData: { fingerprint: "test" }, - attachPm: "success", - withTestClock: true, - }); - - await initProductsV0({ - ctx, - products: [pro], - prefix: testCase, - }); - }); - - test("should attach annual pro product with one off prepaid", async () => { - const options = [ - { - feature_id: TestFeature.Users, - quantity: 1, - }, - ]; - - const preview = await autumn.attachPreview({ - customer_id: customerId, - product_id: pro.id, - options, - }); - - await autumn.attach({ - customer_id: customerId, - product_id: pro.id, - options, - }); - - console.log(preview); - - const customer = await autumn.customers.get(customerId); - - const invoice = customer.invoices[0]; - // expect(preview.total).toBe(invoice.total); - expect(invoice.total).toBe( - getBasePrice({ product: pro }) + options[0].quantity * 100, - ); - }); -}); diff --git a/server/tests/attach/others/others9.test.ts b/server/tests/attach/others/others9.test.ts deleted file mode 100644 index fe876b234..000000000 --- a/server/tests/attach/others/others9.test.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { beforeAll, describe, expect, test } from "bun:test"; -import { type AppEnv, LegacyVersion, type Organization } from "@autumn/shared"; -import { TestFeature } from "@tests/setup/v2Features.js"; -import { attachAndExpectCorrect } from "@tests/utils/expectUtils/expectAttach.js"; -import { expectAutumnError } from "@tests/utils/expectUtils/expectErrUtils.js"; -import ctx from "@tests/utils/testInitUtils/createTestContext.js"; -import chalk from "chalk"; -import type Stripe from "stripe"; -import type { DrizzleCli } from "@/db/initDrizzle.js"; -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"; - -export const free = constructProduct({ - items: [ - constructFeatureItem({ - featureId: TestFeature.Words, - }), - ], - isAnnual: false, - type: "free", - isDefault: false, -}); - -// Pro trial - -// Pro - -const testCase = "others9"; - -describe(`${chalk.yellowBright(`${testCase}: Testing attach free product again`)}`, () => { - const customerId = testCase; - const autumn: AutumnInt = new AutumnInt({ version: LegacyVersion.v1_4 }); - - beforeAll(async () => { - await initCustomerV3({ - ctx, - customerId, - customerData: { fingerprint: "test" }, - attachPm: "success", - withTestClock: true, - }); - - await initProductsV0({ - ctx, - products: [free], - prefix: testCase, - }); - }); - - test("should attach free product, then try again and hit error", async () => { - await attachAndExpectCorrect({ - autumn, - customerId, - product: free, - stripeCli: ctx.stripeCli, - db: ctx.db, - org: ctx.org, - env: ctx.env, - skipSubCheck: true, - }); - - await expectAutumnError({ - func: async () => { - await autumn.attach({ - customer_id: customerId, - product_id: free.id, - }); - }, - }); - }); -}); diff --git a/server/tests/attach/response/attach-response1.test.ts b/server/tests/attach/response/attach-response1.test.ts deleted file mode 100644 index 54e0cc7b9..000000000 --- a/server/tests/attach/response/attach-response1.test.ts +++ /dev/null @@ -1,62 +0,0 @@ -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 { constructArrearItem } from "@/utils/scriptUtils/constructItem.js"; -import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js"; -import { initProductsV0 } from "@/utils/scriptUtils/testUtils/initProductsV0.js"; - -const testCase = "attach-response1"; - -const pro = constructProduct({ - items: [ - constructArrearItem({ - featureId: TestFeature.Words, - includedUsage: 1000, - }), - ], - type: "pro", -}); - -describe(`${chalk.yellowBright(`${testCase}: Testing v0.2 response for attach, scenario: new`)}`, () => { - const customerId = testCase; - const autumn: AutumnInt = new AutumnInt({ version: ApiVersion.V0_2 }); - - beforeAll(async () => { - await initProductsV0({ - ctx, - products: [pro], - prefix: testCase, - customerId, - }); - }); - - test("should return v0.2 responses for attach", async () => { - const attachResponse = await autumn.attach({ - customer_id: customerId, - product_id: pro.id, - }); - - expect(attachResponse.checkout_url).toBeDefined(); - expect(Object.keys(attachResponse)).toEqual(["checkout_url"]); - }); - - test("should return correct v1.2 responses for attach", async () => { - const autumnV1 = new AutumnInt({ version: ApiVersion.V1_2 }); - const attachResponse = await autumnV1.attach({ - customer_id: customerId, - product_id: pro.id, - }); - - expect(attachResponse).toMatchObject({ - customer_id: customerId, - product_ids: [pro.id], - checkout_url: expect.any(String), - }); - expect(attachResponse.code).toBeDefined(); - expect(attachResponse.message).toBeDefined(); - expect(attachResponse.message).toBeDefined(); - }); -}); diff --git a/server/tests/attach/response/attach-response2.test.ts b/server/tests/attach/response/attach-response2.test.ts deleted file mode 100644 index d57e7cb55..000000000 --- a/server/tests/attach/response/attach-response2.test.ts +++ /dev/null @@ -1,98 +0,0 @@ -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 { initProductsV0 } from "@/utils/scriptUtils/testUtils/initProductsV0.js"; -import { initCustomerV3 } from "../../../src/utils/scriptUtils/testUtils/initCustomerV3"; - -const testCase = "attach-response2"; - -const pro = constructProduct({ - type: "pro", - isDefault: false, - items: [ - constructFeatureItem({ - featureId: TestFeature.Words, - includedUsage: 1000, - }), - ], -}); - -const premium = constructProduct({ - type: "premium", - items: [ - constructFeatureItem({ - featureId: TestFeature.Words, - includedUsage: 1000, - }), - ], -}); - -describe(`${chalk.yellowBright(`${testCase}: Testing v0.2 / v1.2 response for attach, scenario: upgrade`)}`, () => { - const customerId = testCase; - const autumn: AutumnInt = new AutumnInt({ version: ApiVersion.V0_2 }); - - beforeAll(async () => { - await initCustomerV3({ - ctx, - customerId, - customerData: {}, - attachPm: "success", - withTestClock: true, - }); - - await initProductsV0({ - ctx, - products: [pro, premium], - prefix: testCase, - }); - - await autumn.attach({ - customer_id: customerId, - product_id: pro.id, - }); - }); - - test("should return v0.2 responses for attach", async () => { - const attachResponse = await autumn.attach({ - customer_id: customerId, - product_id: premium.id, - }); - - expect(Object.keys(attachResponse)).toEqual(["success", "message"]); - }); - - test("should return correct v1.2 responses for attach", async () => { - const autumnV1 = new AutumnInt({ version: ApiVersion.V1_2 }); - - await autumnV1.cancel({ - customer_id: customerId, - product_id: premium.id, - cancel_immediately: true, - }); - - await autumnV1.attach({ - customer_id: customerId, - product_id: pro.id, - }); - - const attachResponse = await autumnV1.attach({ - customer_id: customerId, - product_id: premium.id, - }); - - expect(attachResponse).toMatchObject({ - customer_id: customerId, - product_ids: [premium.id], - code: expect.any(String), - message: expect.any(String), - }); - // expect(attachResponse.code).toBeDefined(); - // expect(attachResponse.message).toBeDefined(); - // expect(attachResponse.message).toBeDefined(); - }); -}); diff --git a/server/tests/attach/response/attach-response3.test.ts b/server/tests/attach/response/attach-response3.test.ts deleted file mode 100644 index c4b49bfb2..000000000 --- a/server/tests/attach/response/attach-response3.test.ts +++ /dev/null @@ -1,90 +0,0 @@ -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 { initProductsV0 } from "@/utils/scriptUtils/testUtils/initProductsV0.js"; -import { initCustomerV3 } from "../../../src/utils/scriptUtils/testUtils/initCustomerV3"; - -const testCase = "attach-response3"; - -const pro = constructProduct({ - type: "pro", - isDefault: false, - items: [ - constructFeatureItem({ - featureId: TestFeature.Words, - includedUsage: 1000, - }), - ], -}); - -const premium = constructProduct({ - type: "premium", - items: [ - constructFeatureItem({ - featureId: TestFeature.Words, - includedUsage: 1000, - }), - ], -}); - -describe(`${chalk.yellowBright(`${testCase}: Testing v0.2 / v1.2 response for attach, scenario: downgrade`)}`, () => { - const customerId = testCase; - const autumn: AutumnInt = new AutumnInt({ version: ApiVersion.V0_2 }); - - beforeAll(async () => { - await initCustomerV3({ - ctx, - customerId, - customerData: {}, - attachPm: "success", - withTestClock: true, - }); - - await initProductsV0({ - ctx, - products: [pro, premium], - prefix: testCase, - }); - - await autumn.attach({ - customer_id: customerId, - product_id: premium.id, - }); - }); - - test("should return v0.2 responses for attach", async () => { - const attachResponse = await autumn.attach({ - customer_id: customerId, - product_id: pro.id, - }); - - expect(Object.keys(attachResponse)).toEqual(["success", "message"]); - }); - - test("should return correct v1.2 responses for attach", async () => { - const autumnV1 = new AutumnInt({ version: ApiVersion.V1_2 }); - - await autumnV1.subscriptions.update({ - customer_id: customerId, - product_id: premium.id, - cancel_action: "uncancel", - }); - - const attachResponse = await autumnV1.attach({ - customer_id: customerId, - product_id: pro.id, - }); - - expect(attachResponse).toMatchObject({ - customer_id: customerId, - product_ids: [pro.id], - code: expect.any(String), - message: expect.any(String), - }); - }); -}); diff --git a/server/tests/attach/response/attach-response4.test.ts b/server/tests/attach/response/attach-response4.test.ts deleted file mode 100644 index c3991fc5e..000000000 --- a/server/tests/attach/response/attach-response4.test.ts +++ /dev/null @@ -1,75 +0,0 @@ -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 { initProductsV0 } from "@/utils/scriptUtils/testUtils/initProductsV0.js"; -import { initCustomerV3 } from "../../../src/utils/scriptUtils/testUtils/initCustomerV3"; - -const testCase = "attach-response4"; - -const pro = constructProduct({ - type: "pro", - isDefault: false, - items: [ - constructFeatureItem({ - featureId: TestFeature.Words, - includedUsage: 1000, - }), - ], -}); - -describe(`${chalk.yellowBright(`${testCase}: Testing v0.2 / v1.2 response for attach, scenario: new (card on file)`)}`, () => { - const customerId = testCase; - const autumn: AutumnInt = new AutumnInt({ version: ApiVersion.V0_2 }); - - beforeAll(async () => { - await initCustomerV3({ - ctx, - customerId, - customerData: {}, - attachPm: "success", - withTestClock: true, - }); - - await initProductsV0({ - ctx, - products: [pro], - prefix: testCase, - }); - }); - - test("should return v0.2 responses for attach", async () => { - const attachResponse = await autumn.attach({ - customer_id: customerId, - product_id: pro.id, - }); - - expect(Object.keys(attachResponse)).toEqual(["success", "message"]); - }); - - test("should return correct v1.2 responses for attach", async () => { - const autumnV1 = new AutumnInt({ version: ApiVersion.V1_2 }); - - await autumnV1.cancel({ - customer_id: customerId, - product_id: pro.id, - cancel_immediately: true, - }); - - const attachResponse = await autumnV1.attach({ - customer_id: customerId, - product_id: pro.id, - }); - - expect(attachResponse).toMatchObject({ - customer_id: customerId, - product_ids: [pro.id], - code: expect.any(String), - message: expect.any(String), - }); - }); -}); diff --git a/server/tests/attach/response/attach-response5.test.ts b/server/tests/attach/response/attach-response5.test.ts deleted file mode 100644 index 57bd2f280..000000000 --- a/server/tests/attach/response/attach-response5.test.ts +++ /dev/null @@ -1,77 +0,0 @@ -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 { initProductsV0 } from "@/utils/scriptUtils/testUtils/initProductsV0.js"; -import { initCustomerV3 } from "../../../src/utils/scriptUtils/testUtils/initCustomerV3"; - -const testCase = "attach-response5"; - -const oneOff = constructProduct({ - type: "one_off", - isDefault: false, - items: [ - constructFeatureItem({ - featureId: TestFeature.Words, - includedUsage: 1000, - interval: null, - }), - ], -}); - -describe(`${chalk.yellowBright(`${testCase}: Testing v0.2 / v1.2 response for attach, scenario: one off (card on file)`)}`, () => { - const customerId = testCase; - const autumn: AutumnInt = new AutumnInt({ version: ApiVersion.V0_2 }); - - beforeAll(async () => { - await initCustomerV3({ - ctx, - customerId, - customerData: {}, - attachPm: "success", - withTestClock: true, - }); - - await initProductsV0({ - ctx, - products: [oneOff], - prefix: testCase, - }); - }); - - test("should return v0.2 responses for attach", async () => { - const attachResponse = await autumn.attach({ - customer_id: customerId, - product_id: oneOff.id, - }); - - expect(attachResponse).toMatchObject({ - // customer_id: customerId, - // product_ids: [oneOff.id], - // code: expect.any(String), - success: true, - message: expect.any(String), - }); - }); - - test("should return correct v1.2 responses for attach", async () => { - const autumnV1 = new AutumnInt({ version: ApiVersion.V1_2 }); - - const attachResponse = await autumnV1.attach({ - customer_id: customerId, - product_id: oneOff.id, - }); - - expect(attachResponse).toMatchObject({ - success: true, - customer_id: customerId, - product_ids: [oneOff.id], - code: expect.any(String), - message: expect.any(String), - }); - }); -}); diff --git a/server/tests/attach/updateEnts/expectUpdateEnts.ts b/server/tests/attach/updateEnts/expectUpdateEnts.ts deleted file mode 100644 index f337eac6f..000000000 --- a/server/tests/attach/updateEnts/expectUpdateEnts.ts +++ /dev/null @@ -1,130 +0,0 @@ -import { expect } from "bun:test"; -import { - type AppEnv, - AttachBranch, - type Organization, - type ProductItem, - type ProductV2, -} from "@autumn/shared"; -import { expectSubToBeCorrect } from "@tests/merged/mergeUtils/expectSubCorrect.js"; -import { expectFeaturesCorrect } from "@tests/utils/expectUtils/expectFeaturesCorrect.js"; -import { - expectSubItemsCorrect, - getSubsFromCusId, -} from "@tests/utils/expectUtils/expectSubUtils.js"; -import type Stripe from "stripe"; -import type { DrizzleCli } from "@/db/initDrizzle.js"; -import type { AutumnInt } from "@/external/autumn/autumnCli.js"; - -const runUpdateEntsTest = async ({ - autumn, - stripeCli, - customerId, - customProduct, - newVersion, - db, - org, - env, - customItems, - usage, -}: { - autumn: AutumnInt; - stripeCli: Stripe; - customerId: string; - customProduct: ProductV2; - newVersion?: number; - db: DrizzleCli; - org: Organization; - env: AppEnv; - customItems?: ProductItem[]; - usage?: { - featureId: string; - value: number; - }[]; -}) => { - // 1. Get subs before - - const { subs: subsBefore } = await getSubsFromCusId({ - stripeCli, - customerId, - productId: customProduct.id, - db, - org, - env, - }); - - const preview = await autumn.attachPreview({ - customer_id: customerId, - product_id: customProduct.id, - version: newVersion, - is_custom: customItems ? true : undefined, - items: customItems, - }); - - if (newVersion) { - expect(preview.branch).toBe(AttachBranch.NewVersion); - } else { - expect(preview.branch).toBe(AttachBranch.SameCustomEnts); - expect(preview.due_today).toBeUndefined(); - } - - await autumn.attach({ - customer_id: customerId, - product_id: customProduct.id, - version: newVersion, - is_custom: customItems ? true : undefined, - items: customItems, - }); - - // 1. Ensure no new invoices created - const { subs: subsAfter, cusProduct } = await getSubsFromCusId({ - stripeCli, - customerId, - productId: customProduct.id, - db, - org, - env, - }); - - const invoicesBefore = subsBefore.map((sub) => sub.latest_invoice); - const invoicesAfter = subsAfter.map((sub) => sub.latest_invoice); - const subIdsBefore = subsBefore.map((sub) => sub.id); - const subIdsAfter = subsAfter.map((sub) => sub.id); - - // let periodEndsBefore = subsBefore.map((sub) => sub.current_period_end); - // let periodEndsAfter = subsAfter.map((sub) => sub.current_period_end); - - expect(invoicesAfter).toStrictEqual(invoicesBefore); - expect(subIdsAfter).toStrictEqual(subIdsBefore); - // expect(periodEndsAfter).toStrictEqual(periodEndsBefore); - - if (customItems) { - expect(cusProduct.is_custom).toBe(true); - } - - const customer = await autumn.customers.get(customerId); - expectFeaturesCorrect({ - customer, - product: customProduct, - usage, - }); - - // 2. Expect product attached - await expectSubItemsCorrect({ - stripeCli, - customerId, - product: customProduct, - db, - org, - env, - }); - - await expectSubToBeCorrect({ - customerId, - db, - org, - env, - }); -}; - -export default runUpdateEntsTest; diff --git a/server/tests/attach/updateEnts/updateEnts1.test.ts b/server/tests/attach/updateEnts/updateEnts1.test.ts deleted file mode 100644 index 3ac162c13..000000000 --- a/server/tests/attach/updateEnts/updateEnts1.test.ts +++ /dev/null @@ -1,155 +0,0 @@ -import { beforeAll, describe, expect, test } from "bun:test"; -import { LegacyVersion } from "@autumn/shared"; -import { TestFeature } from "@tests/setup/v2Features.js"; -import { attachAndExpectCorrect } from "@tests/utils/expectUtils/expectAttach.js"; -import { getExpectedInvoiceTotal } from "@tests/utils/expectUtils/expectInvoiceUtils.js"; -import ctx from "@tests/utils/testInitUtils/createTestContext.js"; -import chalk from "chalk"; -import { AutumnInt } from "@/external/autumn/autumnCli.js"; -import { timeout } from "@/utils/genUtils.js"; -import { constructArrearItem } 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 { advanceToNextInvoice } from "../../utils/testAttachUtils/testAttachUtils.js"; -import { replaceItems } from "../utils.js"; -import runUpdateEntsTest from "./expectUpdateEnts.js"; - -const testCase = "updateEnts1"; - -export const pro = constructProduct({ - items: [ - constructArrearItem({ - featureId: TestFeature.Words, - includedUsage: 10000, - }), - ], - type: "pro", -}); - -describe(`${chalk.yellowBright(`${testCase}: Testing update ents (changing included usage)`)}`, () => { - const customerId = testCase; - const autumn: AutumnInt = new AutumnInt({ version: LegacyVersion.v1_4 }); - let testClockId: string; - - const curUnix = new Date().getTime(); - const numUsers = 0; - - beforeAll(async () => { - await initProductsV0({ - ctx, - products: [pro], - prefix: testCase, - customerId, - }); - - const { testClockId: testClockId1 } = await initCustomerV3({ - ctx, - customerId, - customerData: {}, - attachPm: "success", - withTestClock: true, - }); - - testClockId = testClockId1!; - }); - - test("should attach pro product", async () => { - await attachAndExpectCorrect({ - autumn, - customerId, - product: pro, - stripeCli: ctx.stripeCli, - db: ctx.db, - org: ctx.org, - env: ctx.env, - }); - }); - - const newItem = constructArrearItem({ - featureId: TestFeature.Words, - includedUsage: 20000, - }); - - const customItems = replaceItems({ - items: pro.items, - featureId: TestFeature.Words, - newItem, - }); - - const usage = 50000; - const overage = 50000 - (newItem.included_usage as number); - - test("should update overage item to have new included usage", async () => { - const customProduct = { - ...pro, - items: customItems, - }; - - await autumn.track({ - customer_id: customerId, - value: usage, - feature_id: TestFeature.Words, - }); - - await timeout(5000); - - await runUpdateEntsTest({ - autumn, - stripeCli: ctx.stripeCli, - customerId, - customProduct, - db: ctx.db, - org: ctx.org, - env: ctx.env, - customItems, - usage: [ - { - featureId: TestFeature.Words, - value: usage, - }, - ], - }); - }); - - test("should have correct invoice next cycle", async () => { - const invoiceTotal = await getExpectedInvoiceTotal({ - org: ctx.org, - env: ctx.env, - customerId, - productId: pro.id, - stripeCli: ctx.stripeCli, - db: ctx.db, - usage: [ - { - featureId: TestFeature.Words, - value: usage, - }, - ], - }); - - await advanceToNextInvoice({ - stripeCli: ctx.stripeCli, - testClockId, - withPause: true, - }); - - // let curUnix = Date.now(); - // curUnix = await advanceTestClock({ - // stripeCli: ctx.stripeCli, - // testClockId, - // advanceTo: addMonths(curUnix, 1).getTime(), - // }); - - // await advanceTestClock({ - // stripeCli: ctx.stripeCli, - // testClockId, - // advanceTo: addHours(curUnix, hoursToFinalizeInvoice).getTime(), - // waitForSeconds: 10, - // }); - - const customer = await autumn.customers.get(customerId); - const invoice = customer.invoices![0]; - expect(invoice.total).toBe(invoiceTotal); - }); -}); diff --git a/server/tests/attach/updateEnts/updateEnts2.test.ts b/server/tests/attach/updateEnts/updateEnts2.test.ts deleted file mode 100644 index e97834606..000000000 --- a/server/tests/attach/updateEnts/updateEnts2.test.ts +++ /dev/null @@ -1,170 +0,0 @@ -import { beforeAll, describe, expect, test } from "bun:test"; -import { LegacyVersion } from "@autumn/shared"; -import { TestFeature } from "@tests/setup/v2Features.js"; -import { hoursToFinalizeInvoice } from "@tests/utils/constants.js"; -import { attachAndExpectCorrect } from "@tests/utils/expectUtils/expectAttach.js"; -import { getExpectedInvoiceTotal } from "@tests/utils/expectUtils/expectInvoiceUtils.js"; -import { advanceTestClock } from "@tests/utils/stripeUtils.js"; -import ctx from "@tests/utils/testInitUtils/createTestContext.js"; -import chalk from "chalk"; -import { addHours, addMonths, addWeeks } from "date-fns"; -import { AutumnInt } from "@/external/autumn/autumnCli.js"; -import { timeout } from "@/utils/genUtils.js"; -import { constructArrearItem } 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 { replaceItems } from "../utils.js"; -import runUpdateEntsTest from "./expectUpdateEnts.js"; - -const testCase = "updateEnts2"; - -export const pro = constructProduct({ - items: [ - constructArrearItem({ - featureId: TestFeature.Words, - includedUsage: 10000, - }), - ], - type: "pro", - isAnnual: true, -}); - -/** - * updateEnts2: - * Testing updating entitlements for annual plans - * 1. Start with pro annual plan (usage-based) - * 2. Update included usage amount - * 3. Verify features and usage are updated correctly - * 4. Verify invoice total is correct in next billing cycle - * - * Verifies that updating entitlements works correctly for annual plans - * and that usage/billing is calculated properly - */ - -describe(`${chalk.yellowBright(`${testCase}: Testing update ents (changing included usage) for annual plan`)}`, () => { - const customerId = testCase; - const autumn: AutumnInt = new AutumnInt({ version: LegacyVersion.v1_4 }); - let testClockId: string; - - const curUnix = new Date().getTime(); - const numUsers = 0; - - beforeAll(async () => { - const { testClockId: testClockId1 } = await initCustomerV3({ - ctx, - customerId, - customerData: {}, - attachPm: "success", - withTestClock: true, - }); - - await initProductsV0({ - ctx, - products: [pro], - prefix: testCase, - }); - - testClockId = testClockId1!; - }); - - test("should attach pro annual product", async () => { - await attachAndExpectCorrect({ - autumn, - customerId, - product: pro, - stripeCli: ctx.stripeCli, - db: ctx.db, - org: ctx.org, - env: ctx.env, - }); - }); - - const newItem = constructArrearItem({ - featureId: TestFeature.Words, - includedUsage: 5000, - }); - - const customItems = replaceItems({ - items: pro.items, - featureId: TestFeature.Words, - newItem, - }); - - const usage = 1200500; - - test("should attach custom pro product", async () => { - await advanceTestClock({ - stripeCli: ctx.stripeCli, - testClockId, - advanceTo: addWeeks(curUnix, 2).getTime(), - waitForSeconds: 30, - }); - - const customProduct = { - ...pro, - items: customItems, - }; - - await autumn.track({ - customer_id: customerId, - value: usage, - feature_id: TestFeature.Words, - }); - - await timeout(5000); - - await runUpdateEntsTest({ - autumn, - stripeCli: ctx.stripeCli, - customerId, - customProduct, - db: ctx.db, - org: ctx.org, - env: ctx.env, - customItems, - usage: [ - { - featureId: TestFeature.Words, - value: usage, - }, - ], - }); - }); - - test("should have correct invoice usage next cycle", async () => { - const invoiceTotal = await getExpectedInvoiceTotal({ - org: ctx.org, - env: ctx.env, - customerId, - productId: pro.id, - stripeCli: ctx.stripeCli, - db: ctx.db, - usage: [ - { - featureId: TestFeature.Words, - value: usage, - }, - ], - onlyIncludeMonthly: true, - }); - - let curUnix = Date.now(); - curUnix = await advanceTestClock({ - stripeCli: ctx.stripeCli, - testClockId, - advanceTo: addMonths(curUnix, 1).getTime(), - }); - - await advanceTestClock({ - stripeCli: ctx.stripeCli, - testClockId, - advanceTo: addHours(curUnix, hoursToFinalizeInvoice).getTime(), - waitForSeconds: 10, - }); - - const customer = await autumn.customers.get(customerId); - const invoice = customer.invoices![0]; - expect(invoice.total).toBe(invoiceTotal); - }); -}); diff --git a/server/tests/attach/updateEnts/updateEnts3.test.ts b/server/tests/attach/updateEnts/updateEnts3.test.ts deleted file mode 100644 index 52bfd7da3..000000000 --- a/server/tests/attach/updateEnts/updateEnts3.test.ts +++ /dev/null @@ -1,186 +0,0 @@ -import { beforeAll, describe, test } from "bun:test"; -import { LegacyVersion } from "@autumn/shared"; -import { TestFeature } from "@tests/setup/v2Features.js"; -import { attachAndExpectCorrect } from "@tests/utils/expectUtils/expectAttach.js"; -import { advanceTestClock } from "@tests/utils/stripeUtils.js"; -import ctx from "@tests/utils/testInitUtils/createTestContext.js"; -import chalk from "chalk"; -import { addWeeks } from "date-fns"; -import { AutumnInt } from "@/external/autumn/autumnCli.js"; -import { constructFeatureItem } from "@/internal/products/product-items/productItemUtils.js"; -import { constructArrearItem } 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 { replaceItems } from "../utils.js"; -import runUpdateEntsTest from "./expectUpdateEnts.js"; - -const testCase = "updateEnts3"; - -export const pro = constructProduct({ - items: [ - constructArrearItem({ - featureId: TestFeature.Words, - includedUsage: 10000, - }), - ], - type: "pro", - isAnnual: true, -}); - -/** - * updateEnts2: - * Testing updating entitlements for annual plans - * 1. Start with pro annual plan (usage-based) - * 2. Update included usage amount - * 3. Verify features and usage are updated correctly - * 4. Verify invoice total is correct in next billing cycle - * - * Verifies that updating entitlements works correctly for annual plans - * and that usage/billing is calculated properly - */ - -describe(`${chalk.yellowBright(`${testCase}: Testing update ents (changing feature items)`)}`, () => { - const customerId = testCase; - const autumn: AutumnInt = new AutumnInt({ version: LegacyVersion.v1_4 }); - let testClockId: string; - - const curUnix = new Date().getTime(); - - beforeAll(async () => { - const { testClockId: testClockId1 } = await initCustomerV3({ - ctx, - customerId, - customerData: {}, - attachPm: "success", - withTestClock: true, - }); - - await initProductsV0({ - ctx, - products: [pro], - prefix: testCase, - }); - - testClockId = testClockId1!; - }); - - test("should attach pro annual product", async () => { - await attachAndExpectCorrect({ - autumn, - customerId, - product: pro, - stripeCli: ctx.stripeCli, - db: ctx.db, - org: ctx.org, - env: ctx.env, - }); - }); - - const newFeatureItem = constructFeatureItem({ - feature_id: TestFeature.Messages, - included_usage: 500, - }); - - const usage = 1200500; - - const customItems = [...pro.items, newFeatureItem]; - - test("should attach custom pro product with new feature item", async () => { - const customProduct = { - ...pro, - items: customItems, - }; - - await autumn.track({ - customer_id: customerId, - value: usage, - feature_id: TestFeature.Words, - }); - - await advanceTestClock({ - stripeCli: ctx.stripeCli, - testClockId, - advanceTo: addWeeks(curUnix, 2).getTime(), - waitForSeconds: 10, - }); - - await runUpdateEntsTest({ - autumn, - stripeCli: ctx.stripeCli, - customerId, - customProduct, - db: ctx.db, - org: ctx.org, - env: ctx.env, - customItems, - usage: [ - { - featureId: TestFeature.Words, - value: usage, - }, - ], - }); - }); - - test("should attach custom pro product with updated feature item", async () => { - const customItems2 = replaceItems({ - items: customItems, - featureId: TestFeature.Messages, - newItem: constructFeatureItem({ - feature_id: TestFeature.Messages, - included_usage: 1000, - }), - }); - - const customProduct = { - ...pro, - items: customItems2, - }; - - await runUpdateEntsTest({ - autumn, - stripeCli: ctx.stripeCli, - customerId, - customProduct, - db: ctx.db, - org: ctx.org, - env: ctx.env, - customItems: customItems2, - usage: [ - { - featureId: TestFeature.Words, - value: usage, - }, - ], - }); - }); - - test("should attach custom pro product with removed feature item", async () => { - const customItems2 = customItems.filter( - (item) => item.feature_id !== TestFeature.Messages, - ); - - const customProduct = { - ...pro, - items: customItems2, - }; - - await runUpdateEntsTest({ - autumn, - stripeCli: ctx.stripeCli, - customerId, - customProduct, - db: ctx.db, - org: ctx.org, - env: ctx.env, - customItems: customItems2, - usage: [ - { - featureId: TestFeature.Words, - value: usage, - }, - ], - }); - }); -}); diff --git a/server/tests/attach/updateEnts/updateEnts4.test.ts b/server/tests/attach/updateEnts/updateEnts4.test.ts deleted file mode 100644 index 9a2c18b3d..000000000 --- a/server/tests/attach/updateEnts/updateEnts4.test.ts +++ /dev/null @@ -1,85 +0,0 @@ -import { beforeAll, describe, expect, test } from "bun:test"; -import { AttachBranch, BillingInterval, LegacyVersion } from "@autumn/shared"; -import { TestFeature } from "@tests/setup/v2Features.js"; -import { attachAndExpectCorrect } from "@tests/utils/expectUtils/expectAttach.js"; -import ctx from "@tests/utils/testInitUtils/createTestContext.js"; -import chalk from "chalk"; -import { AutumnInt } from "@/external/autumn/autumnCli.js"; -import { constructPriceItem } from "@/internal/products/product-items/productItemUtils.js"; -import { nullish } from "@/utils/genUtils.js"; -import { constructArrearItem } 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"; - -const testCase = "updateEnts4"; - -export const pro = constructProduct({ - items: [ - constructArrearItem({ - featureId: TestFeature.Words, - includedUsage: 10000, - }), - ], - type: "pro", - isAnnual: true, -}); - -describe(`${chalk.yellowBright(`${testCase}: Checking price changes don't result in update ents func`)}`, () => { - const customerId = testCase; - const autumn: AutumnInt = new AutumnInt({ version: LegacyVersion.v1_4 }); - let testClockId: string; - - const curUnix = new Date().getTime(); - - beforeAll(async () => { - const { testClockId: testClockId1 } = await initCustomerV3({ - ctx, - customerId, - customerData: {}, - attachPm: "success", - withTestClock: true, - }); - - await initProductsV0({ - ctx, - products: [pro], - prefix: testCase, - }); - - testClockId = testClockId1!; - }); - - test("should attach pro annual product", async () => { - await attachAndExpectCorrect({ - autumn, - customerId, - product: pro, - stripeCli: ctx.stripeCli, - db: ctx.db, - org: ctx.org, - env: ctx.env, - }); - }); - - test("branch should not be same custom ents if base price updated", async () => { - let customItems = pro.items.filter((item) => !nullish(item.feature_id)); - - customItems = [ - ...customItems, - constructPriceItem({ - price: 10, - interval: BillingInterval.Year, - }), - ]; - - const preview = await autumn.attachPreview({ - customer_id: customerId, - product_id: pro.id, - is_custom: true, - items: customItems, - }); - - expect(preview.branch).toBe(AttachBranch.SameCustom); - }); -}); diff --git a/server/tests/attach/upgrade/upgrade1.test.ts b/server/tests/attach/upgrade/upgrade1.test.ts deleted file mode 100644 index 54604b617..000000000 --- a/server/tests/attach/upgrade/upgrade1.test.ts +++ /dev/null @@ -1,134 +0,0 @@ -import { beforeAll, describe, test } from "bun:test"; -import { type AppEnv, LegacyVersion, type Organization } from "@autumn/shared"; -import { TestFeature } from "@tests/setup/v2Features.js"; -import { attachAndExpectCorrect } from "@tests/utils/expectUtils/expectAttach.js"; -import { advanceTestClock } from "@tests/utils/stripeUtils.js"; -import ctx from "@tests/utils/testInitUtils/createTestContext.js"; -import chalk from "chalk"; -import type { Stripe } from "stripe"; -import type { DrizzleCli } from "@/db/initDrizzle.js"; -import { AutumnInt } from "@/external/autumn/autumnCli.js"; -import { timeout } from "@/utils/genUtils"; -import { constructArrearItem } 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 pro = constructProduct({ - id: "pro", - items: [constructArrearItem({ featureId: TestFeature.Words })], - type: "pro", -}); -const premium = constructProduct({ - id: "premium", - items: [constructArrearItem({ featureId: TestFeature.Words })], - type: "premium", -}); -const growth = constructProduct({ - id: "growth", - items: [constructArrearItem({ featureId: TestFeature.Words })], - type: "growth", -}); - -describe(`${chalk.yellowBright("upgrade1: Testing usage upgrades")}`, () => { - const customerId = "upgrade1"; - const autumn: AutumnInt = new AutumnInt({ version: LegacyVersion.v1_4 }); - - let stripeCli: Stripe; - let testClockId: string; - let curUnix: number; - let db: DrizzleCli; - let org: Organization; - let env: AppEnv; - - beforeAll(async () => { - db = ctx.db; - org = ctx.org; - env = ctx.env; - stripeCli = ctx.stripeCli; - - const { testClockId: testClockId1 } = await initCustomerV3({ - ctx, - customerId, - customerData: {}, - attachPm: "success", - withTestClock: true, - }); - - await initProductsV0({ - ctx, - products: [pro, premium, growth], - prefix: customerId, - }); - - testClockId = testClockId1!; - }); - - test("should attach pro product", async () => { - await attachAndExpectCorrect({ - autumn, - customerId, - product: pro, - stripeCli, - db, - org, - env, - }); - }); - - test("should attach premium product", async () => { - const wordsUsage = 100000; - await timeout(4000); - await autumn.track({ - customer_id: customerId, - feature_id: TestFeature.Words, - value: wordsUsage, - }); - - // curUnix = await advanceTestClock({ - // stripeCli, - // testClockId, - // advanceTo: addWeeks(new Date(), 2).getTime(), - // waitForSeconds: 10, - // }); - - await attachAndExpectCorrect({ - autumn, - customerId, - product: premium, - stripeCli, - db, - org, - env, - }); - - await timeout(2000); - }); - - test("should attach growth product", async () => { - const wordsUsage = 200000; - await autumn.track({ - customer_id: customerId, - feature_id: TestFeature.Words, - value: wordsUsage, - }); - - curUnix = await advanceTestClock({ - stripeCli, - testClockId, - numberOfWeeks: 1, - waitForSeconds: 30, - }); - - await attachAndExpectCorrect({ - autumn, - customerId, - product: growth, - stripeCli, - db, - org, - env, - }); - }); -}); diff --git a/server/tests/attach/upgrade/upgrade2.test.ts b/server/tests/attach/upgrade/upgrade2.test.ts deleted file mode 100644 index 899f18b65..000000000 --- a/server/tests/attach/upgrade/upgrade2.test.ts +++ /dev/null @@ -1,140 +0,0 @@ -import { beforeAll, describe, test } from "bun:test"; -import { type AppEnv, LegacyVersion, type Organization } from "@autumn/shared"; -import { TestFeature } from "@tests/setup/v2Features.js"; -import { attachAndExpectCorrect } from "@tests/utils/expectUtils/expectAttach.js"; -import { advanceTestClock } from "@tests/utils/stripeUtils.js"; -import ctx from "@tests/utils/testInitUtils/createTestContext.js"; -import chalk from "chalk"; -import { addWeeks } from "date-fns"; -import type Stripe from "stripe"; -import type { DrizzleCli } from "@/db/initDrizzle.js"; -import { AutumnInt } from "@/external/autumn/autumnCli.js"; -import { constructArrearItem } 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"; - -// Shared products for attach tests -const testCase = "upgrade2"; -export const pro = constructProduct({ - id: "pro", - items: [constructArrearItem({ featureId: TestFeature.Words })], - type: "pro", -}); - -export const proAnnual = constructProduct({ - id: "pro_annual", - items: [constructArrearItem({ featureId: TestFeature.Words })], - type: "pro", - isAnnual: true, -}); - -export const premiumAnnual = constructProduct({ - id: "premium_annual", - items: [constructArrearItem({ featureId: TestFeature.Words })], - type: "premium", - isAnnual: true, -}); - -/** - * upgrade2: - * 1. Start with pro monthly plan (usage-based) - * 2. Upgrade to pro annual plan (usage-based) - * 3. Upgrade to premium annual plan (usage-based) - * - * Verifies subscription items and anchors are correct after each upgrade - */ - -describe(`${chalk.yellowBright("upgrade2: Testing usage upgrades with monthly -> annual")}`, () => { - const customerId = "upgrade2"; - const autumn: AutumnInt = new AutumnInt({ version: LegacyVersion.v1_4 }); - let stripeCli: Stripe; - let testClockId: string; - let db: DrizzleCli, org: Organization, env: AppEnv; - - let curUnix = new Date().getTime(); - - beforeAll(async () => { - db = ctx.db; - org = ctx.org; - env = ctx.env; - stripeCli = ctx.stripeCli; - - const { testClockId: testClockId1 } = await initCustomerV3({ - ctx, - customerId, - customerData: {}, - attachPm: "success", - withTestClock: true, - }); - - await initProductsV0({ - ctx, - products: [pro, proAnnual, premiumAnnual], - prefix: testCase, - }); - - testClockId = testClockId1!; - }); - - test("should attach pro product", async () => { - await attachAndExpectCorrect({ - autumn, - customerId, - product: pro, - stripeCli, - db, - org, - env, - }); - }); - - test("should attach pro annual product", async () => { - await autumn.track({ - customer_id: customerId, - feature_id: TestFeature.Words, - value: 100000, - }); - - curUnix = await advanceTestClock({ - stripeCli, - testClockId, - advanceTo: addWeeks(curUnix, 2).getTime(), - }); - - await attachAndExpectCorrect({ - autumn, - customerId, - product: proAnnual, - stripeCli, - db, - org, - env, - }); - }); - - test("should attach premium annual product", async () => { - await autumn.track({ - customer_id: customerId, - feature_id: TestFeature.Words, - value: 5000000, - }); - - await advanceTestClock({ - stripeCli, - testClockId, - advanceTo: addWeeks(curUnix, 1).getTime(), - waitForSeconds: 10, - }); - - await attachAndExpectCorrect({ - autumn, - customerId, - product: premiumAnnual, - stripeCli, - db, - org, - env, - }); - }); -}); diff --git a/server/tests/attach/upgrade/upgrade3.test.ts b/server/tests/attach/upgrade/upgrade3.test.ts deleted file mode 100644 index 7375779c5..000000000 --- a/server/tests/attach/upgrade/upgrade3.test.ts +++ /dev/null @@ -1,187 +0,0 @@ -import { beforeAll, describe, test } from "bun:test"; -import { type AppEnv, LegacyVersion, type Organization } from "@autumn/shared"; -import { TestFeature } from "@tests/setup/v2Features.js"; -import { attachAndExpectCorrect } from "@tests/utils/expectUtils/expectAttach.js"; -import { advanceTestClock } from "@tests/utils/stripeUtils.js"; -import ctx from "@tests/utils/testInitUtils/createTestContext.js"; -import chalk from "chalk"; -import { addWeeks } from "date-fns"; -import type Stripe from "stripe"; -import type { DrizzleCli } from "@/db/initDrizzle.js"; -import { AutumnInt } from "@/external/autumn/autumnCli.js"; -import { timeout } from "@/utils/genUtils.js"; -import { constructArrearProratedItem } 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"; - -const testCase = "upgrade3"; - -const pro = constructProduct({ - items: [ - constructArrearProratedItem({ - featureId: TestFeature.Users, - pricePerUnit: 12, - }), - ], - type: "pro", -}); - -const premium = constructProduct({ - items: [ - constructArrearProratedItem({ - featureId: TestFeature.Users, - pricePerUnit: 20, - }), - ], - type: "premium", -}); - -const proAnnual = constructProduct({ - items: [ - constructArrearProratedItem({ - featureId: TestFeature.Users, - pricePerUnit: 12, - }), - ], - type: "pro", - isAnnual: true, -}); - -/** - * upgrade3: - * Testing upgrades for arrear prorated - * 1. Start with pro monthly plan (usage-based) - * 2. Upgrade to pro annual plan (usage-based) - * 3. Upgrade to premium annual plan (usage-based) - * - * Verifies subscription items and anchors are correct after each upgrade - * with arrear prorated billing - */ - -describe(`${chalk.yellowBright(`${testCase}: Testing upgrades with arrear prorated`)}`, () => { - const customerId = testCase; - const autumn: AutumnInt = new AutumnInt({ version: LegacyVersion.v1_4 }); - let testClockId: string; - let db: DrizzleCli, org: Organization, env: AppEnv; - let stripeCli: Stripe; - - let curUnix = new Date().getTime(); - let numUsers = 0; - - beforeAll(async () => { - db = ctx.db; - org = ctx.org; - env = ctx.env; - stripeCli = ctx.stripeCli; - - const { testClockId: testClockId1 } = await initCustomerV3({ - ctx, - customerId, - customerData: {}, - attachPm: "success", - withTestClock: true, - }); - - await initProductsV0({ - ctx, - products: [pro, premium, proAnnual], - prefix: testCase, - }); - - testClockId = testClockId1!; - }); - - test("should attach pro product (arrear prorated)", async () => { - // 1. Create multiple entities - const entities = await autumn.entities.create(customerId, [ - { - id: "entity1", - name: "entity1", - feature_id: TestFeature.Users, - }, - { - id: "entity2", - name: "entity2", - feature_id: TestFeature.Users, - }, - ]); - numUsers = 2; - - await attachAndExpectCorrect({ - autumn, - customerId, - product: pro, - stripeCli, - db, - org, - env, - usage: [ - { - featureId: TestFeature.Users, - value: 2, - }, - ], - }); - }); - - test("should create entity, then upgrade to premium product (arrear prorated)", async () => { - curUnix = await advanceTestClock({ - stripeCli, - testClockId, - advanceTo: addWeeks(curUnix, 1).getTime(), - }); - - // TODO: Check price paid for entity3 - await autumn.entities.create(customerId, [ - { - id: "entity3", - name: "entity3", - feature_id: TestFeature.Users, - }, - ]); - numUsers += 1; - - await timeout(3000); - - await attachAndExpectCorrect({ - autumn, - customerId, - product: premium, - stripeCli, - db, - org, - env, - usage: [ - { - featureId: TestFeature.Users, - value: numUsers, - }, - ], - }); - }); - - test("should upgrade to pro-annual product (arrear prorated)", async () => { - curUnix = await advanceTestClock({ - stripeCli, - testClockId, - advanceTo: addWeeks(curUnix, 1).getTime(), - }); - - await attachAndExpectCorrect({ - autumn, - customerId, - product: proAnnual, - stripeCli, - db, - org, - env, - usage: [ - { - featureId: TestFeature.Users, - value: numUsers, - }, - ], - }); - }); -}); diff --git a/server/tests/attach/upgrade/upgrade4.test.ts b/server/tests/attach/upgrade/upgrade4.test.ts deleted file mode 100644 index 7c1c0fa87..000000000 --- a/server/tests/attach/upgrade/upgrade4.test.ts +++ /dev/null @@ -1,168 +0,0 @@ -import { beforeAll, describe, test } from "bun:test"; -import { type AppEnv, LegacyVersion, type Organization } from "@autumn/shared"; -import { TestFeature } from "@tests/setup/v2Features.js"; -import { attachAndExpectCorrect } from "@tests/utils/expectUtils/expectAttach.js"; -import { advanceTestClock } from "@tests/utils/stripeUtils.js"; -import ctx from "@tests/utils/testInitUtils/createTestContext.js"; -import chalk from "chalk"; -import { addWeeks } from "date-fns"; -import type Stripe from "stripe"; -import type { DrizzleCli } from "@/db/initDrizzle.js"; -import { AutumnInt } from "@/external/autumn/autumnCli.js"; -import { 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"; - -const testCase = "upgrade4"; - -export const pro = constructProduct({ - items: [ - constructPrepaidItem({ - featureId: TestFeature.Users, - price: 12, - billingUnits: 1, - }), - ], - type: "pro", -}); - -export const premium = constructProduct({ - items: [ - constructPrepaidItem({ - featureId: TestFeature.Users, - price: 20, - billingUnits: 1, - }), - ], - type: "premium", -}); - -export const proAnnual = constructProduct({ - items: [ - constructPrepaidItem({ - featureId: TestFeature.Users, - price: 12, - billingUnits: 1, - }), - ], - type: "pro", - isAnnual: true, -}); - -/** - * upgrade3: - * Testing upgrades for arrear prorated - * 1. Start with pro monthly plan (usage-based) - * 2. Upgrade to pro annual plan (usage-based) - * 3. Upgrade to premium annual plan (usage-based) - * - * Verifies subscription items and anchors are correct after each upgrade - * with arrear prorated billing - */ - -describe(`${chalk.yellowBright(`${testCase}: Testing upgrades with prepaid continuous use`)}`, () => { - const customerId = testCase; - const autumn: AutumnInt = new AutumnInt({ version: LegacyVersion.v1_4 }); - let testClockId: string; - let db: DrizzleCli, org: Organization, env: AppEnv; - let stripeCli: Stripe; - - let curUnix = new Date().getTime(); - const numUsers = 0; - - beforeAll(async () => { - db = ctx.db; - org = ctx.org; - env = ctx.env; - stripeCli = ctx.stripeCli; - - const { testClockId: testClockId1 } = await initCustomerV3({ - ctx, - customerId, - customerData: {}, - attachPm: "success", - withTestClock: true, - }); - - await initProductsV0({ - ctx, - products: [pro, premium, proAnnual], - prefix: testCase, - }); - - testClockId = testClockId1!; - }); - - const proOpts = [ - { - feature_id: TestFeature.Users, - quantity: 4, - }, - ]; - - test("should attach pro product (arrear prorated)", async () => { - await attachAndExpectCorrect({ - autumn, - customerId, - product: pro, - stripeCli, - db, - org, - env, - options: proOpts, - }); - }); - - const premiumOpts = [ - { - feature_id: TestFeature.Users, - quantity: 6, - }, - ]; - - test("should create entity, then upgrade to premium product (arrear prorated)", async () => { - curUnix = await advanceTestClock({ - stripeCli, - testClockId, - advanceTo: addWeeks(curUnix, 1).getTime(), - }); - - await attachAndExpectCorrect({ - autumn, - customerId, - product: premium, - stripeCli, - db, - org, - env, - options: premiumOpts, - }); - }); - - const proAnnualOpts = [ - { - feature_id: TestFeature.Users, - quantity: 3, - }, - ]; - - test("should upgrade to pro-annual product (arrear prorated)", async () => { - curUnix = await advanceTestClock({ - stripeCli, - testClockId, - advanceTo: addWeeks(curUnix, 1).getTime(), - }); - - await attachAndExpectCorrect({ - autumn, - customerId, - product: proAnnual, - stripeCli, - db, - org, - env, - options: proAnnualOpts, - }); - }); -}); diff --git a/server/tests/attach/upgrade/upgrade5.test.ts b/server/tests/attach/upgrade/upgrade5.test.ts deleted file mode 100644 index 41c7297b4..000000000 --- a/server/tests/attach/upgrade/upgrade5.test.ts +++ /dev/null @@ -1,122 +0,0 @@ -import { beforeAll, describe, test } from "bun:test"; -import { type AppEnv, LegacyVersion, type Organization } from "@autumn/shared"; -import { TestFeature } from "@tests/setup/v2Features.js"; -import { attachAndExpectCorrect } from "@tests/utils/expectUtils/expectAttach.js"; -import { advanceTestClock } from "@tests/utils/stripeUtils.js"; -import ctx from "@tests/utils/testInitUtils/createTestContext.js"; -import chalk from "chalk"; -import { addWeeks } from "date-fns"; -import type Stripe from "stripe"; -import type { DrizzleCli } from "@/db/initDrizzle.js"; -import { AutumnInt } from "@/external/autumn/autumnCli.js"; -import { 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"; - -const testCase = "upgrade5"; - -export const pro = constructProduct({ - items: [ - constructPrepaidItem({ - featureId: TestFeature.Messages, - price: 12, - billingUnits: 100, - }), - ], - type: "pro", -}); - -export const premium = constructProduct({ - items: [ - constructPrepaidItem({ - featureId: TestFeature.Messages, - price: 8, - billingUnits: 100, - }), - ], - type: "premium", -}); - -describe(`${chalk.yellowBright(`${testCase}: Testing upgrades with prepaid single use`)}`, () => { - const customerId = testCase; - const autumn: AutumnInt = new AutumnInt({ version: LegacyVersion.v1_4 }); - let testClockId: string; - let db: DrizzleCli, org: Organization, env: AppEnv; - let stripeCli: Stripe; - - let curUnix = new Date().getTime(); - const numUsers = 0; - - beforeAll(async () => { - db = ctx.db; - org = ctx.org; - env = ctx.env; - stripeCli = ctx.stripeCli; - - const { testClockId: testClockId1 } = await initCustomerV3({ - ctx, - customerId, - customerData: {}, - attachPm: "success", - withTestClock: true, - }); - - await initProductsV0({ - ctx, - products: [pro, premium], - prefix: testCase, - }); - - testClockId = testClockId1!; - }); - - const proOpts = [ - { - feature_id: TestFeature.Messages, - quantity: 300, - }, - ]; - - test("should attach pro product (prepaid single use)", async () => { - await attachAndExpectCorrect({ - autumn, - customerId, - product: pro, - stripeCli, - db, - org, - env, - options: proOpts, - }); - }); - - const premiumOpts = [ - { - feature_id: TestFeature.Messages, - quantity: 600, - }, - ]; - - test("should upgrade to premium product (prepaid single use)", async () => { - curUnix = await advanceTestClock({ - stripeCli, - testClockId, - advanceTo: addWeeks(curUnix, 1).getTime(), - waitForSeconds: 20, - }); - - return; - - await attachAndExpectCorrect({ - autumn, - customerId, - product: premium, - stripeCli, - db, - org, - env, - options: premiumOpts, - }); - }); -}); diff --git a/server/tests/attach/upgrade/upgrade7.test.ts b/server/tests/attach/upgrade/upgrade7.test.ts deleted file mode 100644 index 54aab9227..000000000 --- a/server/tests/attach/upgrade/upgrade7.test.ts +++ /dev/null @@ -1,80 +0,0 @@ -import { beforeAll, describe, test } from "bun:test"; -import { type AppEnv, LegacyVersion, type Organization } from "@autumn/shared"; -import { attachAndExpectCorrect } from "@tests/utils/expectUtils/expectAttach.js"; -import ctx from "@tests/utils/testInitUtils/createTestContext.js"; -import chalk from "chalk"; -import type Stripe from "stripe"; -import type { DrizzleCli } from "@/db/initDrizzle.js"; -import { AutumnInt } from "@/external/autumn/autumnCli.js"; -import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js"; -import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js"; -import { initProductsV0 } from "@/utils/scriptUtils/testUtils/initProductsV0.js"; - -const testCase = "upgrade7"; - -export const pro = constructProduct({ - items: [], - type: "pro", -}); - -export const premium = constructProduct({ - items: [], - type: "premium", -}); - -describe(`${chalk.yellowBright(`${testCase}: Testing upgrade via cancel + attach`)}`, () => { - const customerId = testCase; - const autumn: AutumnInt = new AutumnInt({ version: LegacyVersion.v1_4 }); - let testClockId: string; - let db: DrizzleCli, org: Organization, env: AppEnv; - let stripeCli: Stripe; - - beforeAll(async () => { - db = ctx.db; - org = ctx.org; - env = ctx.env; - stripeCli = ctx.stripeCli; - - const { testClockId: testClockId1 } = await initCustomerV3({ - ctx, - customerId, - customerData: {}, - attachPm: "success", - withTestClock: true, - }); - - await initProductsV0({ - ctx, - products: [pro, premium], - prefix: testCase, - }); - - testClockId = testClockId1!; - }); - - test("should attach pro product", async () => { - await attachAndExpectCorrect({ - autumn, - customerId, - product: pro, - stripeCli, - db, - org, - env, - }); - }); - - test("should cancel than attach premium product", async () => { - await autumn.cancel({ - customer_id: customerId, - product_id: pro.id, - cancel_immediately: true, - }); - - await autumn.attach({ - customer_id: customerId, - product_id: premium.id, - force_checkout: true, - }); - }); -}); diff --git a/server/tests/attach/upgradeOld/sharedProducts.ts b/server/tests/attach/upgradeOld/sharedProducts.ts deleted file mode 100644 index cfd150a60..000000000 --- a/server/tests/attach/upgradeOld/sharedProducts.ts +++ /dev/null @@ -1,128 +0,0 @@ -import { - BillingInterval, - FreeTrialDuration, - ProductItemInterval, -} from "@autumn/shared"; -import { TestFeature } from "@tests/setup/v2Features.js"; -import ctx from "@tests/utils/testInitUtils/createTestContext.js"; -import { constructPriceItem } from "@/internal/products/product-items/productItemUtils.js"; -import { constructFeatureItem } from "@/utils/scriptUtils/constructItem.js"; -import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js"; -import { createSharedProducts } from "@/utils/scriptUtils/testUtils/createSharedProduct.js"; - -/** - * Shared products for upgradeOld test group - * Matches global products.pro, products.proWithTrial, products.premium, products.premiumWithTrial - */ - -export const sharedProProduct = constructProduct({ - id: "shared-upgradeold-pro", - type: "pro", - excludeBase: true, - items: [ - constructFeatureItem({ - featureId: TestFeature.Dashboard, - isBoolean: true, - }), - constructFeatureItem({ - featureId: TestFeature.Messages, - includedUsage: 10, - interval: ProductItemInterval.Month, - }), - constructFeatureItem({ - featureId: TestFeature.Admin, - unlimited: true, - }), - constructPriceItem({ - price: 2000, - interval: BillingInterval.Month, - }), - ], -}); - -export const sharedProWithTrialProduct = constructProduct({ - id: "shared-upgradeold-pro-trial", - type: "pro", - excludeBase: true, - items: [ - constructFeatureItem({ - featureId: TestFeature.Dashboard, - isBoolean: true, - }), - constructFeatureItem({ - featureId: TestFeature.Messages, - includedUsage: 10, - interval: ProductItemInterval.Month, - }), - constructFeatureItem({ - featureId: TestFeature.Admin, - unlimited: true, - }), - constructPriceItem({ - price: 2000, - interval: BillingInterval.Month, - }), - ], - freeTrial: { - length: 7, - duration: FreeTrialDuration.Day, - unique_fingerprint: true, - card_required: true, - }, -}); - -export const sharedPremiumProduct = constructProduct({ - id: "shared-upgradeold-premium", - type: "premium", - excludeBase: true, - items: [ - constructFeatureItem({ - featureId: TestFeature.Messages, - includedUsage: 100, - interval: ProductItemInterval.Month, - }), - constructPriceItem({ - price: 5000, - interval: BillingInterval.Month, - }), - ], -}); - -export const sharedPremiumWithTrialProduct = constructProduct({ - id: "shared-upgradeold-premium-trial", - type: "premium", - excludeBase: true, - items: [ - constructFeatureItem({ - featureId: TestFeature.Messages, - includedUsage: 100, - interval: ProductItemInterval.Month, - }), - constructPriceItem({ - price: 5000, - interval: BillingInterval.Month, - }), - ], - freeTrial: { - length: 7, - duration: FreeTrialDuration.Day, - unique_fingerprint: true, - card_required: true, - }, -}); - -export const initUpgradeOldSharedProducts = async () => { - await createSharedProducts({ - ctx, - products: [ - sharedProProduct, - sharedProWithTrialProduct, - sharedPremiumProduct, - sharedPremiumWithTrialProduct, - ], - }); -}; - -// Auto-init removed to prevent conflicts when running tests in parallel -// Each test file now initializes its own products with unique prefixes -// await initUpgradeOldSharedProducts(); diff --git a/server/tests/attach/upgradeOld/upgradeOld2.test.ts b/server/tests/attach/upgradeOld/upgradeOld2.test.ts deleted file mode 100644 index c6bc13aea..000000000 --- a/server/tests/attach/upgradeOld/upgradeOld2.test.ts +++ /dev/null @@ -1,101 +0,0 @@ -import { beforeAll, describe, test } from "bun:test"; -import { - BillingInterval, - FreeTrialDuration, - ProductItemInterval, -} 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 { constructPriceItem } from "@/internal/products/product-items/productItemUtils.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"; - -const testCase = "upgradeOld2"; - -const proProduct = constructProduct({ - type: "pro", - excludeBase: true, - items: [ - constructFeatureItem({ - featureId: TestFeature.Dashboard, - isBoolean: true, - }), - constructFeatureItem({ - featureId: TestFeature.Messages, - includedUsage: 10, - interval: ProductItemInterval.Month, - }), - constructFeatureItem({ - featureId: TestFeature.Admin, - unlimited: true, - }), - constructPriceItem({ - price: 2000, - interval: BillingInterval.Month, - }), - ], -}); - -const premiumWithTrialProduct = constructProduct({ - type: "premium", - excludeBase: true, - items: [ - constructFeatureItem({ - featureId: TestFeature.Messages, - includedUsage: 100, - interval: ProductItemInterval.Month, - }), - constructPriceItem({ - price: 5000, - interval: BillingInterval.Month, - }), - ], - freeTrial: { - length: 7, - duration: FreeTrialDuration.Day, - unique_fingerprint: true, - card_required: true, - }, -}); - -describe(`${chalk.yellowBright( - "upgradeOld2: Testing upgrade (paid to trial)", -)}`, () => { - const customerId = testCase; - const autumn: AutumnInt = new AutumnInt(); - - beforeAll(async () => { - await initProductsV0({ - ctx, - products: [proProduct, premiumWithTrialProduct], - prefix: testCase, - customerId, - }); - - await initCustomerV3({ - ctx, - customerId, - customerData: {}, - attachPm: "success", - withTestClock: true, - }); - }); - - test("should attach pro", async () => { - await autumn.attach({ - customer_id: customerId, - product_id: proProduct.id, - }); - }); - - test("should attach premium with trial and have trial", async () => { - await autumn.attach({ - customer_id: customerId, - product_id: premiumWithTrialProduct.id, - }); - }); -}); diff --git a/server/tests/attach/upgradeOld/upgradeOld3.test.ts b/server/tests/attach/upgradeOld/upgradeOld3.test.ts deleted file mode 100644 index e4087b540..000000000 --- a/server/tests/attach/upgradeOld/upgradeOld3.test.ts +++ /dev/null @@ -1,139 +0,0 @@ -import { beforeAll, describe, expect, test } from "bun:test"; -import { - BillingInterval, - CusProductStatus, - FreeTrialDuration, - ProductItemInterval, -} from "@autumn/shared"; -import { TestFeature } from "@tests/setup/v2Features.js"; -import { expectCustomerV0Correct } from "@tests/utils/expectUtils/expectCustomerV0Correct.js"; -import ctx from "@tests/utils/testInitUtils/createTestContext.js"; -import chalk from "chalk"; -import { addDays } from "date-fns"; -import type Stripe from "stripe"; -import { AutumnInt } from "@/external/autumn/autumnCli.js"; -import { constructPriceItem } from "@/internal/products/product-items/productItemUtils.js"; -import { constructFeatureItem } from "@/utils/scriptUtils/constructItem.js"; -import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js"; -import { advanceTestClock } from "@/utils/scriptUtils/testClockUtils.js"; -import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js"; -import { initProductsV0 } from "@/utils/scriptUtils/testUtils/initProductsV0.js"; - -const testCase = "upgradeOld3"; - -const proWithTrialProduct = constructProduct({ - type: "pro", - excludeBase: true, - items: [ - constructFeatureItem({ - featureId: TestFeature.Dashboard, - isBoolean: true, - }), - constructFeatureItem({ - featureId: TestFeature.Messages, - includedUsage: 10, - interval: ProductItemInterval.Month, - }), - constructFeatureItem({ - featureId: TestFeature.Admin, - unlimited: true, - }), - constructPriceItem({ - price: 2000, - interval: BillingInterval.Month, - }), - ], - freeTrial: { - length: 7, - duration: FreeTrialDuration.Day, - unique_fingerprint: true, - card_required: true, - }, -}); - -const premiumWithTrialProduct = constructProduct({ - type: "premium", - excludeBase: true, - items: [ - constructFeatureItem({ - featureId: TestFeature.Messages, - includedUsage: 100, - interval: ProductItemInterval.Month, - }), - constructPriceItem({ - price: 5000, - interval: BillingInterval.Month, - }), - ], - freeTrial: { - length: 7, - duration: FreeTrialDuration.Day, - unique_fingerprint: true, - card_required: true, - }, -}); - -describe(`${chalk.yellowBright("upgradeOld3: Testing upgrade (trial to trial)")}`, () => { - const customerId = testCase; - let testClockId: string; - const autumn: AutumnInt = new AutumnInt(); - let stripeCli: Stripe; - beforeAll(async () => { - stripeCli = ctx.stripeCli; - - await initProductsV0({ - ctx, - products: [proWithTrialProduct, premiumWithTrialProduct], - prefix: testCase, - customerId, - }); - - const { testClockId: testClockId_ } = await initCustomerV3({ - ctx, - customerId, - customerData: {}, - attachPm: "success", - withTestClock: true, - }); - - testClockId = testClockId_; - }); - - test("should attach pro with trial", async () => { - await autumn.attach({ - customer_id: customerId, - product_id: proWithTrialProduct.id, - }); - - console.log(` ${chalk.greenBright("Attached pro with trial")}`); - }); - - test("should attach premium with trial", async () => { - const advanceTo = addDays(new Date(), 3).getTime(); - - await advanceTestClock({ - stripeCli, - testClockId, - advanceTo, - waitForSeconds: 10, - }); - - await autumn.attach({ - customer_id: customerId, - product_id: premiumWithTrialProduct.id, - }); - }); - - test("should check product and ents", async () => { - const res = await autumn.customers.get(customerId); - expectCustomerV0Correct({ - sent: premiumWithTrialProduct, - cusRes: res, - status: CusProductStatus.Trialing, - }); - - const invoices = res.invoices; - - expect(invoices![0].total).toBe(0); - }); -}); diff --git a/server/tests/attach/upgradeOld/upgradeOld4.test.ts b/server/tests/attach/upgradeOld/upgradeOld4.test.ts deleted file mode 100644 index 32f42a400..000000000 --- a/server/tests/attach/upgradeOld/upgradeOld4.test.ts +++ /dev/null @@ -1,163 +0,0 @@ -// TESTING UPGRADES - -import { beforeAll, describe, test } from "bun:test"; -import { - BillingInterval, - type Customer, - ProductItemInterval, -} from "@autumn/shared"; -import { AutumnCli } from "@tests/cli/AutumnCli.js"; -import { TestFeature } from "@tests/setup/v2Features.js"; -import { expectCustomerV0Correct } from "@tests/utils/expectUtils/expectCustomerV0Correct.js"; -import { expectAutumnError } from "@tests/utils/expectUtils/expectErrUtils.js"; -import ctx from "@tests/utils/testInitUtils/createTestContext.js"; -import chalk from "chalk"; -import type Stripe from "stripe"; -import { AutumnInt } from "@/external/autumn/autumnCli.js"; -import { - attachFailedPaymentMethod, - attachPmToCus, -} from "@/external/stripe/stripeCusUtils.js"; -import { constructPriceItem } from "@/internal/products/product-items/productItemUtils.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"; - -const testCase = "upgradeOld4"; - -const proProduct = constructProduct({ - type: "pro", - excludeBase: true, - items: [ - constructFeatureItem({ - featureId: TestFeature.Dashboard, - isBoolean: true, - }), - constructFeatureItem({ - featureId: TestFeature.Messages, - includedUsage: 10, - interval: ProductItemInterval.Month, - }), - constructFeatureItem({ - featureId: TestFeature.Admin, - unlimited: true, - }), - constructPriceItem({ - price: 2000, - interval: BillingInterval.Month, - }), - ], -}); - -const premiumProduct = constructProduct({ - type: "premium", - excludeBase: true, - items: [ - constructFeatureItem({ - featureId: TestFeature.Messages, - includedUsage: 100, - interval: ProductItemInterval.Month, - }), - constructPriceItem({ - price: 5000, - interval: BillingInterval.Month, - }), - ], -}); - -describe(`${chalk.yellowBright("upgradeOld4: Testing upgrade from pro -> premium")}`, () => { - let customer: Customer; - const customerId = testCase; - - let stripeCli: Stripe; - const autumn: AutumnInt = new AutumnInt(); - - beforeAll(async () => { - stripeCli = ctx.stripeCli; - - await initProductsV0({ - ctx, - products: [proProduct, premiumProduct], - prefix: testCase, - customerId, - }); - - const { customer: customer_ } = await initCustomerV3({ - ctx, - customerId, - customerData: {}, - attachPm: "success", - withTestClock: true, - }); - - customer = customer_; - }); - - test("should attach pro (trial)", async () => { - await autumn.attach({ - customer_id: customerId, - product_id: proProduct.id, - }); - - const res = await autumn.customers.get(customerId); - expectCustomerV0Correct({ - sent: proProduct, - cusRes: res, - }); - }); - - // 1. Try force checkout... - test("should attach premium and not be able to force checkout", async () => { - expectAutumnError({ - func: async () => { - await autumn.attach({ - customer_id: customerId, - product_id: premiumProduct.id, - force_checkout: true, - }); - }, - }); - }); - - test("should attach premium and not be able to upgrade (without payment method)", async () => { - await attachFailedPaymentMethod({ - stripeCli: stripeCli, - customer: customer, - }); - - await expectAutumnError({ - func: async () => { - await autumn.attach({ - customer_id: customerId, - product_id: premiumProduct.id, - force_checkout: true, - }); - }, - }); - }); - return; - - // Attach payment method - test("should attach successful payment method", async () => { - await attachPmToCus({ - db: ctx.db, - customer: customer, - org: ctx.org, - env: ctx.env, - }); - }); - - test("should attach premium and have correct product and entitlements", async () => { - await AutumnCli.attach({ - customerId: customerId, - productId: premiumProduct.id, - }); - - const res = await AutumnCli.getCustomer(customerId); - expectCustomerV0Correct({ - sent: premiumProduct, - cusRes: res, - }); - }); -}); diff --git a/server/tests/attach/utils.ts b/server/tests/attach/utils.ts deleted file mode 100644 index f213f6466..000000000 --- a/server/tests/attach/utils.ts +++ /dev/null @@ -1,185 +0,0 @@ -import type { BillingInterval, ProductItem, ProductV2 } from "@autumn/shared"; -import { nullish } from "@/utils/genUtils.js"; - -// export const runAttachTest = async ({ -// autumn, -// customerId, -// entityId, -// product, -// options, -// stripeCli, -// db, -// org, -// env, -// usage, -// waitForInvoice = 0, -// isCanceled = false, -// skipFeatureCheck = false, -// singleInvoice = false, -// skipSubCheck = false, -// entities, -// }: { -// autumn: AutumnInt; -// customerId: string; -// entityId?: string; -// product: ProductV2; -// options?: FeatureOptions[]; -// stripeCli: Stripe; -// db: DrizzleCli; -// org: Organization; -// env: AppEnv; -// usage?: { -// featureId: string; -// value: number; -// }[]; -// waitForInvoice?: number; -// isCanceled?: boolean; -// skipFeatureCheck?: boolean; -// singleInvoice?: boolean; -// skipSubCheck?: boolean; -// entities?: CreateEntity[]; -// }) => { -// const preview = await autumn.attachPreview({ -// customer_id: customerId, -// product_id: product.id, -// entity_id: entityId, -// }); - -// const total = getAttachTotal({ -// preview, -// options, -// }); - -// await autumn.attach({ -// customer_id: customerId, -// product_id: product.id, -// entity_id: entityId, -// options: toSnakeCase(options), -// }); - -// if (waitForInvoice) { -// await timeout(waitForInvoice); -// } - -// const customer = await autumn.customers.get(customerId); - -// const productCount = customer.products.reduce((acc: number, p: any) => { -// if (product.group == p.group) { -// return acc + 1; -// } else return acc; -// }, 0); - -// expect( -// productCount, -// `customer should only have 1 product (from this group: ${product.group})` -// ).to.equal(1); - -// expectProductAttached({ -// customer, -// product, -// entityId, -// }); - -// let intervals = Array.from( -// new Set(product.items.map((item) => item.interval)) -// ).filter(notNullish); -// const multiInterval = intervals.length > 1; - -// const freeProduct = isFreeProductV2({ product }); -// if (!freeProduct) { -// let multiInvoice = !singleInvoice && multiInterval; -// expectInvoicesCorrect({ -// customer, -// first: multiInvoice ? undefined : { productId: product.id, total }, -// second: multiInvoice ? { productId: product.id, total } : undefined, -// }); -// } - -// if (!skipFeatureCheck) { -// expectFeaturesCorrect({ -// customer, -// product, -// usage, -// options, -// entities, -// }); -// } - -// const branch = preview.branch; -// if (branch == AttachBranch.OneOff || freeProduct) { -// return; -// } - -// if (skipSubCheck) return; - -// await expectSubItemsCorrect({ -// stripeCli, -// customerId, -// product, -// db, -// org, -// env, -// isCanceled, -// }); - -// const stripeSubs = await stripeCli.subscriptions.list({ -// customer: customer.stripe_id!, -// }); -// if (multiInterval) { -// expect(stripeSubs.data.length).to.equal(2, "should have 2 subscriptions"); -// } else { -// expect(stripeSubs.data.length).to.equal( -// 1, -// "should only have 1 subscription" -// ); -// } -// }; - -export const addPrefixToProducts = ({ - products, - prefix, -}: { - products: ProductV2[]; - prefix: string; -}) => { - for (const product of products) { - product.id = `${prefix}_${product.id}`; - product.name = `${product.name} ${prefix}`; - product.group = prefix; - } - - return products; -}; - -export const replaceItems = ({ - featureId, - interval, - newItem, - items, -}: { - featureId?: string; - interval?: BillingInterval; - newItem: ProductItem; - items: ProductItem[]; -}) => { - const newItems = structuredClone(items); - - let index; - if (featureId) { - index = newItems.findIndex((item) => item.feature_id === featureId); - } - - if (interval) { - index = newItems.findIndex( - (item) => item.interval === (interval as any) && nullish(item.feature_id), - ); - } - - if (index === -1) { - throw new Error("Item not found"); - } - - newItems[index!] = newItem; - - return newItems; -}; diff --git a/server/tests/external-psps/revenuecat/revenuecat-migration.test.ts b/server/tests/external-psps/revenuecat/revenuecat-migration.test.ts deleted file mode 100644 index cc321a365..000000000 --- a/server/tests/external-psps/revenuecat/revenuecat-migration.test.ts +++ /dev/null @@ -1,211 +0,0 @@ -import { beforeAll, describe, expect, test } from "bun:test"; -import { - ApiVersion, - AppEnv, - CusProductStatus, - customers, - ProcessorType, -} from "@autumn/shared"; -import { replaceItems } from "@tests/attach/utils.js"; -import { TestFeature } from "@tests/setup/v2Features.js"; -import ctx from "@tests/utils/testInitUtils/createTestContext.js"; -import chalk from "chalk"; -import { eq } from "drizzle-orm"; -import { AutumnInt } from "@/external/autumn/autumnCli.js"; -import { RCMappingService } from "@/external/revenueCat/misc/RCMappingService.js"; -import { CusProductService } from "@/internal/customers/cusProducts/CusProductService.js"; -import { OrgService } from "@/internal/orgs/OrgService.js"; -import { encryptData } from "@/utils/encryptUtils.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 { expectFeaturesCorrect } from "../../utils/expectUtils/expectFeaturesCorrect.js"; -import { timeout } from "../../utils/genUtils.js"; -import { - expectWebhookSuccess, - RevenueCatWebhookClient, -} from "./utils/revenue-cat-webhook-client.js"; - -const testCase = "rcMigration1"; -const RC_WEBHOOK_SECRET = "test_rc_webhook_secret_migration"; - -// RevenueCat product ID -const RC_PRO_MONTHLY_ID = "com.app.migration_pro_monthly"; - -// Autumn product definitions -const messagesFeature = constructFeatureItem({ - featureId: TestFeature.Messages, - includedUsage: 1000, -}); - -const proMonthlyV1 = constructProduct({ - id: `${testCase}-pro-monthly`, - type: "pro", - items: [messagesFeature], - isDefault: false, -}); - -const proMonthlyV2 = { - ...proMonthlyV1, - version: 2, - items: replaceItems({ - items: proMonthlyV1.items, - featureId: TestFeature.Messages, - newItem: constructFeatureItem({ - featureId: TestFeature.Messages, - includedUsage: 2000, - }), - }), -}; - -describe( - chalk.yellowBright("rcMigration1: RevenueCat customer migration"), - () => { - const customerId = `${testCase}-customer`; - const autumnV1 = new AutumnInt({ version: ApiVersion.V1_2 }); - let internalCustomerId: string | null = null; - let rcClient: RevenueCatWebhookClient; - - beforeAll(async () => { - // 1. Configure org with RevenueCat processor config - if ( - ctx.org.processor_configs?.revenuecat?.sandbox_webhook_secret !== - RC_WEBHOOK_SECRET - ) { - await OrgService.update({ - db: ctx.db, - orgId: ctx.org.id, - updates: { - processor_configs: { - ...ctx.org.processor_configs, - revenuecat: { - api_key: encryptData("mock_rc_api_key_live"), - sandbox_api_key: encryptData("mock_rc_api_key_sandbox"), - project_id: "mock_project_live", - sandbox_project_id: "mock_project_sandbox", - webhook_secret: RC_WEBHOOK_SECRET, - sandbox_webhook_secret: RC_WEBHOOK_SECRET, - }, - }, - }, - }); - } - - // 2. Create product and mappings - await initProductsV0({ - ctx, - products: [proMonthlyV1], - prefix: testCase, - customerId, - }); - - await RCMappingService.upsert({ - db: ctx.db, - data: { - org_id: ctx.org.id, - env: AppEnv.Sandbox, - autumn_product_id: proMonthlyV1.id, - revenuecat_product_ids: [RC_PRO_MONTHLY_ID], - }, - }); - - // 3. Create customer - await initCustomerV3({ - ctx, - customerId, - withTestClock: false, - }); - - const dbCustomer = await ctx.db.query.customers.findFirst({ - where: eq(customers.id, customerId), - }); - expect(dbCustomer).toBeDefined(); - internalCustomerId = dbCustomer!.internal_id; - - // Initialize RevenueCat webhook client - rcClient = new RevenueCatWebhookClient({ - orgId: ctx.org.id, - env: ctx.env, - webhookSecret: RC_WEBHOOK_SECRET, - }); - }); - - test("should create customer with pro monthly v1 product via initial purchase", async () => { - const { response, data } = await rcClient.initialPurchase({ - productId: RC_PRO_MONTHLY_ID, - appUserId: customerId, - originalTransactionId: "migration_tx_12345", - }); - - expectWebhookSuccess({ response, data }); - - const customer = await autumnV1.customers.get(customerId); - expect(customer).toBeDefined(); - expect(customer.products).toHaveLength(1); - expect(customer.products[0].id).toBe(proMonthlyV1.id); - - // Verify cus_product has RevenueCat processor - const cusProducts = await CusProductService.list({ - db: ctx.db, - internalCustomerId: internalCustomerId!, - inStatuses: [CusProductStatus.Active], - }); - expect(cusProducts).toHaveLength(1); - expect(cusProducts[0].processor?.type).toBe(ProcessorType.RevenueCat); - }); - - test("should create v2 of the product with updated features", async () => { - // Create v2 with increased usage - const newItems = replaceItems({ - items: proMonthlyV1.items, - featureId: TestFeature.Messages, - newItem: constructFeatureItem({ - featureId: TestFeature.Messages, - includedUsage: 2000, // Increased from 1000 - }), - }); - - await autumnV1.products.update(proMonthlyV1.id, { - items: newItems, - }); - }); - - test("should migrate customer from v1 to v2", async () => { - await autumnV1.track({ - customer_id: customerId, - value: 500, - feature_id: TestFeature.Messages, - }); - - await timeout(2000); - - // Run migration via API - await autumnV1.migrate({ - from_product_id: proMonthlyV1.id, - to_product_id: proMonthlyV1.id, - from_version: 1, - to_version: 2, - }); - - await timeout(5000); - - const customer = await autumnV1.customers.get(customerId); - expect(customer).toBeDefined(); - expect(customer.products).toHaveLength(1); - expect(customer.products[0].id).toBe(proMonthlyV1.id); - expect(customer.products[0].version).toBe(2); - - expectFeaturesCorrect({ - customer, - product: proMonthlyV2, - usage: [ - { - featureId: TestFeature.Messages, - value: 500, - }, - ], - }); - }); - }, -); diff --git a/server/tests/external-psps/revenuecat/revenuecat-webhooks.test.ts b/server/tests/external-psps/revenuecat/revenuecat-webhooks.test.ts deleted file mode 100644 index 0f036f7eb..000000000 --- a/server/tests/external-psps/revenuecat/revenuecat-webhooks.test.ts +++ /dev/null @@ -1,395 +0,0 @@ -import { beforeAll, describe, expect, test } from "bun:test"; -import { - ApiVersion, - AppEnv, - CusProductStatus, - customers, -} from "@autumn/shared"; -import { TestFeature } from "@tests/setup/v2Features.js"; -import ctx from "@tests/utils/testInitUtils/createTestContext.js"; -import chalk from "chalk"; -import { eq } from "drizzle-orm"; -import { AutumnInt } from "@/external/autumn/autumnCli.js"; -import { RCMappingService } from "@/external/revenueCat/misc/RCMappingService.js"; -import { CusProductService } from "@/internal/customers/cusProducts/CusProductService.js"; -import { OrgService } from "@/internal/orgs/OrgService.js"; -import { encryptData } from "@/utils/encryptUtils.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 { - expectWebhookSuccess, - RevenueCatWebhookClient, -} from "./utils/revenue-cat-webhook-client.js"; - -const testCase = "rc1"; -const RC_WEBHOOK_SECRET = "test_rc_webhook_secret_12345"; - -// RevenueCat product IDs (what RC sends in webhooks) -const RC_PRO_MONTHLY_ID = "com.app.pro_monthly"; -const RC_PRO_YEARLY_ID = "com.app.pro_yearly"; -const RC_ADD_ON_ID = "com.app.add_on_pack"; - -// Autumn product definitions -const messagesFeature = constructFeatureItem({ - featureId: TestFeature.Messages, - includedUsage: 1000, -}); - -const proMonthly = constructProduct({ - id: `${testCase}-pro-monthly`, - type: "pro", - items: [messagesFeature], - isDefault: false, -}); - -const proYearly = constructProduct({ - id: `${testCase}-pro-yearly`, - type: "pro", - isAnnual: true, - items: [ - constructFeatureItem({ - featureId: TestFeature.Messages, - includedUsage: 1000, - }), - ], - isDefault: false, -}); - -const addOnPack = constructProduct({ - id: `${testCase}-add-on`, - type: "one_off", - items: [ - constructFeatureItem({ - featureId: TestFeature.Messages, - includedUsage: 100, - }), - ], - isAddOn: true, - isDefault: false, -}); - -describe(chalk.yellowBright("rc1: RevenueCat webhook integration"), () => { - const customerId = `${testCase}-customer`; - const autumnV1 = new AutumnInt({ version: ApiVersion.V1_2 }); - let proMonthlyCusProductId: string | null = null; - let internalCustomerId: string | null = null; - let rcClient: RevenueCatWebhookClient; - - const fetchLatestActiveCusProductId = async () => { - if (!internalCustomerId) { - throw new Error("internalCustomerId not set"); - } - - const cusProducts = await CusProductService.list({ - db: ctx.db, - internalCustomerId, - inStatuses: [ - CusProductStatus.Active, - CusProductStatus.PastDue, - CusProductStatus.Scheduled, - ], - }); - - const activeSorted = cusProducts - .filter( - (cp) => - cp.status === CusProductStatus.Active || - cp.status === CusProductStatus.PastDue, - ) - .sort((a, b) => (a.created_at ?? 0) - (b.created_at ?? 0)); - - expect( - activeSorted.length > 0, - "Expected at least one active cus_product for customer", - ).toBe(true); - - // Return the latest active cus_product id for the customer - return activeSorted[activeSorted.length - 1]!.id; - }; - - const fetchLatestCusProductIdAnyStatus = async () => { - if (!internalCustomerId) { - throw new Error("internalCustomerId not set"); - } - - const cusProducts = await CusProductService.list({ - db: ctx.db, - internalCustomerId, - inStatuses: undefined, - }); - - if (cusProducts.length === 0) { - return null; - } - - const sorted = [...cusProducts].sort( - (a, b) => (a.created_at ?? 0) - (b.created_at ?? 0), - ); - - return sorted[sorted.length - 1]!.id; - }; - - const getBaselineCusProductId = () => { - if (!proMonthlyCusProductId) { - throw new Error( - "Baseline CusProduct ID was not set from initial purchase", - ); - } - - return proMonthlyCusProductId; - }; - const updateBaselineCusProductId = (cusProductId: string) => { - proMonthlyCusProductId = cusProductId; - }; - - beforeAll(async () => { - // 1. Configure org with RevenueCat processor config - if ( - ctx.org.processor_configs?.revenuecat?.sandbox_webhook_secret !== - RC_WEBHOOK_SECRET - ) { - await OrgService.update({ - db: ctx.db, - orgId: ctx.org.id, - updates: { - processor_configs: { - ...ctx.org.processor_configs, - revenuecat: { - api_key: encryptData("mock_rc_api_key_live"), - sandbox_api_key: encryptData("mock_rc_api_key_sandbox"), - project_id: "mock_project_live", - sandbox_project_id: "mock_project_sandbox", - webhook_secret: RC_WEBHOOK_SECRET, - sandbox_webhook_secret: RC_WEBHOOK_SECRET, - }, - }, - }, - }); - } - - // Initialize RevenueCat webhook client - rcClient = new RevenueCatWebhookClient({ - orgId: ctx.org.id, - env: ctx.env, - webhookSecret: RC_WEBHOOK_SECRET, - }); - - // 2-4. Create products, mappings, and customer concurrently - await Promise.all([ - initProductsV0({ - ctx, - products: [proMonthly, proYearly, addOnPack], - prefix: testCase, - }), - RCMappingService.upsert({ - db: ctx.db, - data: { - org_id: ctx.org.id, - env: AppEnv.Sandbox, - autumn_product_id: proMonthly.id, - revenuecat_product_ids: [RC_PRO_MONTHLY_ID], - }, - }), - RCMappingService.upsert({ - db: ctx.db, - data: { - org_id: ctx.org.id, - env: AppEnv.Sandbox, - autumn_product_id: addOnPack.id, - revenuecat_product_ids: [RC_ADD_ON_ID], - }, - }), - RCMappingService.upsert({ - db: ctx.db, - data: { - org_id: ctx.org.id, - env: AppEnv.Sandbox, - autumn_product_id: proYearly.id, - revenuecat_product_ids: [RC_PRO_YEARLY_ID], - }, - }), - initCustomerV3({ - ctx, - customerId, - withTestClock: false, - }), - ]); - - const dbCustomer = await ctx.db.query.customers.findFirst({ - where: eq(customers.id, customerId), - }); - expect(dbCustomer).toBeDefined(); - internalCustomerId = dbCustomer!.internal_id; - }); - - test("should create customer with pro monthly product", async () => { - const result = await rcClient.initialPurchase({ - productId: RC_PRO_MONTHLY_ID, - appUserId: customerId, - originalTransactionId: "1234567890", - }); - expectWebhookSuccess(result); - - const customer = await autumnV1.customers.get(customerId); - expect(customer).toBeDefined(); - expect(customer.id).toBe(customerId); - expect(customer.products).toHaveLength(1); - expect(customer.products[0].id).toBe(proMonthly.id); - - proMonthlyCusProductId = await fetchLatestActiveCusProductId(); - }); - - test("should upgrade customer to pro yearly product upon renewal", async () => { - const result = await rcClient.renewal({ - productId: RC_PRO_YEARLY_ID, - appUserId: customerId, - originalTransactionId: "1234567890", - }); - expectWebhookSuccess(result); - - await fetchLatestActiveCusProductId(); - const customer = await autumnV1.customers.get(customerId); - expect(customer).toBeDefined(); - expect(customer.id).toBe(customerId); - expect(customer.products).toHaveLength(1); - expect(customer.products[0].id).toBe(proYearly.id); - }); - - test("should downgrade customer to pro monthly product upon initial purchase", async () => { - const result = await rcClient.initialPurchase({ - productId: RC_PRO_MONTHLY_ID, - appUserId: customerId, - originalTransactionId: "1234567890", - }); - expectWebhookSuccess(result); - - const customer = await autumnV1.customers.get(customerId); - expect(customer).toBeDefined(); - expect(customer.id).toBe(customerId); - expect(customer.products).toHaveLength(1); - expect(customer.products[0].id).toBe(proMonthly.id); - - const currentCusProductId = await fetchLatestActiveCusProductId(); - console.log("currentCusProductId", currentCusProductId); - expect(currentCusProductId).not.toBe(getBaselineCusProductId()); - updateBaselineCusProductId(currentCusProductId); - }); - - test("should go to cancelling state upon cancellation", async () => { - const result = await rcClient.cancellation({ - productId: RC_PRO_MONTHLY_ID, - appUserId: customerId, - originalTransactionId: "1234567890", - expirationAtMs: Date.now() + 1000 * 60 * 60 * 24 * 30, - }); - expectWebhookSuccess(result); - - const customer = await autumnV1.customers.get(customerId); - expect(customer).toBeDefined(); - expect(customer.id).toBe(customerId); - expect(customer.products).toHaveLength(1); - expect(customer.products[0].id).toBe(proMonthly.id); - const canceledAt = customer.products[0].canceled_at ?? 0; - expect(typeof canceledAt).toBe("number"); - expect(Math.abs(Date.now() - canceledAt)).toBeLessThanOrEqual(3000); - - const currentCusProductId = await fetchLatestActiveCusProductId(); - expect(currentCusProductId).toBe(getBaselineCusProductId()); - }); - - test("should uncancel customer after cancellation event", async () => { - const result = await rcClient.uncancellation({ - productId: RC_PRO_MONTHLY_ID, - appUserId: customerId, - }); - expectWebhookSuccess(result); - - const customer = await autumnV1.customers.get(customerId); - expect(customer).toBeDefined(); - expect(customer.id).toBe(customerId); - expect(customer.products).toHaveLength(1); - expect(customer.products[0].id).toBe(proMonthly.id); - expect(customer.products[0].canceled_at).toBeNull(); - - const currentCusProductId = await fetchLatestActiveCusProductId(); - expect(currentCusProductId).toBe(getBaselineCusProductId()); - }); - - test("should mark product as past due upon billing issue", async () => { - const result = await rcClient.billingIssue({ - productId: RC_PRO_MONTHLY_ID, - appUserId: customerId, - originalTransactionId: "1234567890", - }); - expectWebhookSuccess(result); - - const customer = await autumnV1.customers.get(customerId); - expect(customer).toBeDefined(); - expect(customer.id).toBe(customerId); - expect(customer.products).toHaveLength(1); - expect(customer.products[0].id).toBe(proMonthly.id); - expect(String(customer.products[0].status)).toBe("past_due"); - - const currentCusProductId = await fetchLatestActiveCusProductId(); - expect(currentCusProductId).toBe(getBaselineCusProductId()); - }); - - test("should go to expired state upon expiration", async () => { - const result = await rcClient.expiration({ - productId: RC_PRO_MONTHLY_ID, - appUserId: customerId, - originalTransactionId: "1234567890", - }); - expectWebhookSuccess(result); - - const customer = await autumnV1.customers.get(customerId); - expect(customer).toBeDefined(); - expect(customer.id).toBe(customerId); - expect(customer.products).toHaveLength(0); - - const latestCusProductId = await fetchLatestCusProductIdAnyStatus(); - // After expiration, there may no longer be a cus_product row at all. In that - // case, we just assert there are no cus_products for this customer anymore. - if (latestCusProductId === null) { - const allCusProducts = await CusProductService.list({ - db: ctx.db, - internalCustomerId: internalCustomerId!, - inStatuses: undefined, - }); - expect(allCusProducts.length).toBe(0); - } else { - expect(latestCusProductId).toBe(getBaselineCusProductId()); - } - }); - - test("should attach add-on product after expiration via non-renewing purchase", async () => { - const result = await rcClient.nonRenewingPurchase({ - productId: RC_ADD_ON_ID, - appUserId: customerId, - originalTransactionId: "add_on_tx_12345", - }); - expectWebhookSuccess(result); - - const customer = await autumnV1.customers.get(customerId); - expect(customer).toBeDefined(); - expect(customer.id).toBe(customerId); - expect(customer.products).toHaveLength(1); - expect(customer.products[0].id).toBe(addOnPack.id); - - const addOnCusProducts = await CusProductService.getByProductId({ - db: ctx.db, - productId: addOnPack.id, - orgId: ctx.org.id, - env: ctx.env, - limit: 1, - }); - expect( - addOnCusProducts.length > 0, - `CusProduct for add-on product ${addOnPack.id} should exist`, - ).toBe(true); - const addOnCusProductId = addOnCusProducts[0]!.id; - expect(typeof addOnCusProductId).toBe("string"); - }); -}); diff --git a/server/tests/integration/billing/legacy/attach/new/legacy-addon-merged.test.ts b/server/tests/integration/billing/legacy/attach/addon/legacy-addon-merged.test.ts similarity index 100% rename from server/tests/integration/billing/legacy/attach/new/legacy-addon-merged.test.ts rename to server/tests/integration/billing/legacy/attach/addon/legacy-addon-merged.test.ts diff --git a/server/tests/integration/billing/legacy/attach/addon/legacy-addon.test.ts b/server/tests/integration/billing/legacy/attach/addon/legacy-addon.test.ts new file mode 100644 index 000000000..7569a6332 --- /dev/null +++ b/server/tests/integration/billing/legacy/attach/addon/legacy-addon.test.ts @@ -0,0 +1,244 @@ +/** + * Legacy Add-on Attach Tests + * + * Migrated from: + * - server/tests/attach/addOn/addOn1.test.ts (attach pro then free add-on) + * - server/tests/attach/addOn/addOn2.test.ts (attach pro then free add-on with credits) + * - server/tests/attach/basic/basic2.test.ts (attach pro then monthly prepaid add-on with quantity) + * + * Tests V1 attach (s.attach) behavior for: + * - Attaching a free add-on after a base product + * - Verifying both products appear as active + * - Verifying feature balances after add-on attachment + * - Attaching prepaid add-ons with quantity options + */ + +import { expect, test } from "bun:test"; +import type { ApiCustomerV1, ApiCustomerV3 } from "@autumn/shared"; +import { AutumnCli } from "@tests/cli/AutumnCli"; +import { expectCustomerFeatureCorrect } from "@tests/integration/billing/utils/expectCustomerFeatureCorrect"; +import { expectCustomerInvoiceCorrect } from "@tests/integration/billing/utils/expectCustomerInvoiceCorrect"; +import { expectCustomerProducts } from "@tests/integration/billing/utils/expectCustomerProductCorrect"; +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"; + +// ═══════════════════════════════════════════════════════════════════════════════ +// TEST 1: Attach pro then free add-on with messages +// (from addOn1) +// +// Scenario: +// - Pro product ($20/month) with Messages feature (100 included) +// - Free add-on with Messages feature (200 included) +// - Attach Pro, then attach add-on +// +// Expected: +// - Customer has both Pro and add-on active +// - 1 invoice for $20 (Pro subscription only, add-on is free) +// - Messages balance = 100 (Pro) + 200 (add-on) = 300 +// ═══════════════════════════════════════════════════════════════════════════════ + +test.concurrent(`${chalk.yellowBright("legacy-addon 1: attach pro then free add-on with messages")}`, async () => { + const customerId = "legacy-addon-1"; + + const messagesItem = items.monthlyMessages({ includedUsage: 100 }); + const pro = products.pro({ id: "pro", items: [messagesItem] }); + + const addOnMessagesItem = items.monthlyMessages({ includedUsage: 200 }); + const addOn = products.base({ + id: "addon", + items: [addOnMessagesItem], + isAddOn: true, + }); + + const { autumnV1 } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success", testClock: true }), + s.products({ list: [pro, addOn] }), + ], + actions: [ + s.attach({ productId: pro.id }), + s.attach({ productId: addOn.id }), + ], + }); + + const customer = await autumnV1.customers.get(customerId); + + await expectCustomerProducts({ + customer, + active: [pro.id, addOn.id], + }); + + await expectCustomerInvoiceCorrect({ + customer, + count: 1, + latestTotal: 20, + }); + + expectCustomerFeatureCorrect({ + customer, + featureId: TestFeature.Messages, + balance: 300, + usage: 0, + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════════ +// TEST 2: Attach pro then free add-on twice +// (from addOn2) +// +// Scenario: +// - Pro product ($20/month) with no feature items +// - Free add-on with Credits feature (100 included) +// - Attach Pro, then attach add-on, then attach add-on again +// +// Expected: +// - Customer has both Pro and add-on active after each attachment +// - Credits feature balance remains 100 after second attach +// ═══════════════════════════════════════════════════════════════════════════════ + +test.concurrent(`${chalk.yellowBright("legacy-addon 2: attach pro then free add-on twice")}`, async () => { + const customerId = "legacy-addon-2"; + + const pro = products.pro({ id: "pro", items: [] }); + + const creditsItem = items.monthlyCredits(); + const addOn = products.base({ + id: "addon", + items: [creditsItem], + isAddOn: true, + }); + + const { autumnV1 } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success", testClock: true }), + s.products({ list: [pro, addOn] }), + ], + actions: [ + s.attach({ productId: pro.id }), + s.attach({ productId: addOn.id }), + ], + }); + + const customer = await autumnV1.customers.get(customerId); + + await expectCustomerProducts({ + customer, + active: [pro.id, addOn.id], + }); + + expectCustomerFeatureCorrect({ + customer, + featureId: TestFeature.Credits, + balance: 100, + usage: 0, + }); + + // Attach the same free add-on a second time + await autumnV1.attach({ + customer_id: customerId, + product_id: `${addOn.id}_${customerId}`, + }); + + const customerAfter = await autumnV1.customers.get(customerId); + + await expectCustomerProducts({ + customer: customerAfter, + active: [pro.id, addOn.id], + }); + + expectCustomerFeatureCorrect({ + customer: customerAfter, + featureId: TestFeature.Credits, + balance: 100, + usage: 0, + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════════ +// TEST 3: Attach pro then monthly prepaid add-on with quantity +// (from basic2) +// +// Scenario: +// - Pro product ($20/month) with Messages feature (10 included) +// - Monthly prepaid add-on for Messages ($9/250 units, 0 included) +// - Attach Pro, then attach add-on with quantity 500 (2 packs) +// +// Expected: +// - Customer has both Pro and add-on active +// - 2 invoices (Pro $20, add-on $18) +// - Messages balance = 10 (Pro) + 500 (add-on) = 510 +// - /check returns correct combined balance (using V0 entitled endpoint) +// ═══════════════════════════════════════════════════════════════════════════════ + +test.concurrent(`${chalk.yellowBright("legacy-addon 3: attach pro then monthly prepaid add-on with quantity")}`, async () => { + const customerId = "legacy-addon-3"; + const monthlyQuantity = 500; + + const messagesItem = items.monthlyMessages({ includedUsage: 10 }); + const pro = products.pro({ id: "pro", items: [messagesItem] }); + + const prepaidMessagesItem = items.prepaidMessages({ + price: 9, + billingUnits: 250, + includedUsage: 0, + }); + const addOn = products.base({ + id: "monthly-add-on", + items: [prepaidMessagesItem], + isAddOn: true, + }); + + await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success", testClock: true }), + s.products({ list: [pro, addOn] }), + ], + actions: [s.attach({ productId: pro.id })], + }); + + // Attach the prepaid add-on with quantity options (using AutumnCli like original) + await AutumnCli.attach({ + customerId, + productId: addOn.id, + forceCheckout: false, + options: [ + { + feature_id: TestFeature.Messages, + quantity: monthlyQuantity, + }, + ], + }); + + // Use AutumnCli.getCustomer for V1 response format (entitlements, add_ons) + const cusRes = await AutumnCli.getCustomer(customerId) as ApiCustomerV1; + + // Pro gives 10 Messages, add-on gives 500 + const expectedBalance = 10 + monthlyQuantity; + + const monthlyMessagesBalance = cusRes.entitlements.find( + (e) => e.feature_id === TestFeature.Messages && e.interval === "month", + ); + expect(monthlyMessagesBalance?.balance).toBe(expectedBalance); + + expect(cusRes.add_ons).toHaveLength(1); + const monthlyAddOnFound = cusRes.add_ons.find((a) => a.id === addOn.id); + expect(monthlyAddOnFound).toBeDefined(); + + expect(cusRes.invoices.length).toBe(2); + + // Verify /entitled returns correct balance (V0 endpoint with balances array) + const entitledRes = await AutumnCli.entitled(customerId, TestFeature.Messages) as { + allowed: boolean; + balances: { feature_id: string; balance: number }[]; + }; + const messagesBalance = entitledRes.balances.find( + (b) => b.feature_id === TestFeature.Messages, + ); + expect(messagesBalance?.balance).toBe(expectedBalance); +}); diff --git a/server/tests/integration/billing/legacy/attach/attach-edge-cases.test.ts b/server/tests/integration/billing/legacy/attach/attach-edge-cases.test.ts index cdaa0096c..b4328bc76 100644 --- a/server/tests/integration/billing/legacy/attach/attach-edge-cases.test.ts +++ b/server/tests/integration/billing/legacy/attach/attach-edge-cases.test.ts @@ -1,10 +1,16 @@ import { expect, test } from "bun:test"; +import type { ApiCustomerV3 } from "@autumn/shared"; +import { expectCustomerProducts } from "@tests/integration/billing/utils/expectCustomerProductCorrect"; +import { TestFeature } from "@tests/setup/v2Features"; +import { completeInvoiceCheckoutV2 as completeInvoiceCheckout } from "@tests/utils/browserPool/completeInvoiceCheckoutV2"; +import { expectAutumnError } from "@tests/utils/expectUtils/expectErrUtils"; import { expectProductAttached } from "@tests/utils/expectUtils/expectProductAttached.js"; import { items } from "@tests/utils/fixtures/items.js"; import { products } from "@tests/utils/fixtures/products.js"; -import { completeInvoiceCheckoutV2 as completeInvoiceCheckout } from "@tests/utils/browserPool/completeInvoiceCheckoutV2"; +import ctx from "@tests/utils/testInitUtils/createTestContext"; import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js"; import chalk from "chalk"; +import { CusService } from "@/internal/customers/CusService"; import { removeAllPaymentMethods } from "@/external/stripe/customers/paymentMethods/operations/removeAllPaymentMethods.js"; import { attachPaymentMethod } from "@/utils/scriptUtils/initCustomer.js"; @@ -107,3 +113,159 @@ test.concurrent(`${chalk.yellowBright("attach: pro then upgrade to premium with product: premium, }); }); + +// ═══════════════════════════════════════════════════════════════════════════════ +// TEST 3: Create customer and entity with null IDs (autumn_id generation) +// (from others6) +// +// Scenario: +// - Create customer with id=null → gets autumn_id +// - Create entity with id=null → gets autumn_id +// - Attach pro product with invoice option +// - Then assign external ID to the same customer +// +// Expected: +// - Customer and entity get autumn_id when created with null +// - Can attach product using autumn_id +// - Can later assign external ID to same customer +// ═══════════════════════════════════════════════════════════════════════════════ + +test.concurrent(`${chalk.yellowBright("attach-edge-case 3: null customer_id and entity_id (autumn_id generation)")}`, async () => { + const customerId = "edge-case-null-ids"; + const email = `${customerId}@test.com`; + + const wordsItem = items.consumableWords(); + const pro = products.pro({ + id: "pro", + items: [wordsItem], + }); + + // Clean up any existing customer with this email + const existingCustomers = await CusService.getByEmail({ + db: ctx.db, + orgId: ctx.org.id, + env: ctx.env, + email, + }); + + const { autumnV1 } = await initScenario({ + setup: [s.products({ list: [pro], prefix: customerId })], + actions: [], + }); + + if (existingCustomers.length > 0) { + await autumnV1.customers.delete(existingCustomers[0].internal_id); + } + + // Create customer with id=null → should get autumn_id + const customer = await autumnV1.customers.create({ + id: null, + email, + name: customerId, + withAutumnId: true, + }); + + expect(customer.autumn_id).toBeDefined(); + const internalCustomerId = customer.autumn_id; + + // Create entity with id=null → should get autumn_id + const entity = await autumnV1.entities.create(internalCustomerId, { + id: null, + feature_id: TestFeature.Users, + }); + + expect(entity.autumn_id).toBeDefined(); + const internalEntityId = entity.autumn_id; + + // Attach pro product using autumn_ids, with invoice option + await autumnV1.attach({ + customer_id: internalCustomerId, + entity_id: internalEntityId, + product_id: pro.id, + invoice: true, + enable_product_immediately: true, + }); + + const customerAfterAttach = await autumnV1.customers.get(internalCustomerId); + + await expectCustomerProducts({ + customer: customerAfterAttach, + active: [pro.id], + }); + + expect(customerAfterAttach.invoices?.length).toBe(1); + expect(customerAfterAttach.invoices?.[0].status).toBe("draft"); + + // Now assign external ID to the same customer + const customerWithId = await autumnV1.customers.create({ + id: customerId, + email, + }); + + // Should be the same customer (same autumn_id) + expect(customerWithId.autumn_id).toBe(internalCustomerId); + + // Can now fetch by external ID + const customerById = await autumnV1.customers.get(customerId); + + await expectCustomerProducts({ + customer: customerById, + active: [pro.id], + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════════ +// TEST 4: Duplicate attach error (attach same product twice) +// (from others9) +// +// Scenario: +// - Free product with Words feature +// - Attach free product → success +// - Attach same free product again → error +// +// Expected: +// - First attach succeeds +// - Second attach throws AutumnError +// ═══════════════════════════════════════════════════════════════════════════════ + +test.concurrent(`${chalk.yellowBright("attach-edge-case 4: duplicate attach error")}`, async () => { + const customerId = "edge-case-duplicate-attach"; + + const wordsItem = items.monthlyWords({ includedUsage: 100 }); + const free = products.base({ + id: "free", + items: [wordsItem], + }); + + const { autumnV1 } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success", testClock: true }), + s.products({ list: [free] }), + ], + actions: [], + }); + + // First attach should succeed + await autumnV1.attach({ + customer_id: customerId, + product_id: free.id, + }); + + const customerAfterFirstAttach = await autumnV1.customers.get(customerId); + + await expectCustomerProducts({ + customer: customerAfterFirstAttach, + active: [free.id], + }); + + // Second attach should fail + await expectAutumnError({ + func: async () => { + await autumnV1.attach({ + customer_id: customerId, + product_id: free.id, + }); + }, + }); +}); diff --git a/server/tests/integration/billing/legacy/attach/attach-misc.test.ts b/server/tests/integration/billing/legacy/attach/attach-misc.test.ts index 3963255dc..d13eae3d0 100644 --- a/server/tests/integration/billing/legacy/attach/attach-misc.test.ts +++ b/server/tests/integration/billing/legacy/attach/attach-misc.test.ts @@ -1,5 +1,5 @@ import { expect, test } from "bun:test"; -import type { AppEnv } from "@autumn/shared"; +import { type AppEnv, ErrCode } from "@autumn/shared"; import { TestFeature } from "@tests/setup/v2Features.js"; import { getMainCusProduct } from "@tests/utils/cusProductUtils/cusProductUtils.js"; import { expectProductAttached } from "@tests/utils/expectUtils/expectProductAttached.js"; @@ -8,8 +8,9 @@ import { products } from "@tests/utils/fixtures/products.js"; import ctx from "@tests/utils/testInitUtils/createTestContext.js"; import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js"; import chalk from "chalk"; +import AutumnError from "@/external/autumn/autumnCli"; import { cusProductToSub } from "@/internal/customers/cusProducts/cusProductUtils/convertCusProduct.js"; -import { timeout } from "@/utils/genUtils.js"; +import { generateId, timeout } from "@/utils/genUtils.js"; // ============================================================================ // Test 1: Auto-create customer and entity via attach @@ -160,3 +161,72 @@ test.skip(`${chalk.yellowBright("attach-misc: attach race condition - concurrent const successes = responses.filter((r) => r.status === "fulfilled"); expect(successes.length).toEqual(1); }); + +// ============================================================================ +// Test 3: Idempotency key - duplicate requests rejected +// (from others10) +// ============================================================================ +test.concurrent(`${chalk.yellowBright("attach-misc: idempotency key duplicate rejected")}`, async () => { + const customerId = "attach-misc-idempotency"; + + const creditsItem = items.monthlyMessages({ includedUsage: 500 }); + const pro = products.pro({ + id: "pro", + items: [creditsItem], + }); + + const { autumnV1 } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success", testClock: true }), + s.products({ list: [pro] }), + ], + actions: [], + }); + + const idempotencyKey = generateId("it"); + + // Send two concurrent attach requests with the same idempotency key + const results = await Promise.allSettled([ + autumnV1.attach( + { + customer_id: customerId, + product_id: pro.id, + }, + { + idempotencyKey, + }, + ), + autumnV1.attach( + { + customer_id: customerId, + product_id: pro.id, + }, + { + idempotencyKey, + }, + ), + ]); + + // Exactly one request should succeed + const fulfilled = results.filter((r) => r.status === "fulfilled"); + const rejected = results.filter((r) => r.status === "rejected"); + + expect(fulfilled).toHaveLength(1); + expect(rejected).toHaveLength(1); + + // The successful request should have attached the product + const successResult = fulfilled[0] as PromiseFulfilledResult< + Awaited> + >; + expect(successResult.value.success).toBe(true); + expect(successResult.value.customer_id).toBe(customerId); + expect(successResult.value.product_ids).toContain(pro.id); + + // The rejected request should have the duplicate idempotency key error + const rejectedResult = rejected[0] as PromiseRejectedResult; + expect(rejectedResult.reason).toBeInstanceOf(AutumnError); + expect((rejectedResult.reason as AutumnError).code).toBe( + ErrCode.DuplicateIdempotencyKey, + ); +}); diff --git a/server/tests/integration/billing/legacy/attach/attach-response.test.ts b/server/tests/integration/billing/legacy/attach/attach-response.test.ts new file mode 100644 index 000000000..cd7efa0bd --- /dev/null +++ b/server/tests/integration/billing/legacy/attach/attach-response.test.ts @@ -0,0 +1,313 @@ +/** + * Legacy Attach Response Format Tests + * + * Migrated from: + * - server/tests/attach/response/attach-response1.test.ts (new, no card → checkout_url) + * - server/tests/attach/response/attach-response2.test.ts (upgrade response) + * - server/tests/attach/response/attach-response3.test.ts (downgrade response) + * - server/tests/attach/response/attach-response4.test.ts (new, card on file) + * - server/tests/attach/response/attach-response5.test.ts (one-off, card on file) + * + * Tests that attach responses have the correct shape for v0.2 and v1.2 API versions + * across different attach scenarios (new, upgrade, downgrade, one-off). + */ + +/** biome-ignore-all lint/suspicious/noExplicitAny: test file */ + +import { expect, test } from "bun:test"; +import { ApiVersion } from "@autumn/shared"; +import { items } from "@tests/utils/fixtures/items"; +import { products } from "@tests/utils/fixtures/products"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario"; +import chalk from "chalk"; +import { AutumnInt } from "@/external/autumn/autumnCli"; + +// ═══════════════════════════════════════════════════════════════════════════════ +// TEST 1: New attach with no card on file → returns checkout_url +// (from attach-response1) +// +// Scenario: +// - Pro product ($20/month) with consumable Words (1000 included) +// - Customer with NO payment method +// - v0.2: attach returns only { checkout_url } +// - v1.2: attach returns { customer_id, product_ids, checkout_url, code, message } +// ═══════════════════════════════════════════════════════════════════════════════ + +test.concurrent(`${chalk.yellowBright("attach-response 1: new attach, no card → checkout_url")}`, async () => { + const customerId = "attach-response-1"; + + const pro = products.pro({ + id: "pro", + items: [items.consumableWords({ includedUsage: 1000 })], + }); + + await initScenario({ + customerId, + setup: [s.customer({}), s.products({ list: [pro] })], + actions: [], + }); + + const autumnV0 = new AutumnInt({ version: ApiVersion.V0_2 }); + const autumnV1 = new AutumnInt({ version: ApiVersion.V1_2 }); + + // v0.2 response: only checkout_url + const v0Response = await autumnV0.attach({ + customer_id: customerId, + product_id: pro.id, + }); + + expect(v0Response.checkout_url).toBeDefined(); + expect(Object.keys(v0Response)).toEqual(["checkout_url"]); + + // v1.2 response: richer object + const v1Response = await autumnV1.attach({ + customer_id: customerId, + product_id: pro.id, + }); + + expect(v1Response).toMatchObject({ + customer_id: customerId, + product_ids: [pro.id], + checkout_url: expect.any(String), + }); + expect(v1Response.code).toBeDefined(); + expect(v1Response.message).toBeDefined(); +}); + +// ═══════════════════════════════════════════════════════════════════════════════ +// TEST 2: Upgrade response +// (from attach-response2) +// +// Scenario: +// - Pro ($20/month) and Premium ($50/month) with Words (1000 included) +// - Customer with payment method, attach Pro first +// - Upgrade to Premium +// - v0.2: returns { success, message } +// - v1.2: cancel Premium, re-attach Pro, then upgrade to Premium → +// returns { customer_id, product_ids, code, message } +// ═══════════════════════════════════════════════════════════════════════════════ + +test.concurrent(`${chalk.yellowBright("attach-response 2: upgrade response")}`, async () => { + const customerId1 = "attach-response-2-1"; + const customerId2 = "attach-response-2-2"; + + const wordsItem = items.monthlyWords({ includedUsage: 1000 }); + const pro = products.pro({ id: "pro", items: [wordsItem] }); + const premium = products.premium({ id: "premium", items: [wordsItem] }); + + const { autumnV1 } = await initScenario({ + customerId: customerId1, + setup: [ + s.customer({ paymentMethod: "success", testClock: true }), + s.products({ list: [pro, premium] }), + s.otherCustomers([{ id: customerId2, paymentMethod: "success" }]), + ], + actions: [s.attach({ productId: pro.id })], + }); + + const autumnV0 = new AutumnInt({ version: ApiVersion.V0_2 }); + + // v0.2 upgrade response + const v0Response = await autumnV0.attach({ + customer_id: customerId1, + product_id: premium.id, + }); + + expect(Object.keys(v0Response)).toEqual(["success", "message"]); + + await autumnV1.attach({ + customer_id: customerId2, + product_id: pro.id, + }); + + // v1.2 upgrade response + const v1Response = await autumnV1.attach({ + customer_id: customerId2, + product_id: premium.id, + }); + + expect(v1Response).toMatchObject({ + customer_id: customerId2, + product_ids: [premium.id], + code: expect.any(String), + message: expect.any(String), + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════════ +// TEST 3: Downgrade response +// (from attach-response3) +// +// Scenario: +// - Pro ($20/month) and Premium ($50/month) with Words (1000 included) +// - Customer with payment method, attach Premium first +// - Downgrade to Pro +// - v0.2: returns { success, message } +// - v1.2: uncancel Premium, then downgrade to Pro → +// returns { customer_id, product_ids, code, message } +// ═══════════════════════════════════════════════════════════════════════════════ + +test.concurrent(`${chalk.yellowBright("attach-response 3: downgrade response")}`, async () => { + const customerId = "attach-response-3"; + + const wordsItem = items.monthlyWords({ includedUsage: 1000 }); + const pro = products.pro({ id: "pro", items: [wordsItem] }); + const premium = products.premium({ id: "premium", items: [wordsItem] }); + + const { autumnV1 } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success", testClock: true }), + s.products({ list: [pro, premium] }), + ], + actions: [s.attach({ productId: premium.id })], + }); + + const autumnV0 = new AutumnInt({ version: ApiVersion.V0_2 }); + + // v0.2 downgrade response + const v0Response = await autumnV0.attach({ + customer_id: customerId, + product_id: pro.id, + }); + + expect(Object.keys(v0Response)).toEqual(["success", "message"]); + + // Reset: uncancel Premium for v1.2 test + await autumnV1.subscriptions.update({ + customer_id: customerId, + product_id: premium.id, + cancel_action: "uncancel", + }); + + // v1.2 downgrade response + const v1Response = await autumnV1.attach({ + customer_id: customerId, + product_id: pro.id, + }); + + expect(v1Response).toMatchObject({ + customer_id: customerId, + product_ids: [pro.id], + code: expect.any(String), + message: expect.any(String), + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════════ +// TEST 4: New attach with card on file → immediate success +// (from attach-response4) +// +// Scenario: +// - Pro ($20/month) with Words (1000 included) +// - Customer with payment method +// - v0.2: attach returns { success, message } +// - v1.2: cancel Pro, re-attach → +// returns { customer_id, product_ids, code, message } +// ═══════════════════════════════════════════════════════════════════════════════ + +test.concurrent(`${chalk.yellowBright("attach-response 4: new attach, card on file")}`, async () => { + const customerId = "attach-response-4"; + + const pro = products.pro({ + id: "pro", + items: [items.monthlyWords({ includedUsage: 1000 })], + }); + + const { autumnV1 } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success", testClock: true }), + s.products({ list: [pro] }), + ], + actions: [], + }); + + const autumnV0 = new AutumnInt({ version: ApiVersion.V0_2 }); + + // v0.2 response + const v0Response = await autumnV0.attach({ + customer_id: customerId, + product_id: pro.id, + }); + + expect(Object.keys(v0Response)).toEqual(["success", "message"]); + + // Reset: cancel Pro for v1.2 test + await autumnV1.cancel({ + customer_id: customerId, + product_id: pro.id, + cancel_immediately: true, + }); + + // v1.2 response + const v1Response = await autumnV1.attach({ + customer_id: customerId, + product_id: pro.id, + }); + + expect(v1Response).toMatchObject({ + customer_id: customerId, + product_ids: [pro.id], + code: expect.any(String), + message: expect.any(String), + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════════ +// TEST 5: One-off attach with card on file +// (from attach-response5) +// +// Scenario: +// - One-off product ($10) with lifetime Words (1000 included) +// - Customer with payment method +// - v0.2: attach returns { success, message } +// - v1.2: attach again (one-off can be attached multiple times) → +// returns { success, customer_id, product_ids, code, message } +// ═══════════════════════════════════════════════════════════════════════════════ + +test.concurrent(`${chalk.yellowBright("attach-response 5: one-off attach, card on file")}`, async () => { + const customerId = "attach-response-5"; + + const oneOff = products.oneOff({ + id: "one-off", + items: [items.lifetimeMessages({ includedUsage: 1000 })], + }); + + await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success", testClock: true }), + s.products({ list: [oneOff] }), + ], + actions: [], + }); + + const autumnV0 = new AutumnInt({ version: ApiVersion.V0_2 }); + const autumnV1 = new AutumnInt({ version: ApiVersion.V1_2 }); + + // v0.2 response + const v0Response = await autumnV0.attach({ + customer_id: customerId, + product_id: oneOff.id, + }); + + expect(v0Response).toMatchObject({ + success: true, + message: expect.any(String), + }); + + // v1.2 response (attach again — one-off can accumulate) + const v1Response = await autumnV1.attach({ + customer_id: customerId, + product_id: oneOff.id, + }); + + expect(v1Response).toMatchObject({ + success: true, + customer_id: customerId, + product_ids: [oneOff.id], + code: expect.any(String), + message: expect.any(String), + }); +}); diff --git a/server/tests/integration/billing/legacy/attach/checkout/legacy-checkout-advanced.test.ts b/server/tests/integration/billing/legacy/attach/checkout/legacy-checkout-advanced.test.ts new file mode 100644 index 000000000..4fed1ea61 --- /dev/null +++ b/server/tests/integration/billing/legacy/attach/checkout/legacy-checkout-advanced.test.ts @@ -0,0 +1,424 @@ +/** + * Legacy Checkout Advanced Tests + * + * Migrated from: + * - server/tests/attach/checkout/checkout4.test.ts (coupon/reward checkout) + * - server/tests/attach/checkout/checkout8.test.ts (prepaid with quantity=0) + * - server/tests/attach/upgrade/upgrade7.test.ts (force checkout after cancel) + * + * Tests V1 attach behavior with advanced checkout scenarios: + * - Coupons/rewards with percentage discounts + * - Prepaid items with quantity=0 edge case + * - Force checkout upgrade after cancel + */ + +import { expect, test } from "bun:test"; +import { type ApiCustomerV3, RewardType } from "@autumn/shared"; +import { expectCustomerFeatureCorrect } from "@tests/integration/billing/utils/expectCustomerFeatureCorrect"; +import { expectCustomerInvoiceCorrect } from "@tests/integration/billing/utils/expectCustomerInvoiceCorrect"; +import { + expectCustomerProducts, + expectProductActive, +} from "@tests/integration/billing/utils/expectCustomerProductCorrect"; +import { expectSubToBeCorrect } from "@tests/merged/mergeUtils/expectSubCorrect"; +import { TestFeature } from "@tests/setup/v2Features"; +import { completeStripeCheckoutFormV2 } from "@tests/utils/browserPool/completeStripeCheckoutFormV2"; +import { expectProductAttached } from "@tests/utils/expectUtils/expectProductAttached"; +import { items } from "@tests/utils/fixtures/items"; +import { products } from "@tests/utils/fixtures/products"; +import { timeout } from "@tests/utils/genUtils"; +import ctx from "@tests/utils/testInitUtils/createTestContext"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario"; +import chalk from "chalk"; +import { CusService } from "@/internal/customers/CusService"; +import { constructCoupon } from "@/utils/scriptUtils/createTestProducts"; + +// ═══════════════════════════════════════════════════════════════════════════════ +// TEST 1: Coupon/reward checkout +// (from checkout4) +// +// Scenario: +// - Pro product ($20/month) with Messages (100 included) +// - 50% discount coupon applied +// +// Expected: +// - Customer has Pro active +// - 1 invoice for $10 (50% of $20) +// ═══════════════════════════════════════════════════════════════════════════════ + +test.concurrent(`${chalk.yellowBright("legacy-checkout-adv 1: coupon/reward checkout")}`, async () => { + const customerId = "legacy-checkout-adv-1"; + const rewardId = "legacycheckoutadv1reward"; + + const messagesItem = items.monthlyMessages({ includedUsage: 100 }); + const pro = products.pro({ + id: "pro", + items: [messagesItem], + }); + + const reward = constructCoupon({ + id: rewardId, + promoCode: "LEGACYCHECKOUTADV1", + discountType: RewardType.PercentageDiscount, + discountValue: 50, + }); + + const { autumnV1 } = await initScenario({ + customerId, + setup: [ + s.customer({ testClock: true }), + s.products({ list: [pro] }), + s.reward({ reward, productId: pro.id }), + ], + actions: [], + }); + + // reward.id is modified in place by s.reward (suffixed with productPrefix) + const res = await autumnV1.attach({ + customer_id: customerId, + product_id: pro.id, + reward: reward.id, + }); + + await completeStripeCheckoutFormV2({ url: res.checkout_url }); + await timeout(10000); + + const customer = await autumnV1.customers.get(customerId); + + await expectProductActive({ + customer, + productId: pro.id, + }); + + await expectCustomerInvoiceCorrect({ + customer, + count: 1, + latestTotal: 10, // 50% of $20 + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════════ +// TEST 2: Prepaid with quantity=0 +// (from checkout8) +// +// Scenario: +// - Pro product ($20/month) with two one-off prepaid items: +// - Messages: $5/100 units +// - Words: $10/100 units +// - Attach with options: Messages quantity=0, Words quantity=4 (400 units) +// +// Expected: +// - Customer has Pro active +// - 1 invoice for $20 (base) + $40 (4x $10 for Words) = $60 +// - Messages balance = 0 +// - Words balance = 400 +// ═══════════════════════════════════════════════════════════════════════════════ + +test.concurrent(`${chalk.yellowBright("legacy-checkout-adv 2: prepaid with quantity=0")}`, async () => { + const customerId = "legacy-checkout-adv-2"; + + const messagesItem = items.oneOffMessages({ + price: 5, + billingUnits: 100, + includedUsage: 0, + }); + const wordsItem = items.oneOffWords({ + price: 10, + billingUnits: 100, + includedUsage: 0, + }); + const pro = products.pro({ + id: "pro", + items: [messagesItem, wordsItem], + }); + + const options = [ + { feature_id: TestFeature.Messages, quantity: 0 }, + { feature_id: TestFeature.Words, quantity: 400 }, + ]; + + const { autumnV1 } = await initScenario({ + customerId, + setup: [s.customer({ testClock: true }), s.products({ list: [pro] })], + actions: [], + }); + + const res = await autumnV1.attach({ + customer_id: customerId, + product_id: pro.id, + options, + }); + + await completeStripeCheckoutFormV2({ url: res.checkout_url }); + + const customer = await autumnV1.customers.get(customerId); + + await expectProductActive({ + customer, + productId: pro.id, + }); + + await expectCustomerInvoiceCorrect({ + customer, + count: 1, + latestTotal: 60, // $20 base + $40 (4 x $10 for Words) + }); + + expectCustomerFeatureCorrect({ + customer, + featureId: TestFeature.Messages, + balance: 0, + }); + + expectCustomerFeatureCorrect({ + customer, + featureId: TestFeature.Words, + includedUsage: 400, + balance: 400, // 4 packs x 100 units + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════════ +// TEST 3: Force checkout upgrade after cancel +// (from upgrade7) +// +// Scenario: +// - Pro product ($20/month) with no items +// - Premium product ($50/month) with no items +// - Attach Pro, then cancel immediately, then attach Premium with force_checkout +// +// Expected: +// - Customer has Premium active after flow +// - 2 invoices (Pro + Premium) +// ═══════════════════════════════════════════════════════════════════════════════ + +test.concurrent(`${chalk.yellowBright("legacy-checkout-adv 3: force checkout upgrade after cancel")}`, async () => { + const customerId = "legacy-checkout-adv-3"; + + const pro = products.pro({ id: "pro", items: [] }); + const premium = products.premium({ id: "premium", items: [] }); + + const { autumnV1 } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success", testClock: true }), + s.products({ list: [pro, premium] }), + ], + actions: [], + }); + + // Attach Pro + await autumnV1.attach({ + customer_id: customerId, + product_id: pro.id, + }); + + const customerAfterPro = await autumnV1.customers.get(customerId); + + await expectCustomerProducts({ + customer: customerAfterPro, + active: [pro.id], + }); + + await expectCustomerInvoiceCorrect({ + customer: customerAfterPro, + count: 1, + latestTotal: 20, // Pro $20 + }); + + // Cancel Pro immediately + await autumnV1.cancel({ + customer_id: customerId, + product_id: pro.id, + cancel_immediately: true, + }); + + const customerAfterCancel = await autumnV1.customers.get(customerId); + + await expectCustomerProducts({ + customer: customerAfterCancel, + active: [], + }); + + // Attach Premium with force_checkout + const res = await autumnV1.attach({ + customer_id: customerId, + product_id: premium.id, + force_checkout: true, + }); + + await completeStripeCheckoutFormV2({ url: res.checkout_url }); + + const customerAfterPremium = await autumnV1.customers.get(customerId); + + await expectCustomerProducts({ + customer: customerAfterPremium, + active: [premium.id], + }); + + await expectCustomerInvoiceCorrect({ + customer: customerAfterPremium, + count: 2, + latestTotal: 50, // Premium $50 + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════════ +// TEST 4: Separate subscriptions due to force checkout (per entity) +// (from separate2) +// +// Scenario: +// - Pro product ($20/month) with Messages (100 included) +// - Premium product ($50/month) with Messages (100 included) +// - Credits add-on (prepaid, $10/100 units) +// - 2 entities, attach Pro to each with force_checkout (separate checkouts) +// - Upgrade both to Premium +// - Attach add-on to entity 2 (should use entity 2's subscription) +// +// Expected: +// - Each entity gets a separate subscription (not merged) +// - Add-on attaches to correct entity's subscription +// ═══════════════════════════════════════════════════════════════════════════════ + +test.concurrent(`${chalk.yellowBright("legacy-checkout-adv 4: separate subs due to force checkout")}`, async () => { + const customerId = "legacy-checkout-adv-4"; + + const messagesItem = items.monthlyMessages({ includedUsage: 100 }); + const pro = products.pro({ id: "pro", items: [messagesItem] }); + const premium = products.premium({ id: "premium", items: [messagesItem] }); + + const addOnBillingUnits = 100; + const creditsItem = items.prepaidMessages({ + price: 10, + billingUnits: addOnBillingUnits, + includedUsage: 0, + }); + const addOn = products.base({ + id: "credits-addon", + items: [creditsItem], + }); + + const { autumnV1, entities } = await initScenario({ + customerId, + setup: [ + s.customer({ testClock: true }), + s.products({ list: [pro, premium, addOn] }), + s.entities({ count: 2, featureId: TestFeature.Users }), + ], + actions: [], + }); + + const { db, org, env } = ctx; + const subIds: string[] = []; + + // Attach Pro to each entity with force_checkout (creates separate subs) + for (const entity of entities) { + const res = await autumnV1.attach({ + customer_id: customerId, + product_id: pro.id, + entity_id: entity.id, + force_checkout: true, + }); + + expect(res.checkout_url).toBeDefined(); + await completeStripeCheckoutFormV2({ url: res.checkout_url }); + } + + // Verify separate subscriptions + const fullCus = await CusService.getFull({ + idOrInternalId: customerId, + db, + orgId: org.id, + env, + }); + + const cusProducts = fullCus.customer_products; + const entity1Prod = cusProducts.find((cp) => cp.entity_id === entities[0].id); + const entity2Prod = cusProducts.find((cp) => cp.entity_id === entities[1].id); + + const entity1SubId = entity1Prod?.subscription_ids?.[0]; + const entity2SubId = entity2Prod?.subscription_ids?.[0]; + + expect(entity1SubId).not.toBe(entity2SubId); + subIds.push(entity1SubId!); + subIds.push(entity2SubId!); + + await expectSubToBeCorrect({ + db, + customerId, + org, + env, + subId: entity1SubId, + }); + + // Upgrade both entities to Premium + for (const entity of entities) { + await autumnV1.attach({ + customer_id: customerId, + product_id: premium.id, + entity_id: entity.id, + }); + } + + // Verify subs still correct after upgrades + await expectSubToBeCorrect({ + db, + customerId, + org, + env, + subId: subIds[0], + }); + + await expectSubToBeCorrect({ + db, + customerId, + org, + env, + subId: subIds[1], + }); + + // Attach add-on to entity 2 + await autumnV1.attach({ + customer_id: customerId, + product_id: addOn.id, + entity_id: entities[1].id, + options: [ + { + feature_id: TestFeature.Messages, + quantity: addOnBillingUnits * 2, + }, + ], + }); + + // Verify add-on is on entity 2's subscription + const fullCusAfterAddOn = await CusService.getFull({ + idOrInternalId: customerId, + db, + orgId: org.id, + env, + }); + + const addOnProd = fullCusAfterAddOn.customer_products.find( + (cp) => cp.product.id === addOn.id, + ); + + expect(addOnProd).toBeDefined(); + const addOnSubId = addOnProd?.subscription_ids?.[0]; + expect(addOnSubId).toBe(subIds[1]); // Should be on entity 2's sub + + await expectSubToBeCorrect({ + db, + customerId, + org, + env, + subId: subIds[1], + }); + + // Verify entities have correct products + const entity1 = await autumnV1.entities.get(customerId, entities[0].id); + expectProductAttached({ customer: entity1, productId: premium.id }); + + const entity2 = await autumnV1.entities.get(customerId, entities[1].id); + expectProductAttached({ customer: entity2, productId: premium.id }); + expectProductAttached({ customer: entity2, productId: addOn.id }); +}); diff --git a/server/tests/integration/billing/legacy/attach/checkout/legacy-checkout-basic.test.ts b/server/tests/integration/billing/legacy/attach/checkout/legacy-checkout-basic.test.ts new file mode 100644 index 000000000..8389aef56 --- /dev/null +++ b/server/tests/integration/billing/legacy/attach/checkout/legacy-checkout-basic.test.ts @@ -0,0 +1,276 @@ +/** + * Legacy Checkout Basic Tests + * + * Migrated from: + * - server/tests/attach/checkout/checkout1.test.ts (basic stripe checkout) + * - server/tests/attach/checkout/checkout2.test.ts (one-time add-on via force_checkout) + * - server/tests/attach/checkout/checkout3.test.ts (multi-product checkout) + * + * Tests V1 attach behavior through Stripe checkout flows: + * - Basic product checkout via checkout URL + * - One-time add-on purchases with force_checkout + * - Multi-product checkout (product_ids array) + */ + +import { expect, test } from "bun:test"; +import type { + ApiCustomerV1, + ApiCustomerV3, + CheckResponseV0, +} from "@autumn/shared"; +import { AutumnCli } from "@tests/cli/AutumnCli"; +import { expectCustomerFeatureCorrect } from "@tests/integration/billing/utils/expectCustomerFeatureCorrect"; +import { expectCustomerInvoiceCorrect } from "@tests/integration/billing/utils/expectCustomerInvoiceCorrect"; +import { + expectCustomerProducts, + expectProductActive, +} from "@tests/integration/billing/utils/expectCustomerProductCorrect"; +import { TestFeature } from "@tests/setup/v2Features"; +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 { initScenario, s } from "@tests/utils/testInitUtils/initScenario"; +import chalk from "chalk"; + +// ═══════════════════════════════════════════════════════════════════════════════ +// TEST 1: Basic Stripe checkout +// (from checkout1) +// +// Scenario: +// - Pro product ($20/month) with Dashboard (boolean), Messages (10 included), Admin (unlimited) +// - Customer attaches via checkout URL +// +// Expected: +// - Customer has Pro active +// - 1 invoice for $20 +// - Check endpoint returns correct balances for all features +// ═══════════════════════════════════════════════════════════════════════════════ + +test.concurrent(`${chalk.yellowBright("legacy-checkout 1: basic stripe checkout")}`, async () => { + const customerId = "legacy-checkout-1"; + + const dashboardItem = items.dashboard(); + const messagesItem = items.monthlyMessages({ includedUsage: 10 }); + const adminItem = items.adminRights(); + const pro = products.pro({ + id: "pro", + items: [dashboardItem, messagesItem, adminItem], + }); + + const { autumnV1 } = await initScenario({ + customerId, + setup: [s.customer({ testClock: true }), s.products({ list: [pro] })], + actions: [], + }); + + // Attach via checkout URL + const { checkout_url } = await autumnV1.attach({ + customer_id: customerId, + product_id: pro.id, + }); + + await completeStripeCheckoutFormV2({ url: checkout_url }); + await timeout(12000); + + const customer = await autumnV1.customers.get(customerId); + + await expectProductActive({ + customer, + productId: pro.id, + }); + + await expectCustomerInvoiceCorrect({ + customer, + count: 1, + latestTotal: 20, + }); + + // Verify /check returns correct balances for each feature + const dashboardCheck = (await AutumnCli.entitled( + customerId, + TestFeature.Dashboard, + )) as CheckResponseV0; + expect(dashboardCheck.allowed).toBe(true); + + const messagesCheck = (await AutumnCli.entitled( + customerId, + TestFeature.Messages, + )) as CheckResponseV0; + expect(messagesCheck.allowed).toBe(true); + const messagesBalance = messagesCheck.balances.find( + (b) => b.feature_id === TestFeature.Messages, + ); + expect(messagesBalance?.balance).toBe(10); + + const adminCheck = (await AutumnCli.entitled( + customerId, + TestFeature.AdminRights, + )) as CheckResponseV0; + expect(adminCheck.allowed).toBe(true); +}); + +// ═══════════════════════════════════════════════════════════════════════════════ +// TEST 2: One-time add-on via force_checkout +// (from checkout2) +// +// Scenario: +// - Pro product ($20/month) with Dashboard (boolean), Messages (10 included), Admin (unlimited) +// - One-off add-on with prepaid messages ($9/250 units) +// - Attach Pro, then attach add-on twice via force_checkout +// +// Expected: +// - Customer has Pro and add-on +// - 3 invoices (Pro $20, add-on $9 x2) +// - Messages balance = 10 (Pro) + 500 (add-on purchases) + 500 (second purchase) = 1010 +// ═══════════════════════════════════════════════════════════════════════════════ + +test.concurrent(`${chalk.yellowBright("legacy-checkout 2: one-time add-on via force_checkout")}`, async () => { + const customerId = "legacy-checkout-2"; + const oneTimeQuantity = 500; + const oneTimeBillingUnits = 250; + const oneTimePurchaseCount = 2; + + const dashboardItem = items.dashboard(); + const messagesItem = items.monthlyMessages({ includedUsage: 10 }); + const adminItem = items.adminRights(); + const pro = products.pro({ + id: "pro", + items: [dashboardItem, messagesItem, adminItem], + }); + + const oneOffItem = items.oneOffMessages({ + price: 9, + billingUnits: oneTimeBillingUnits, + includedUsage: 0, + }); + const oneOff = products.oneOffAddOn({ + id: "one_off", + items: [oneOffItem], + }); + + const { autumnV1 } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success", testClock: true }), + s.products({ list: [pro, oneOff] }), + ], + actions: [s.attach({ productId: pro.id })], + }); + + // Purchase one-off add-on twice via force_checkout + for (let i = 0; i < oneTimePurchaseCount; i++) { + const res = await autumnV1.attach({ + customer_id: customerId, + product_id: oneOff.id, + force_checkout: true, + }); + + await completeStripeCheckoutFormV2({ + url: res.checkout_url, + overrideQuantity: oneTimeQuantity / oneTimeBillingUnits, + }); + } + + const cusRes = (await AutumnCli.getCustomer(customerId)) as ApiCustomerV1; + + // Find the add-on balance for Messages with lifetime interval (one-time purchase) + const addOnBalance = cusRes.entitlements.find( + (e) => e.feature_id === TestFeature.Messages && e.interval === "lifetime", + ); + const expectedAddOnBalance = oneTimeQuantity * oneTimePurchaseCount; + expect(addOnBalance?.balance).toBe(expectedAddOnBalance); + + expect(cusRes.add_ons).toHaveLength(1); + expect(cusRes.add_ons[0].id).toBe(oneOff.id); + expect(cusRes.invoices.length).toBe(1 + oneTimePurchaseCount); + + // Verify /check returns correct combined balance + const res = (await AutumnCli.entitled( + customerId, + TestFeature.Messages, + )) as CheckResponseV0; + expect(res.allowed).toBe(true); + + const proMeteredAmt = 10; + const messagesBalance = res.balances.find( + (b) => b.feature_id === TestFeature.Messages, + ); + expect(messagesBalance?.balance).toBe(proMeteredAmt + expectedAddOnBalance); +}); + +// ═══════════════════════════════════════════════════════════════════════════════ +// TEST 3: Multi-product checkout +// (from checkout3) +// +// Scenario: +// - Pro product ($20/month) with Messages (100 included) +// - One-off add-on with Users (5 included) +// - Attach both via product_ids array in single checkout +// +// Expected: +// - Customer has both Pro and add-on active +// - Features correct for both products +// ═══════════════════════════════════════════════════════════════════════════════ + +test.concurrent(`${chalk.yellowBright("legacy-checkout 3: multi-product checkout")}`, async () => { + const customerId = "legacy-checkout-3"; + + const messagesItem = items.monthlyMessages({ includedUsage: 100 }); + const pro = products.pro({ + id: "pro", + items: [messagesItem], + }); + + const usersItem = items.monthlyUsers({ includedUsage: 5 }); + const oneOff = products.oneOffAddOn({ + id: "one_off", + items: [usersItem], + }); + + const { autumnV1 } = await initScenario({ + customerId, + setup: [ + s.customer({ testClock: true }), + s.products({ list: [pro, oneOff] }), + ], + actions: [], + }); + + // Attach both products via product_ids + const res = await autumnV1.attach({ + customer_id: customerId, + product_ids: [pro.id, oneOff.id], + }); + + await completeStripeCheckoutFormV2({ url: res.checkout_url }); + await timeout(10000); + + const customer = await autumnV1.customers.get(customerId); + + await expectCustomerProducts({ + customer, + active: [pro.id, oneOff.id], + }); + + await expectCustomerInvoiceCorrect({ + customer, + count: 1, + latestTotal: 30, // Pro $20 + one-off $10 + latestInvoiceProductIds: [pro.id, oneOff.id], + }); + + expectCustomerFeatureCorrect({ + customer, + featureId: TestFeature.Messages, + balance: 100, + usage: 0, + }); + + expectCustomerFeatureCorrect({ + customer, + featureId: TestFeature.Users, + balance: 5, + usage: 0, + }); +}); diff --git a/server/tests/integration/billing/legacy/attach/downgrade/legacy-downgrade.test.ts b/server/tests/integration/billing/legacy/attach/downgrade/legacy-downgrade.test.ts new file mode 100644 index 000000000..1d5b8fdd4 --- /dev/null +++ b/server/tests/integration/billing/legacy/attach/downgrade/legacy-downgrade.test.ts @@ -0,0 +1,467 @@ +/** + * Legacy Attach V1 Downgrade Tests (Customer-Level) + * + * Migrated from: + * - server/tests/attach/downgrade/downgrade1.test.ts (premium -> pro, advance clock) + * - server/tests/attach/downgrade/downgrade2.test.ts (premium -> free, advance clock) + * - server/tests/attach/downgrade/downgrade3.test.ts (chain: premium -> pro -> free -> pro -> premium) + * - server/tests/attach/downgrade/downgrade4.test.ts (quarterly -> premium -> pro, mixed intervals) + * - server/tests/attach/downgrade/downgrade5.test.ts (premium -> pro schedule, renew, advance clock) + * + * Tests V1 attach downgrade behavior for customer-level subscriptions: + * - Scheduled downgrades (current product canceling, new product scheduled) + * - Clock advancement to activate scheduled downgrades + * - Renewing to cancel scheduled downgrades + * - Mixed billing intervals (quarterly -> monthly) + */ + +import { test } from "bun:test"; +import { type ApiCustomerV3, BillingInterval } from "@autumn/shared"; +import { expectCustomerFeatureCorrect } from "@tests/integration/billing/utils/expectCustomerFeatureCorrect"; +import { + expectProductActive, + expectProductCanceling, + expectProductNotPresent, + expectProductScheduled, +} from "@tests/integration/billing/utils/expectCustomerProductCorrect"; +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"; +import { advanceTestClock } from "@/utils/scriptUtils/testClockUtils"; + +// ═══════════════════════════════════════════════════════════════════════════════ +// TEST 1: Premium -> Pro, advance clock +// (from downgrade1) +// +// Scenario: +// - Premium ($50/month) with consumable Words +// - Pro ($20/month) with consumable Words +// - Attach Premium, then downgrade to Pro (scheduled) +// - Advance clock to next cycle +// +// Expected: +// - Premium is canceling (scheduled for end of cycle) +// - Pro is scheduled +// - After clock advance: Pro is active, Premium is gone +// ═══════════════════════════════════════════════════════════════════════════════ + +test.concurrent(`${chalk.yellowBright("legacy-downgrade 1: premium -> pro, advance clock")}`, async () => { + const customerId = "legacy-downgrade-1"; + + const wordsItem = items.consumableWords(); + const premium = products.premium({ id: "premium", items: [wordsItem] }); + const pro = products.pro({ id: "pro", items: [wordsItem] }); + + const { autumnV1, testClockId, ctx } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success", testClock: true }), + s.products({ list: [pro, premium] }), + ], + actions: [s.attach({ productId: premium.id })], + }); + + // Downgrade to Pro + await autumnV1.attach({ + customer_id: customerId, + product_id: pro.id, + }); + + // Verify: Premium canceling, Pro scheduled + const customerBefore = + await autumnV1.customers.get(customerId); + + await expectProductCanceling({ + customer: customerBefore, + productId: premium.id, + }); + + await expectProductScheduled({ + customer: customerBefore, + productId: pro.id, + }); + + // Advance clock to next cycle + await advanceTestClock({ + stripeCli: ctx.stripeCli, + testClockId: testClockId!, + numberOfMonths: 1, + waitForSeconds: 10, + }); + + // Verify: Pro is active, Premium is gone + const customerAfter = await autumnV1.customers.get(customerId); + + await expectProductActive({ + customer: customerAfter, + productId: pro.id, + }); + + await expectProductNotPresent({ + customer: customerAfter, + productId: premium.id, + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════════ +// TEST 2: Premium -> Free, advance clock +// (from downgrade2) +// +// Scenario: +// - Premium ($50/month) with consumable Words +// - Free (no price) with Words (100 included) +// - Attach Premium, then downgrade to Free (scheduled) +// - Advance clock to next cycle +// +// Expected: +// - Premium is canceling +// - Free is scheduled +// - After clock advance: Free is active with 100 Words balance +// ═══════════════════════════════════════════════════════════════════════════════ + +test.concurrent(`${chalk.yellowBright("legacy-downgrade 2: premium -> free, advance clock")}`, async () => { + const customerId = "legacy-downgrade-2"; + + const wordsConsumable = items.consumableWords(); + const wordsIncluded = items.monthlyWords({ includedUsage: 100 }); + const premium = products.premium({ id: "premium", items: [wordsConsumable] }); + const free = products.base({ id: "free", items: [wordsIncluded] }); + + const { autumnV1, testClockId, ctx } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success", testClock: true }), + s.products({ list: [premium, free] }), + ], + actions: [s.attach({ productId: premium.id })], + }); + + // Downgrade to Free + await autumnV1.attach({ + customer_id: customerId, + product_id: free.id, + }); + + // Verify: Premium canceling, Free scheduled + const customerBefore = + await autumnV1.customers.get(customerId); + + await expectProductCanceling({ + customer: customerBefore, + productId: premium.id, + }); + + await expectProductScheduled({ + customer: customerBefore, + productId: free.id, + }); + + // Advance clock to next cycle + await advanceTestClock({ + stripeCli: ctx.stripeCli, + testClockId: testClockId!, + numberOfMonths: 1, + waitForSeconds: 10, + }); + + // Verify: Free is active + const customerAfter = await autumnV1.customers.get(customerId); + + await expectProductActive({ + customer: customerAfter, + productId: free.id, + }); + + await expectProductNotPresent({ + customer: customerAfter, + productId: premium.id, + }); + + expectCustomerFeatureCorrect({ + customer: customerAfter, + featureId: TestFeature.Words, + balance: 100, + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════════ +// TEST 3: Chain: Premium -> Pro -> Free -> Pro -> Premium +// (from downgrade3) +// +// Scenario: +// - Premium ($50/month), Pro ($20/month), Free (no price) +// - Attach Premium +// - Downgrade to Pro (scheduled) +// - Change downgrade to Free (scheduled) +// - Change downgrade to Pro (scheduled) +// - Renew Premium (cancels schedule) +// +// Expected: +// - Each downgrade replaces the previous schedule +// - Renewing cancels all scheduled downgrades +// ═══════════════════════════════════════════════════════════════════════════════ + +test.concurrent(`${chalk.yellowBright("legacy-downgrade 3: chain premium -> pro -> free -> pro -> premium")}`, async () => { + const customerId = "legacy-downgrade-3"; + + const wordsConsumable = items.consumableWords(); + const wordsIncluded = items.monthlyWords({ includedUsage: 100 }); + const premium = products.premium({ id: "premium", items: [wordsConsumable] }); + const pro = products.pro({ id: "pro", items: [wordsConsumable] }); + const free = products.base({ id: "free", items: [wordsIncluded] }); + + const { autumnV1 } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success", testClock: true }), + s.products({ list: [premium, pro, free] }), + ], + actions: [s.attach({ productId: premium.id })], + }); + + // Step 1: Downgrade to Pro + await autumnV1.attach({ + customer_id: customerId, + product_id: pro.id, + }); + + let customer = await autumnV1.customers.get(customerId); + await expectProductCanceling({ customer, productId: premium.id }); + await expectProductScheduled({ customer, productId: pro.id }); + + // Step 2: Change downgrade to Free + await autumnV1.attach({ + customer_id: customerId, + product_id: free.id, + }); + + customer = await autumnV1.customers.get(customerId); + await expectProductCanceling({ customer, productId: premium.id }); + await expectProductScheduled({ customer, productId: free.id }); + await expectProductNotPresent({ customer, productId: pro.id }); + + // Step 3: Change downgrade to Pro + await autumnV1.attach({ + customer_id: customerId, + product_id: pro.id, + }); + + customer = await autumnV1.customers.get(customerId); + await expectProductCanceling({ customer, productId: premium.id }); + await expectProductScheduled({ customer, productId: pro.id }); + await expectProductNotPresent({ customer, productId: free.id }); + + // Step 4: Renew Premium (cancels scheduled downgrade) + await autumnV1.attach({ + customer_id: customerId, + product_id: premium.id, + }); + + customer = await autumnV1.customers.get(customerId); + await expectProductActive({ customer, productId: premium.id }); + await expectProductNotPresent({ customer, productId: pro.id }); + await expectProductNotPresent({ customer, productId: free.id }); +}); + +// ═══════════════════════════════════════════════════════════════════════════════ +// TEST 4: Pro-Quarter -> Premium -> Pro (mixed intervals) +// (from downgrade4) +// +// Scenario: +// - Pro-Quarter ($20/quarter) with consumable Words +// - Premium ($50/month) with consumable Words +// - Pro ($20/month) with consumable Words +// - Attach Pro-Quarter +// - Downgrade to Premium (scheduled for end of quarter) +// - Change downgrade to Pro (scheduled for end of quarter) +// - Advance clock 3 months +// +// Expected: +// - After 3 months: Pro (monthly) is active +// ═══════════════════════════════════════════════════════════════════════════════ + +test.concurrent(`${chalk.yellowBright("legacy-downgrade 4: pro-quarter -> premium -> pro (mixed intervals)")}`, async () => { + const customerId = "legacy-downgrade-4"; + + const wordsItem = items.consumableWords(); + + // Quarterly pro product - use constructProduct directly with interval parameter + const proQuarter = constructProduct({ + id: "pro-quarter", + items: [wordsItem], + type: "pro", + interval: BillingInterval.Quarter, + isDefault: false, + }); + + const premium = products.premium({ id: "premium", items: [wordsItem] }); + const pro = products.pro({ id: "pro", items: [wordsItem] }); + + const { autumnV1, testClockId, ctx } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success", testClock: true }), + s.products({ list: [proQuarter, premium, pro] }), + ], + actions: [s.attach({ productId: proQuarter.id })], + }); + + // Downgrade to Premium + await autumnV1.attach({ + customer_id: customerId, + product_id: premium.id, + }); + + let customer = await autumnV1.customers.get(customerId); + await expectProductCanceling({ customer, productId: proQuarter.id }); + await expectProductScheduled({ customer, productId: premium.id }); + + // Change downgrade to Pro + await autumnV1.attach({ + customer_id: customerId, + product_id: pro.id, + }); + + customer = await autumnV1.customers.get(customerId); + await expectProductCanceling({ customer, productId: proQuarter.id }); + await expectProductScheduled({ customer, productId: pro.id }); + + // Advance clock 3 months (end of quarter) - advance 1.5 months twice + await advanceTestClock({ + stripeCli: ctx.stripeCli, + testClockId: testClockId!, + numberOfWeeks: 6, + waitForSeconds: 10, + }); + await advanceTestClock({ + stripeCli: ctx.stripeCli, + testClockId: testClockId!, + numberOfWeeks: 6, + waitForSeconds: 10, + }); + + // Verify: Pro (monthly) is active + const customerAfter = await autumnV1.customers.get(customerId); + + await expectProductActive({ + customer: customerAfter, + productId: pro.id, + }); + + await expectProductNotPresent({ + customer: customerAfter, + productId: proQuarter.id, + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════════ +// TEST 5: Premium -> Pro schedule, renew removes schedule, advance clock +// (from downgrade5) +// +// Scenario: +// - Premium ($50/month) with Messages (100 included) +// - Pro ($20/month) with Dashboard, Messages (10), Admin (unlimited) +// - Attach Premium +// - Downgrade to Pro (scheduled) +// - Renew Premium (removes scheduled Pro) +// - Downgrade to Pro again +// - Advance clock +// +// Expected: +// - Renewing cancels the scheduled downgrade +// - After final downgrade and clock advance: Pro is active +// ═══════════════════════════════════════════════════════════════════════════════ + +test.concurrent(`${chalk.yellowBright("legacy-downgrade 5: premium -> pro schedule, renew, advance clock")}`, async () => { + const customerId = "legacy-downgrade-5"; + + const dashboardItem = items.dashboard(); + const messagesItemPro = items.monthlyMessages({ includedUsage: 10 }); + const adminItem = items.adminRights(); + const messagesItemPremium = items.monthlyMessages({ includedUsage: 100 }); + + const pro = products.pro({ + id: "pro", + items: [dashboardItem, messagesItemPro, adminItem], + }); + const premium = products.premium({ + id: "premium", + items: [messagesItemPremium], + }); + + const { autumnV1, testClockId, ctx } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success", testClock: true }), + s.products({ list: [pro, premium] }), + ], + actions: [s.attach({ productId: premium.id })], + }); + + // Step 1: Downgrade to Pro + await autumnV1.attach({ + customer_id: customerId, + product_id: pro.id, + }); + + let customer = await autumnV1.customers.get(customerId); + await expectProductCanceling({ customer, productId: premium.id }); + await expectProductScheduled({ customer, productId: pro.id }); + + // Verify Premium is still the active product with correct features + expectCustomerFeatureCorrect({ + customer, + featureId: TestFeature.Messages, + balance: 100, + }); + + // Step 2: Renew Premium (cancels scheduled Pro) + await autumnV1.attach({ + customer_id: customerId, + product_id: premium.id, + }); + + customer = await autumnV1.customers.get(customerId); + await expectProductActive({ customer, productId: premium.id }); + await expectProductNotPresent({ customer, productId: pro.id }); + + // Step 3: Downgrade to Pro again + await autumnV1.attach({ + customer_id: customerId, + product_id: pro.id, + }); + + customer = await autumnV1.customers.get(customerId); + await expectProductCanceling({ customer, productId: premium.id }); + await expectProductScheduled({ customer, productId: pro.id }); + + // Step 4: Advance clock to next cycle + await advanceTestClock({ + stripeCli: ctx.stripeCli, + testClockId: testClockId!, + numberOfMonths: 1, + waitForSeconds: 10, + }); + + // Verify: Pro is active with correct features + const customerAfter = await autumnV1.customers.get(customerId); + + await expectProductActive({ + customer: customerAfter, + productId: pro.id, + }); + + await expectProductNotPresent({ + customer: customerAfter, + productId: premium.id, + }); + + expectCustomerFeatureCorrect({ + customer: customerAfter, + featureId: TestFeature.Messages, + balance: 10, + }); +}); diff --git a/server/tests/integration/billing/legacy/attach/entities/legacy-entities-basic.test.ts b/server/tests/integration/billing/legacy/attach/entities/legacy-entities-basic.test.ts new file mode 100644 index 000000000..4c1148d94 --- /dev/null +++ b/server/tests/integration/billing/legacy/attach/entities/legacy-entities-basic.test.ts @@ -0,0 +1,467 @@ +/** + * Legacy Attach V1 Entity Tests + * + * Migrated from: + * - server/tests/attach/entities/entity1.test.ts (attach to entity via checkout) + * - server/tests/attach/entities/entity2.test.ts (attach pro annual, track usage, invoice after cycle) + * - server/tests/attach/entities/entity3.test.ts (attach pro annual, cancel with usage invoice) + * - server/tests/attach/entities/entity4.test.ts (attach to multiple entities, track usage separately) + * - server/tests/attach/entities/entity5.test.ts (downgrade entity product, advance clock) + * + * Tests V1 attach behavior for entity-level subscriptions: + * - Attaching products to entities + * - Tracking usage per entity + * - Entity-level invoices + * - Entity-level downgrades + */ + +import { expect, test } from "bun:test"; +import type { ApiEntityV0 } from "@autumn/shared"; +import { expectCustomerFeatureCorrect } from "@tests/integration/billing/utils/expectCustomerFeatureCorrect"; +import { + expectProductActive, + expectProductScheduled, +} from "@tests/integration/billing/utils/expectCustomerProductCorrect"; +import { TestFeature } from "@tests/setup/v2Features"; +import { expectScheduledApiSub } from "@tests/utils/expectUtils/expectProductAttached"; +import { expectInvoiceAfterUsage } from "@tests/utils/expectUtils/expectSingleUse/expectUsageInvoice"; +import { items } from "@tests/utils/fixtures/items"; +import { products } from "@tests/utils/fixtures/products"; +import { advanceToNextInvoice } from "@tests/utils/testAttachUtils/testAttachUtils"; +import ctx from "@tests/utils/testInitUtils/createTestContext"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario"; +import chalk from "chalk"; +import { timeout } from "@/utils/genUtils"; +import { advanceTestClock } from "@/utils/scriptUtils/testClockUtils"; + +// ═══════════════════════════════════════════════════════════════════════════════ +// TEST 1: Attach to entity via checkout +// (from entity1) +// +// Scenario: +// - Pro ($20/month) with Words (1500 included, arrear) +// - Create entity, attach Pro to entity +// +// Expected: +// - Entity has Pro product attached +// ═══════════════════════════════════════════════════════════════════════════════ + +test.concurrent(`${chalk.yellowBright("legacy-entities 1: attach to entity via checkout")}`, async () => { + const customerId = "legacy-entities-1"; + + const wordsItem = items.consumableWords({ includedUsage: 1500 }); + const pro = products.pro({ id: "pro", items: [wordsItem] }); + + const { autumnV1, entities } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success", testClock: true }), + s.products({ list: [pro] }), + s.entities({ count: 1, featureId: TestFeature.Users }), + ], + actions: [s.attach({ productId: pro.id, entityIndex: 0 })], + }); + + const entityId = entities[0].id; + + // Verify entity has Pro attached + const entity = await autumnV1.entities.get(customerId, entityId); + + await expectProductActive({ + customer: entity, + productId: pro.id, + }); + + // Also verify customer has product attached to entity + const customer = await autumnV1.customers.get(customerId); + await expectProductActive({ + customer, + productId: pro.id, + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════════ +// TEST 2: Attach pro annual to entity, track usage, invoice after cycle +// (from entity2) +// +// Scenario: +// - Pro Annual ($200/year) with Words (1500 included, arrear) +// - Create entity, attach Pro Annual +// - Track large usage (1,250,130 words) +// - Advance clock to next invoice +// +// Expected: +// - Entity has correct usage tracked +// - Invoice generated with overage charges +// ═══════════════════════════════════════════════════════════════════════════════ + +test.concurrent(`${chalk.yellowBright("legacy-entities 2: attach pro annual, track usage, invoice after cycle")}`, async () => { + const customerId = "legacy-entities-2"; + + const wordsItem = items.consumableWords({ includedUsage: 1500 }); + const proAnnual = products.proAnnual({ + id: "pro-annual", + items: [wordsItem], + }); + + const { autumnV1, entities, testClockId } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success", testClock: true }), + s.products({ list: [proAnnual] }), + s.entities({ count: 1, featureId: TestFeature.Users }), + ], + actions: [s.attach({ productId: proAnnual.id, entityIndex: 0 })], + }); + + const entityId = entities[0].id; + + // Track large usage + const usage = 1250130; + await autumnV1.track({ + customer_id: customerId, + entity_id: entityId, + feature_id: TestFeature.Words, + value: usage, + }); + + // Verify entity has correct usage (cached) + const entity = await autumnV1.entities.get(customerId, entityId); + + expectCustomerFeatureCorrect({ + customer: entity, + featureId: TestFeature.Words, + usage, + }); + + // Also verify non-cached + await timeout(2000); + const nonCachedEntity = await autumnV1.entities.get( + customerId, + entityId, + { skip_cache: "true" }, + ); + + expectCustomerFeatureCorrect({ + customer: nonCachedEntity, + featureId: TestFeature.Words, + usage, + }); + + // Advance clock to next invoice (uses advanceToNextInvoice with withPause) + await advanceToNextInvoice({ + stripeCli: ctx.stripeCli, + testClockId: testClockId!, + withPause: true, + }); + + // Verify invoice has correct usage charges + await expectInvoiceAfterUsage({ + autumn: autumnV1, + customerId, + entityId, + featureId: TestFeature.Words, + product: proAnnual, + usage, + stripeCli: ctx.stripeCli, + db: ctx.db, + org: ctx.org, + env: ctx.env, + numInvoices: 2, + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════════ +// TEST 3: Attach pro annual to entity and cancel +// (from entity3) +// +// Scenario: +// - Pro ($20/month) with Words (1500 included, arrear) +// - Create entity, attach Pro +// - Track usage (1,032,100 words) +// - Cancel subscription +// - Advance clock +// +// Expected: +// - Final invoice generated with usage +// - Entity product is expired after clock advance +// ═══════════════════════════════════════════════════════════════════════════════ + +test.concurrent(`${chalk.yellowBright("legacy-entities 3: attach pro, cancel with usage invoice")}`, async () => { + const customerId = "legacy-entities-3"; + + const wordsItem = items.consumableWords({ includedUsage: 1500 }); + const pro = products.pro({ id: "pro", items: [wordsItem] }); + + const { autumnV1, entities, testClockId } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success", testClock: true }), + s.products({ list: [pro] }), + s.entities({ count: 1, featureId: TestFeature.Users }), + ], + actions: [s.attach({ productId: pro.id, entityIndex: 0 })], + }); + + const entityId = entities[0].id; + + // Track usage before cancel + const usage = 1032100; + await autumnV1.track({ + customer_id: customerId, + entity_id: entityId, + feature_id: TestFeature.Words, + value: usage, + }); + + // Cancel subscription + await autumnV1.cancel({ + customer_id: customerId, + product_id: pro.id, + entity_id: entityId, + }); + + await timeout(5000); + + // Advance clock to finalize invoice (advance past month + finalize hours) + await advanceToNextInvoice({ + stripeCli: ctx.stripeCli, + testClockId: testClockId!, + withPause: true, + }); + + // Verify invoice has correct usage charges and product is expired + await expectInvoiceAfterUsage({ + autumn: autumnV1, + customerId, + entityId, + featureId: TestFeature.Words, + product: pro, + usage, + stripeCli: ctx.stripeCli, + db: ctx.db, + org: ctx.org, + env: ctx.env, + numInvoices: 2, + expectExpired: true, + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════════ +// TEST 4: Attach pro to multiple entities, track usage separately +// (from entity4) +// +// Scenario: +// - Pro ($20/month) with Words (1500 included, arrear) +// - Create 2 entities +// - Attach Pro to entity 1, then Pro to entity 2 +// - Track different usage on each entity +// +// Expected: +// - Each entity has separate usage tracking +// - Usage on one entity doesn't affect the other +// ═══════════════════════════════════════════════════════════════════════════════ + +test.concurrent(`${chalk.yellowBright("legacy-entities 4: attach to multiple entities, track usage separately")}`, async () => { + const customerId = "legacy-entities-4"; + + const wordsItem = items.consumableWords({ includedUsage: 1500 }); + const pro = products.pro({ id: "pro", items: [wordsItem] }); + + const { autumnV1, entities } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success", testClock: true }), + s.products({ list: [pro] }), + s.entities({ count: 2, featureId: TestFeature.Users }), + ], + actions: [ + s.attach({ productId: pro.id, entityIndex: 0, timeout: 4000 }), + s.attach({ productId: pro.id, entityIndex: 1, timeout: 4000 }), + ], + }); + + const entity1Id = entities[0].id; + const entity2Id = entities[1].id; + + // Track usage on entity 1 + const entity1Usage = Math.floor(Math.random() * 1000000); + await autumnV1.track({ + customer_id: customerId, + entity_id: entity1Id, + feature_id: TestFeature.Words, + value: entity1Usage, + }); + + // Verify entity 1 has usage, entity 2 has none (cached) + const entity1 = await autumnV1.entities.get( + customerId, + entity1Id, + ); + const entity2 = await autumnV1.entities.get( + customerId, + entity2Id, + ); + + expectCustomerFeatureCorrect({ + customer: entity1, + featureId: TestFeature.Words, + usage: entity1Usage, + }); + + expectCustomerFeatureCorrect({ + customer: entity2, + featureId: TestFeature.Words, + usage: 0, + }); + + // Verify non-cached results + await timeout(2000); + const entity1Uncached = await autumnV1.entities.get( + customerId, + entity1Id, + { skip_cache: "true" }, + ); + const entity2Uncached = await autumnV1.entities.get( + customerId, + entity2Id, + { skip_cache: "true" }, + ); + + expectCustomerFeatureCorrect({ + customer: entity1Uncached, + featureId: TestFeature.Words, + usage: entity1Usage, + }); + + expectCustomerFeatureCorrect({ + customer: entity2Uncached, + featureId: TestFeature.Words, + usage: 0, + }); + + // Track usage on entity 2 + const entity2Usage = Math.floor(Math.random() * 1000000); + await autumnV1.track({ + customer_id: customerId, + entity_id: entity2Id, + feature_id: TestFeature.Words, + value: entity2Usage, + }); + + // Verify both entities have correct independent usage + const entity1After = await autumnV1.entities.get( + customerId, + entity1Id, + ); + const entity2After = await autumnV1.entities.get( + customerId, + entity2Id, + ); + + expectCustomerFeatureCorrect({ + customer: entity1After, + featureId: TestFeature.Words, + usage: entity1Usage, + }); + + expectCustomerFeatureCorrect({ + customer: entity2After, + featureId: TestFeature.Words, + usage: entity2Usage, + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════════ +// TEST 5: Downgrade entity product +// (from entity5) +// +// Scenario: +// - Premium ($50/month) and Pro ($20/month) with Words (1500 included, arrear) +// - Create 2 entities +// - Attach Premium to both entities +// - Downgrade entity 1 to Pro (scheduled) +// - Advance clock +// +// Expected: +// - Entity 1 has Pro scheduled, then active after clock advance +// - Entity 2 still has Premium active +// ═══════════════════════════════════════════════════════════════════════════════ + +test.concurrent(`${chalk.yellowBright("legacy-entities 5: downgrade entity product")}`, async () => { + const customerId = "legacy-entities-5"; + + const wordsItem = items.consumableWords({ includedUsage: 1500 }); + const pro = products.pro({ id: "pro", items: [wordsItem] }); + const premium = products.premium({ id: "premium", items: [wordsItem] }); + + const { autumnV1, entities, testClockId, ctx } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success", testClock: true }), + s.products({ list: [pro, premium] }), + s.entities({ count: 2, featureId: TestFeature.Users }), + ], + actions: [ + s.attach({ productId: premium.id, entityIndex: 0 }), + s.attach({ productId: premium.id, entityIndex: 1 }), + ], + }); + + const entity1Id = entities[0].id; + const entity2Id = entities[1].id; + + // Downgrade entity 1 to Pro + await autumnV1.attach({ + customer_id: customerId, + entity_id: entity1Id, + product_id: pro.id, + }); + + // Verify Pro is scheduled on entity 1 + const entity1Before = await autumnV1.entities.get( + customerId, + entity1Id, + ); + await expectProductScheduled({ + customer: entity1Before, + productId: pro.id, + }); + + // Also verify scheduled subscription via API + await expectScheduledApiSub({ + customerId, + entityId: entity1Id, + productId: pro.id, + }); + + // Advance clock to activate scheduled product + await advanceTestClock({ + stripeCli: ctx.stripeCli, + testClockId: testClockId!, + numberOfMonths: 1, + waitForSeconds: 30, + }); + + // Verify entity 1 has Pro active + const entity1After = await autumnV1.entities.get( + customerId, + entity1Id, + ); + await expectProductActive({ + customer: entity1After, + productId: pro.id, + }); + expect(entity1After.products?.length).toBe(1); + + // Verify entity 2 still has Premium active + const entity2After = await autumnV1.entities.get( + customerId, + entity2Id, + ); + await expectProductActive({ + customer: entity2After, + productId: premium.id, + }); + expect(entity2After.products?.length).toBe(1); +}); diff --git a/server/tests/integration/billing/legacy/attach/invoice/legacy-attach-invoice-mode-advanced.test.ts b/server/tests/integration/billing/legacy/attach/invoice/legacy-attach-invoice-mode-advanced.test.ts new file mode 100644 index 000000000..c7aca3f09 --- /dev/null +++ b/server/tests/integration/billing/legacy/attach/invoice/legacy-attach-invoice-mode-advanced.test.ts @@ -0,0 +1,366 @@ +/** + * Legacy Attach V1 Invoice Mode Advanced Tests + * + * Migrated from: + * - server/tests/attach/checkout/checkout6.test.ts (invoice via /checkout endpoint) + * - server/tests/attach/checkout/checkout7.test.ts (invoice with one-off add-on + quantity) + * + * Tests V1 invoice mode scenarios using the /checkout endpoint: + * - New subscription via /checkout with invoice: true + * - Upgrade with enable_product_immediately and draft invoice + * - One-off add-on with quantity options via /checkout + */ + +import { expect, test } from "bun:test"; +import type { ApiCustomerV3 } from "@autumn/shared"; +import { expectCustomerFeatureCorrect } from "@tests/integration/billing/utils/expectCustomerFeatureCorrect"; +import { expectCustomerInvoiceCorrect } from "@tests/integration/billing/utils/expectCustomerInvoiceCorrect"; +import { + expectCustomerProducts, + expectProductActive, +} from "@tests/integration/billing/utils/expectCustomerProductCorrect"; +import { expectSubToBeCorrect } from "@tests/merged/mergeUtils/expectSubCorrect"; +import { TestFeature } from "@tests/setup/v2Features"; +import { completeInvoiceCheckoutV2 } from "@tests/utils/browserPool/completeInvoiceCheckoutV2"; +import { expectProductAttached } from "@tests/utils/expectUtils/expectProductAttached"; +import { items } from "@tests/utils/fixtures/items"; +import { products } from "@tests/utils/fixtures/products"; +import ctx from "@tests/utils/testInitUtils/createTestContext"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario"; +import chalk from "chalk"; +import { CusService } from "@/internal/customers/CusService"; + +// ═══════════════════════════════════════════════════════════════════════════════ +// TEST 1: Invoice checkout via /checkout endpoint +// (from checkout6 - first test) +// +// Scenario: +// - Pro product ($20/month) with Messages (100 included) +// - Attach via /checkout endpoint with invoice: true +// +// Expected: +// - Returns checkout URL +// - Product attached after invoice payment +// - Features correct +// ═══════════════════════════════════════════════════════════════════════════════ + +test.concurrent(`${chalk.yellowBright("legacy-inv-mode-adv 1: /checkout endpoint new subscription")}`, async () => { + const customerId = "legacy-inv-mode-adv-1"; + + const messagesItem = items.monthlyMessages({ includedUsage: 100 }); + const pro = products.pro({ + id: "pro", + items: [messagesItem], + }); + + const { autumnV1 } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success", testClock: true }), + s.products({ list: [pro] }), + ], + actions: [], + }); + + const res = await autumnV1.checkout({ + customer_id: customerId, + product_id: pro.id, + invoice: true, + }); + + expect(res.url).toBeDefined(); + + await completeInvoiceCheckoutV2({ url: res.url! }); + + const customer = await autumnV1.customers.get(customerId); + + await expectProductActive({ + customer, + productId: pro.id, + }); + + expectCustomerFeatureCorrect({ + customer, + featureId: TestFeature.Messages, + balance: 100, + usage: 0, + }); + + await expectCustomerInvoiceCorrect({ + customer, + count: 1, + latestTotal: 20, + latestStatus: "paid", + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════════ +// TEST 2: Upgrade with enable_product_immediately and draft invoice +// (from checkout6 - second test) +// +// Scenario: +// - Pro product ($20/month) attached normally +// - Upgrade to Premium ($50/month) with: +// - invoice: true +// - enable_product_immediately: true +// - finalize_invoice: false +// +// Expected: +// - No checkout URL (auto-charged or draft) +// - Premium product active immediately +// - Invoice is draft with proration amount ($30) +// ═══════════════════════════════════════════════════════════════════════════════ + +test.concurrent(`${chalk.yellowBright("legacy-inv-mode-adv 2: upgrade with enable_product_immediately draft invoice")}`, async () => { + const customerId = "legacy-inv-mode-adv-2"; + + const proMessagesItem = items.monthlyMessages({ includedUsage: 100 }); + const pro = products.pro({ + id: "pro", + items: [proMessagesItem], + }); + + const premiumMessagesItem = items.monthlyMessages({ includedUsage: 250 }); + const premium = products.premium({ + id: "premium", + items: [premiumMessagesItem], + }); + + const { autumnV1 } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success", testClock: true }), + s.products({ list: [pro, premium] }), + ], + actions: [s.attach({ productId: pro.id })], + }); + + const res = await autumnV1.attach({ + customer_id: customerId, + product_id: premium.id, + invoice: true, + enable_product_immediately: true, + finalize_invoice: false, + }); + + // No checkout URL when finalize_invoice: false + expect(res.checkout_url).toBeFalsy(); + + const customer = await autumnV1.customers.get(customerId); + + await expectProductActive({ + customer, + productId: premium.id, + }); + + expectCustomerFeatureCorrect({ + customer, + featureId: TestFeature.Messages, + balance: 250, + usage: 0, + }); + + // 2 invoices: pro $20 (paid) + premium upgrade proration $30 (draft) + await expectCustomerInvoiceCorrect({ + customer, + count: 2, + invoiceIndex: 0, + latestTotal: 30, // Premium $50 - Pro $20 proration + latestStatus: "draft", + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════════ +// TEST 3: One-off add-on with quantity via /checkout +// (from checkout7) +// +// Scenario: +// - Pro product ($20/month) with Messages (100 included) +// - One-off add-on with prepaid Messages ($10/100 units) +// - Attach pro, then add-on via /checkout with quantity 200 +// +// Expected: +// - Returns checkout URL +// - Add-on attached after payment +// - Messages balance = 100 (pro) + 200 (add-on) = 300 +// ═══════════════════════════════════════════════════════════════════════════════ + +test.concurrent(`${chalk.yellowBright("legacy-inv-mode-adv 3: one-off add-on with quantity via /checkout")}`, async () => { + const customerId = "legacy-inv-mode-adv-3"; + + const messagesItem = items.monthlyMessages({ includedUsage: 100 }); + const pro = products.pro({ + id: "pro", + items: [messagesItem], + }); + + const addOnMessagesItem = items.oneOffMessages({ + price: 10, + billingUnits: 100, + includedUsage: 0, + }); + const addOn = products.base({ + id: "addon", + items: [addOnMessagesItem], + }); + + const { autumnV1 } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success", testClock: true }), + s.products({ list: [pro, addOn] }), + ], + actions: [s.attach({ productId: pro.id })], + }); + + const options = [ + { + quantity: 200, + feature_id: TestFeature.Messages, + }, + ]; + + const res = await autumnV1.checkout({ + customer_id: customerId, + product_id: addOn.id, + invoice: true, + options, + }); + + expect(res.url).toBeDefined(); + + await completeInvoiceCheckoutV2({ url: res.url! }); + + const customer = await autumnV1.customers.get(customerId); + + await expectCustomerProducts({ + customer, + active: [pro.id, addOn.id], + }); + + // Messages: 100 (pro monthly) + 200 (one-off add-on) = 300 + expectCustomerFeatureCorrect({ + customer, + featureId: TestFeature.Messages, + balance: 300, + usage: 0, + }); + + // 2 invoices: pro $20 + add-on $20 (2 packs x $10) + await expectCustomerInvoiceCorrect({ + customer, + count: 2, + invoiceIndex: 0, + latestTotal: 20, // 2 packs x $10 + latestStatus: "paid", + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════════ +// TEST 4: Separate subscriptions due to invoice checkout (per entity) +// (from separate1) +// +// Scenario: +// - Pro product ($20/month) with Messages (100 included) +// - Premium product ($50/month) with Messages (100 included) +// - 2 entities, attach Pro to each with invoice: true (separate checkouts) +// - Upgrade both to Premium +// +// Expected: +// - Each entity gets a separate subscription (not merged) +// - Upgrades work correctly on separate subs +// ═══════════════════════════════════════════════════════════════════════════════ + +test.concurrent(`${chalk.yellowBright("legacy-inv-mode-adv 4: separate subs due to invoice checkout")}`, async () => { + const customerId = "legacy-inv-mode-adv-4"; + + const messagesItem = items.monthlyMessages({ includedUsage: 100 }); + const pro = products.pro({ id: "pro", items: [messagesItem] }); + const premium = products.premium({ id: "premium", items: [messagesItem] }); + + const { autumnV1, entities } = await initScenario({ + customerId, + setup: [ + s.customer({ testClock: true }), + s.products({ list: [pro, premium] }), + s.entities({ count: 2, featureId: TestFeature.Users }), + ], + actions: [], + }); + + const { db, org, env } = ctx; + + // Attach Pro to each entity with invoice checkout (creates separate subs) + for (const entity of entities) { + const res = await autumnV1.attach({ + customer_id: customerId, + product_id: pro.id, + entity_id: entity.id, + invoice: true, + }); + + await completeInvoiceCheckoutV2({ url: res.checkout_url }); + } + + // Verify separate subscriptions + const fullCus = await CusService.getFull({ + idOrInternalId: customerId, + db, + orgId: org.id, + env, + }); + + const cusProducts = fullCus.customer_products; + const entity1Prod = cusProducts.find((cp) => cp.entity_id === entities[0].id); + const entity2Prod = cusProducts.find((cp) => cp.entity_id === entities[1].id); + + const entity1SubId = entity1Prod?.subscription_ids?.[0]; + const entity2SubId = entity2Prod?.subscription_ids?.[0]; + + expect(entity1SubId).not.toBe(entity2SubId); + + await expectSubToBeCorrect({ + db, + customerId, + org, + env, + subId: entity1SubId, + }); + + // Upgrade both entities to Premium + await autumnV1.attach({ + customer_id: customerId, + product_id: premium.id, + entity_id: entities[0].id, + }); + + await autumnV1.attach({ + customer_id: customerId, + product_id: premium.id, + entity_id: entities[1].id, + }); + + // Verify subs still correct after upgrades + await expectSubToBeCorrect({ + db, + customerId, + org, + env, + subId: entity1SubId, + }); + + await expectSubToBeCorrect({ + db, + customerId, + org, + env, + subId: entity2SubId, + }); + + // Verify entities have Premium + const entity1 = await autumnV1.entities.get(customerId, entities[0].id); + expectProductAttached({ customer: entity1, productId: premium.id }); + + const entity2 = await autumnV1.entities.get(customerId, entities[1].id); + expectProductAttached({ customer: entity2, productId: premium.id }); +}); diff --git a/server/tests/integration/billing/legacy/attach/new/legacy-merge-interval.test.ts b/server/tests/integration/billing/legacy/attach/new/legacy-merge-interval.test.ts new file mode 100644 index 000000000..d03e707e6 --- /dev/null +++ b/server/tests/integration/billing/legacy/attach/new/legacy-merge-interval.test.ts @@ -0,0 +1,250 @@ +/** + * Legacy Merge Interval Tests + * + * Migrated from: + * - server/tests/interval/multiSub/multiSubInterval1.test.ts (entity attach mid-cycle interval alignment) + * - server/tests/interval/multiSub/multiSubInterval2.test.ts (entity attach annual after 1 cycle) + * - server/tests/interval/multiSub/multiSubInterval3.test.ts (entity attach annual with monthly usage) + * + * Tests V1 attach (s.attach) behavior for: + * - Billing interval alignment for entity subscriptions + * - next_cycle verification for entity-level attaches + */ + +import { expect, test } from "bun:test"; +import { TestFeature } from "@tests/setup/v2Features"; +import { items } from "@tests/utils/fixtures/items"; +import { products } from "@tests/utils/fixtures/products"; +import ctx from "@tests/utils/testInitUtils/createTestContext"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario"; +import chalk from "chalk"; +import { addMonths, addYears, differenceInDays } from "date-fns"; +import { getLatestPeriodEnd } from "@/external/stripe/stripeSubUtils/convertSubUtils"; +import { getCusSub } from "@/utils/scriptUtils/testUtils/cusTestUtils"; +import { toMilliseconds } from "@/utils/timeUtils"; + +// ═══════════════════════════════════════════════════════════════════════════════ +// TEST 1: Entity attach mid-cycle - verify billing interval alignment +// (from multiSubInterval1) +// +// Scenario: +// - Pro ($20/month) with Words feature +// - 2 entities +// - Attach Pro to customer (creates subscription) +// - Advance clock 2 weeks (mid-cycle) +// - Attach Pro to entity 2 +// +// Expected: +// - Entity 2's next_cycle aligns with customer's existing billing cycle (~1 month from start) +// - Stripe subscription period_end matches next_cycle +// ═══════════════════════════════════════════════════════════════════════════════ + +test.concurrent(`${chalk.yellowBright("legacy-merge-interval 1: entity attach mid-cycle interval alignment")}`, async () => { + const customerId = "legacy-merge-interval-1"; + + const wordsItem = items.monthlyWords({ includedUsage: 100 }); + const pro = products.pro({ id: "pro", items: [wordsItem] }); + + const { autumnV1, entities } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success", testClock: true }), + s.products({ list: [pro] }), + s.entities({ count: 2, featureId: TestFeature.Users }), + ], + actions: [ + s.attach({ productId: pro.id }), + s.advanceTestClock({ weeks: 2 }), + ], + }); + + // Get checkout preview for entity 2 + const checkoutRes = await autumnV1.checkout({ + customer_id: customerId, + product_id: pro.id, + entity_id: entities[1].id, + }); + + expect(checkoutRes.next_cycle).toBeDefined(); + expect(checkoutRes.next_cycle?.starts_at).toBeCloseTo( + addMonths(new Date(), 1).getTime(), + -Math.log10(toMilliseconds.days(1)), + ); + + // Attach Pro to entity 2 + await autumnV1.attach({ + customer_id: customerId, + product_id: pro.id, + entity_id: entities[1].id, + }); + + // Verify Stripe subscription period_end matches + const sub = await getCusSub({ + db: ctx.db, + org: ctx.org, + customerId, + productId: pro.id, + }); + + const subItem = sub!.items.data[0]; + expect(subItem.current_period_end * 1000).toBeCloseTo( + checkoutRes.next_cycle?.starts_at ?? 0, + -Math.log10(toMilliseconds.days(1)), + ); +}); + +// ═══════════════════════════════════════════════════════════════════════════════ +// TEST 2: Entity attach annual after 1 cycle +// (from multiSubInterval2) +// +// Scenario: +// - Pro ($20/month) and Pro Annual ($200/year) with Words feature +// - 2 entities +// - Attach Pro monthly to customer +// - Advance clock 1 month (past first cycle) +// - Attach Pro Annual to entity 2 +// +// Expected: +// - Entity 2's annual subscription next_cycle ≈ 1 year from now +// - Stripe subscription period_end matches next_cycle +// ═══════════════════════════════════════════════════════════════════════════════ + +test.concurrent(`${chalk.yellowBright("legacy-merge-interval 2: entity attach annual after 1 cycle")}`, async () => { + const customerId = "legacy-merge-interval-2"; + + const wordsItem = items.monthlyWords({ includedUsage: 100 }); + const pro = products.pro({ id: "pro", items: [wordsItem] }); + const proAnnual = products.proAnnual({ + id: "pro-annual", + items: [wordsItem], + }); + + const { autumnV1, entities } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success", testClock: true }), + s.products({ list: [pro, proAnnual] }), + s.entities({ count: 2, featureId: TestFeature.Users }), + ], + actions: [ + s.attach({ productId: pro.id }), + s.advanceTestClock({ months: 1 }), + ], + }); + + // Get checkout preview for entity 2 with Pro Annual + const checkoutRes = await autumnV1.checkout({ + customer_id: customerId, + product_id: proAnnual.id, + entity_id: entities[1].id, + }); + + expect(checkoutRes.next_cycle).toBeDefined(); + const expectedDate = addYears(Date.now(), 1).getTime(); + const actualDate = checkoutRes.next_cycle?.starts_at ?? 0; + const daysDiff = Math.abs(differenceInDays(expectedDate, actualDate)); + expect(daysDiff).toBeLessThanOrEqual(1); + + // Attach Pro Annual to entity 2 + await autumnV1.attach({ + customer_id: customerId, + product_id: proAnnual.id, + entity_id: entities[1].id, + }); + + // Verify Stripe subscription period_end matches + const sub = await getCusSub({ + db: ctx.db, + org: ctx.org, + customerId, + productId: proAnnual.id, + }); + + const latestPeriodEnd = getLatestPeriodEnd({ sub }); + expect(latestPeriodEnd * 1000).toBeCloseTo( + checkoutRes.next_cycle?.starts_at ?? 0, + -Math.log10(toMilliseconds.days(1)), + ); +}); + +// ═══════════════════════════════════════════════════════════════════════════════ +// TEST 3: Entity attach annual with monthly usage price +// (from multiSubInterval3) +// +// Scenario: +// - Pro ($20/month) with Words feature +// - Pro Annual ($200/year) with Credits (arrear/monthly usage) + Words feature +// - 2 entities +// - Attach Pro monthly to customer +// - Advance clock 1.5 months +// - Attach Pro Annual to entity 2 +// +// Expected: +// - Entity 2's annual subscription next_cycle ≈ 1 year from now +// - At least one Stripe subscription item has matching period_end +// ═══════════════════════════════════════════════════════════════════════════════ + +test.concurrent(`${chalk.yellowBright("legacy-merge-interval 3: entity attach annual with monthly usage")}`, async () => { + const customerId = "legacy-merge-interval-3"; + + const wordsItem = items.monthlyWords({ includedUsage: 100 }); + const creditsItem = items.consumable({ featureId: TestFeature.Credits }); + const pro = products.pro({ id: "pro", items: [wordsItem] }); + const proAnnual = products.proAnnual({ + id: "pro-annual", + items: [creditsItem, wordsItem], + }); + + const { autumnV1, entities } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success", testClock: true }), + s.products({ list: [pro, proAnnual] }), + s.entities({ count: 2, featureId: TestFeature.Users }), + ], + actions: [ + s.attach({ productId: pro.id }), + s.advanceTestClock({ weeks: 6 }), + ], + }); + + // Get checkout preview for entity 2 with Pro Annual + const checkoutRes = await autumnV1.checkout({ + customer_id: customerId, + product_id: proAnnual.id, + entity_id: entities[1].id, + }); + + expect(checkoutRes.next_cycle).toBeDefined(); + expect(checkoutRes.next_cycle?.starts_at).toBeCloseTo( + addYears(new Date(), 1).getTime(), + -Math.log10(toMilliseconds.days(1)), + ); + + // Attach Pro Annual to entity 2 + await autumnV1.attach({ + customer_id: customerId, + product_id: proAnnual.id, + entity_id: entities[1].id, + }); + + // Verify at least one subscription item has matching period_end + const sub = await getCusSub({ + db: ctx.db, + org: ctx.org, + customerId, + productId: proAnnual.id, + }); + + const periodEndExists = sub!.items.data.some( + (item) => + Math.abs( + differenceInDays( + item.current_period_end * 1000, + checkoutRes.next_cycle?.starts_at ?? 0, + ), + ) < 1, + ); + + expect(periodEndExists).toBe(true); +}); diff --git a/server/tests/integration/billing/legacy/attach/new/legacy-new-merged.test.ts b/server/tests/integration/billing/legacy/attach/new/legacy-new-merged.test.ts index a1813fcd3..c5f4c248d 100644 --- a/server/tests/integration/billing/legacy/attach/new/legacy-new-merged.test.ts +++ b/server/tests/integration/billing/legacy/attach/new/legacy-new-merged.test.ts @@ -205,3 +205,94 @@ test.concurrent(`${chalk.yellowBright("legacy-new-merged 2: scheduled downgrade env: ctx.env, }); }); + +// ═══════════════════════════════════════════════════════════════════════════════ +// TEST 3: Multi-product attach and upgrade across groups +// (from multiProduct1) +// +// Scenario: +// - Group 1: Pro ($20) and Premium ($50) with Messages feature +// - Group 2: Pro ($20) and Premium ($50) with Words feature +// - Attach Pro from both groups simultaneously +// - Upgrade Group 1: Pro → Premium +// - Upgrade Group 2: Pro → Premium +// +// Expected: +// - Both Pro products attached initially +// - Each upgrade replaces only the product in that group +// ═══════════════════════════════════════════════════════════════════════════════ + +test.concurrent(`${chalk.yellowBright("legacy-new-merged 3: multi-product attach and upgrade across groups")}`, async () => { + const customerId = "legacy-new-merged-multi"; + + // Group 1 products (Messages feature) + const proGroup1 = products.base({ + id: "proGroup1", + group: `${customerId}-g1`, + items: [items.consumableMessages({ includedUsage: 10 })], + }); + const premiumGroup1 = products.base({ + id: "premiumGroup1", + group: `${customerId}-g1`, + items: [ + items.consumableMessages({ includedUsage: 100 }), + items.monthlyPrice({ price: 50 }), + ], + }); + + // Group 2 products (Words feature) + const proGroup2 = products.base({ + id: "proGroup2", + group: `${customerId}-g2`, + items: [items.consumableWords({ includedUsage: 10 })], + }); + const premiumGroup2 = products.base({ + id: "premiumGroup2", + group: `${customerId}-g2`, + items: [ + items.consumableWords({ includedUsage: 10 }), + items.monthlyPrice({ price: 50 }), + ], + }); + + const { autumnV1 } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success", testClock: true }), + s.products({ + list: [proGroup1, proGroup2, premiumGroup1, premiumGroup2], + }), + ], + actions: [], + }); + + // 1. Attach Pro from both groups simultaneously + await autumnV1.attach({ + customer_id: customerId, + product_ids: [proGroup1.id, proGroup2.id], + }); + + let customer = await autumnV1.customers.get(customerId); + expectProductAttached({ customer, productId: proGroup1.id }); + expectProductAttached({ customer, productId: proGroup2.id }); + + // 2. Upgrade Group 1: Pro → Premium + await autumnV1.attach({ + customer_id: customerId, + product_id: premiumGroup1.id, + }); + + customer = await autumnV1.customers.get(customerId); + expectProductAttached({ customer, productId: premiumGroup1.id }); + expectProductAttached({ customer, productId: proGroup2.id }); + + // 3. Upgrade Group 2: Pro → Premium + await autumnV1.attach({ + customer_id: customerId, + product_id: premiumGroup2.id, + }); + + customer = await autumnV1.customers.get(customerId); + expectProductAttached({ customer, productId: premiumGroup1.id }); + expectProductAttached({ customer, productId: premiumGroup2.id }); +}); diff --git a/server/tests/integration/billing/legacy/attach/trial/legacy-trial.test.ts b/server/tests/integration/billing/legacy/attach/trial/legacy-trial.test.ts index 637a97951..6c32932aa 100644 --- a/server/tests/integration/billing/legacy/attach/trial/legacy-trial.test.ts +++ b/server/tests/integration/billing/legacy/attach/trial/legacy-trial.test.ts @@ -1,12 +1,20 @@ /** - * Legacy Attach V1 Trial - MainIsTrial Branch Tests + * Legacy Attach V1 Trial Tests * * Migrated from: * - server/tests/merged/trial/trial1.test.ts (upgrade during trial: pro trial → premium trial) * - server/tests/merged/trial/trial2.test.ts (upgrade after trial ends: pro trial → active → premium trial) + * - server/tests/attach/upgradeOld/upgradeOld1.test.ts (trial to paid upgrade) + * - server/tests/attach/basic/basic8.test.ts (trial duplicates with same fingerprint) + * - server/tests/attach/upgradeOld/upgradeOld2.test.ts (paid to trial upgrade) + * - server/tests/attach/upgradeOld/upgradeOld3.test.ts (trial to trial upgrade) * - * Tests V1 attach behavior for the MainIsTrial branch where a customer upgrades - * from one trial product to another. + * Tests V1 attach behavior for trial-related scenarios: + * - Upgrading from one trial product to another (MainIsTrial branch) + * - Upgrading from trial to paid product + * - Trial deduplication based on fingerprint (unique_fingerprint: true) + * - Paid to trial upgrade + * - Trial to trial upgrade with time advancement */ /** biome-ignore-all lint/suspicious/noExplicitAny: test file */ @@ -14,12 +22,17 @@ import { expect, test } from "bun:test"; import { type ApiCustomerV3, + ApiVersion, AttachBranch, CusProductStatus, } from "@autumn/shared"; +import { expectCustomerFeatureCorrect } from "@tests/integration/billing/utils/expectCustomerFeatureCorrect"; import { expectCustomerInvoiceCorrect } from "@tests/integration/billing/utils/expectCustomerInvoiceCorrect"; +import { expectProductActive } from "@tests/integration/billing/utils/expectCustomerProductCorrect"; import { expectProductTrialing } from "@tests/integration/billing/utils/expectCustomerProductTrialing"; import { expectSubToBeCorrect } from "@tests/merged/mergeUtils/expectSubCorrect"; +import { TestFeature } from "@tests/setup/v2Features"; +import { expectCustomerV0Correct } from "@tests/utils/expectUtils/expectCustomerV0Correct"; import { expectProductAttached } from "@tests/utils/expectUtils/expectProductAttached"; import { items } from "@tests/utils/fixtures/items"; import { products } from "@tests/utils/fixtures/products"; @@ -29,6 +42,7 @@ import { initScenario, s } from "@tests/utils/testInitUtils/initScenario"; import chalk from "chalk"; import { addDays } from "date-fns"; import { Decimal } from "decimal.js"; +import { AutumnInt } from "@/external/autumn/autumnCli"; import { timeout } from "@/utils/genUtils"; // ═══════════════════════════════════════════════════════════════════════════════ @@ -240,3 +254,410 @@ test.concurrent(`${chalk.yellowBright("legacy-trial 2: upgrade after trial ends shouldBeTrialing: true, }); }); + +// ═══════════════════════════════════════════════════════════════════════════════ +// TEST 3: Upgrade from trial product to paid product +// (from legacy-upgrade.test.ts / upgradeOld1) +// +// Scenario: +// - Pro product ($20/month) with 7-day trial, dashboard + 10 messages +// - Premium product ($50/month), 100 messages +// - Customer with payment method +// - Attach pro (starts trial) +// - Advance 3 days +// - Upgrade to premium → trial ends, premium starts +// +// Expected: +// - Customer has premium product after upgrade +// - Invoice total is $50 (premium price) +// ═══════════════════════════════════════════════════════════════════════════════ + +test.concurrent(`${chalk.yellowBright("legacy-trial 3: trial to paid upgrade")}`, async () => { + const customerId = "legacy-trial-3"; + + const proWithTrial = products.proWithTrial({ + id: "pro-trial", + items: [items.dashboard(), items.monthlyMessages({ includedUsage: 10 })], + trialDays: 7, + cardRequired: true, + }); + + const premium = products.premium({ + id: "premium", + items: [items.monthlyMessages({ includedUsage: 100 })], + }); + + const autumn = new AutumnInt({ secretKey: ctx.orgSecretKey }); + const autumnV1 = new AutumnInt({ + secretKey: ctx.orgSecretKey, + version: ApiVersion.V0_1, + }); + + await initScenario({ + customerId, + setup: [ + s.customer({ testClock: true, paymentMethod: "success" }), + s.products({ list: [proWithTrial, premium] }), + ], + actions: [ + s.attach({ productId: proWithTrial.id }), + s.advanceTestClock({ days: 3 }), + ], + }); + + // Upgrade to premium + await autumn.attach({ + customer_id: customerId, + product_id: premium.id, + }); + + await timeout(2500); + + // Check product, entitlements and invoices + const res = await autumnV1.customers.get(customerId); + + await expectCustomerV0Correct({ + sent: premium, + cusRes: res, + }); + + const invoices = res.invoices; + + expect(invoices?.[0].total).toBe(50); + expect(invoices?.[0].product_ids).toContain(premium.id); +}); + +// ═══════════════════════════════════════════════════════════════════════════════ +// TEST 4: Trial duplicates with same fingerprint +// (from basic8) +// +// Scenario: +// - Pro product with 7-day trial, unique_fingerprint: true +// - Customer 1 with fingerprint X attaches pro → gets trial +// - Customer 2 with same fingerprint X attaches pro → no trial, full price +// +// Expected: +// - Customer 1: trialing, invoice total $0 +// - Customer 2: active (no trial), invoice total $20 +// ═══════════════════════════════════════════════════════════════════════════════ + +test.concurrent(`${chalk.yellowBright("legacy-trial 4: trial duplicates with same fingerprint")}`, async () => { + const customerId = "legacy-trial-4"; + const customerId2 = "legacy-trial-4-dup"; + const randFingerprint = Math.random().toString(36).substring(2, 15); + + const proWithTrial = products.proWithTrial({ + id: "pro", + items: [ + items.dashboard(), + items.monthlyMessages({ includedUsage: 10 }), + items.adminRights(), + ], + trialDays: 7, + cardRequired: true, + uniqueFingerprint: true, + }); + + const { autumnV1 } = await initScenario({ + customerId, + setup: [ + s.customer({ + paymentMethod: "success", + data: { fingerprint: randFingerprint }, + }), + s.otherCustomers([ + { + id: customerId2, + paymentMethod: "success", + data: { fingerprint: randFingerprint }, + }, + ]), + s.products({ list: [proWithTrial] }), + ], + actions: [s.attach({ productId: proWithTrial.id })], + }); + + // Verify customer 1 is trialing with $0 invoice + const customer1 = await autumnV1.customers.get(customerId); + + await expectProductTrialing({ + customer: customer1, + productId: proWithTrial.id, + }); + + await expectCustomerInvoiceCorrect({ + customer: customer1, + count: 1, + latestTotal: 0, + }); + + // Attach same product to customer 2 (same fingerprint → no trial) + await autumnV1.attach({ + customer_id: customerId2, + product_id: proWithTrial.id, + }); + + // Verify customer 2 is active (not trialing) with $20 invoice + const customer2 = await autumnV1.customers.get(customerId2); + + await expectProductActive({ + customer: customer2, + productId: proWithTrial.id, + }); + + await expectCustomerInvoiceCorrect({ + customer: customer2, + count: 1, + latestTotal: 20, + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════════ +// TEST 5: Paid to trial upgrade +// (from upgradeOld2) +// +// Scenario: +// - Pro product ($20/month) without trial +// - Premium product ($50/month) with 7-day trial +// - Attach Pro (paid) → customer is active +// - Upgrade to Premium → customer gets trial +// +// Expected: +// - Customer is trialing on Premium after upgrade +// ═══════════════════════════════════════════════════════════════════════════════ + +test.concurrent(`${chalk.yellowBright("legacy-trial 5: paid to trial upgrade")}`, async () => { + const customerId = "legacy-trial-5"; + + // Pro: $20/month, no trial + const pro = products.pro({ + id: "pro", + items: [ + items.dashboard(), + items.monthlyMessages({ includedUsage: 10 }), + items.adminRights(), + ], + }); + + // Premium: $50/month with 7-day trial + const premiumWithTrial = products.premiumWithTrial({ + id: "premium", + items: [items.monthlyMessages({ includedUsage: 100 })], + trialDays: 7, + cardRequired: true, + }); + + const { autumnV1 } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success", testClock: true }), + s.products({ list: [pro, premiumWithTrial] }), + ], + actions: [], + }); + + // Attach Pro (paid) + await autumnV1.attach({ + customer_id: customerId, + product_id: pro.id, + }); + + const customerAfterPro = + await autumnV1.customers.get(customerId); + + await expectProductActive({ + customer: customerAfterPro, + productId: pro.id, + }); + + await expectCustomerInvoiceCorrect({ + customer: customerAfterPro, + count: 1, + latestTotal: 20, // Pro $20 + }); + + // Upgrade to Premium with trial + await autumnV1.attach({ + customer_id: customerId, + product_id: premiumWithTrial.id, + }); + + const customerAfterPremium = + await autumnV1.customers.get(customerId); + + await expectProductTrialing({ + customer: customerAfterPremium, + productId: premiumWithTrial.id, + }); + + expectCustomerFeatureCorrect({ + customer: customerAfterPremium, + featureId: TestFeature.Messages, + includedUsage: 100, + balance: 100, + usage: 0, + }); + + // 2 invoices: Pro paid ($20) + Premium trial ($0) + await expectCustomerInvoiceCorrect({ + customer: customerAfterPremium, + count: 2, + latestTotal: 0, // Premium trial - $0 + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════════ +// TEST 6: Trial to trial upgrade with time advancement +// (from upgradeOld3) +// +// Scenario: +// - Pro product ($20/month) with 7-day trial +// - Premium product ($50/month) with 7-day trial +// - Attach Pro → customer is trialing +// - Advance clock 3 days (still in trial) +// - Upgrade to Premium → customer still trialing on Premium +// +// Expected: +// - Customer is trialing on Premium after upgrade +// - Invoice total is $0 (still in trial) +// ═══════════════════════════════════════════════════════════════════════════════ + +test.concurrent(`${chalk.yellowBright("legacy-trial 6: trial to trial upgrade with time advancement")}`, async () => { + const customerId = "legacy-trial-6"; + + // Pro: $20/month with 7-day trial + const proWithTrial = products.proWithTrial({ + id: "pro", + items: [ + items.dashboard(), + items.monthlyMessages({ includedUsage: 10 }), + items.adminRights(), + ], + trialDays: 7, + cardRequired: true, + uniqueFingerprint: true, + }); + + // Premium: $50/month with 7-day trial + const premiumWithTrial = products.premiumWithTrial({ + id: "premium", + items: [items.monthlyMessages({ includedUsage: 100 })], + trialDays: 7, + cardRequired: true, + }); + + const { autumnV1, testClockId } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success", testClock: true }), + s.products({ list: [proWithTrial, premiumWithTrial] }), + ], + actions: [], + }); + + // Attach Pro with trial + await autumnV1.attach({ + customer_id: customerId, + product_id: proWithTrial.id, + }); + + const customerAfterPro = + await autumnV1.customers.get(customerId); + + await expectProductTrialing({ + customer: customerAfterPro, + productId: proWithTrial.id, + }); + + // Advance clock 3 days (still in trial) + await advanceTestClock({ + stripeCli: ctx.stripeCli, + testClockId: testClockId!, + advanceTo: addDays(new Date(), 3).getTime(), + waitForSeconds: 10, + }); + + // Upgrade to Premium with trial + await autumnV1.attach({ + customer_id: customerId, + product_id: premiumWithTrial.id, + }); + + const customerAfterPremium = + await autumnV1.customers.get(customerId); + + await expectProductTrialing({ + customer: customerAfterPremium, + productId: premiumWithTrial.id, + }); + + expectCustomerFeatureCorrect({ + customer: customerAfterPremium, + featureId: TestFeature.Messages, + includedUsage: 100, + balance: 100, + usage: 0, + }); + + await expectCustomerInvoiceCorrect({ + customer: customerAfterPremium, + count: 2, + latestTotal: 0, // Trial - $0 + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════════ +// TEST 7: Skip trial with free_trial=false flag +// (from others7) +// +// Scenario: +// - Pro product ($20/month) with 7-day trial and arrear Words +// - Attach with free_trial=false → skip trial, charge immediately +// +// Expected: +// - Customer is active (not trialing) +// - 1 invoice for base price ($20) +// ═══════════════════════════════════════════════════════════════════════════════ + +test.concurrent(`${chalk.yellowBright("legacy-trial 7: skip trial with free_trial=false")}`, async () => { + const customerId = "legacy-trial-7"; + + // Pro with trial and arrear words + const proWithTrial = products.proWithTrial({ + id: "pro", + items: [items.consumableWords()], + trialDays: 7, + cardRequired: true, + }); + + const { autumnV1 } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success", testClock: true }), + s.products({ list: [proWithTrial] }), + ], + actions: [], + }); + + // Attach with free_trial=false to skip trial + await autumnV1.attach({ + customer_id: customerId, + product_id: proWithTrial.id, + free_trial: false, + }); + + const customer = await autumnV1.customers.get(customerId); + + // Should be active, not trialing + await expectProductActive({ + customer, + productId: proWithTrial.id, + }); + + // Invoice should be for base price ($20) + await expectCustomerInvoiceCorrect({ + customer, + count: 1, + latestTotal: 20, // Pro base price + }); +}); diff --git a/server/tests/integration/billing/legacy/attach/upgrade/legacy-upgrade-prepaid.test.ts b/server/tests/integration/billing/legacy/attach/upgrade/legacy-upgrade-prepaid.test.ts new file mode 100644 index 000000000..b99bba047 --- /dev/null +++ b/server/tests/integration/billing/legacy/attach/upgrade/legacy-upgrade-prepaid.test.ts @@ -0,0 +1,541 @@ +/** + * Legacy Upgrade Tests - Prepaid Billing + * + * Migrated from: + * - server/tests/attach/upgrade/upgrade4.test.ts (Prepaid seats: Pro → Premium → Pro Annual) + * - server/tests/attach/upgrade/upgrade5.test.ts (Prepaid messages: Pro → Premium) + * + * Tests V1 attach behavior for product upgrades with prepaid billing: + * - Prepaid seats (continuous use) with quantity options + * - Prepaid messages (single use) with quantity options + * - V1 quantity semantics (excludes allowance/includedUsage) + * - Prepaid quantity inheritance on upgrade without options + */ + +import { test } from "bun:test"; +import { type ApiCustomerV3, OnDecrease, OnIncrease } from "@autumn/shared"; +import { expectCustomerFeatureCorrect } from "@tests/integration/billing/utils/expectCustomerFeatureCorrect"; +import { expectCustomerInvoiceCorrect } from "@tests/integration/billing/utils/expectCustomerInvoiceCorrect"; +import { expectCustomerProducts } 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 ctx from "@tests/utils/testInitUtils/createTestContext"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario"; +import chalk from "chalk"; + +// ═══════════════════════════════════════════════════════════════════════════════ +// TEST 1: Prepaid seats (continuous use) - Pro → Premium → Pro Annual +// (from upgrade4) +// +// Scenario: +// - Pro ($20/month) with prepaid Users ($10/user, billingUnits: 1) +// - Premium ($50/month) with prepaid Users ($10/user, billingUnits: 1) +// - Pro annual ($200/year) with prepaid Users ($10/user, billingUnits: 1) +// - Attach Pro with 4 users (4 × $10 = $40) +// - Upgrade to Premium with 6 users (6 × $10 = $60) +// - Upgrade to Pro Annual with 3 users (3 × $10 = $30) +// +// Expected: +// - Customer has correct product and balance after each upgrade +// - Invoice totals include base price + prepaid seat charges +// ═══════════════════════════════════════════════════════════════════════════════ + +test.concurrent(`${chalk.yellowBright("legacy-upgrade-prepaid 1: prepaid seats Pro → Premium → Pro Annual")}`, async () => { + const customerId = "legacy-upgrade-prepaid-1"; + + // Prepaid users: $10/user (default), billingUnits: 1 + const proUsers = items.prepaidUsers({ includedUsage: 0, billingUnits: 1 }); + const premiumUsers = items.prepaidUsers({ + includedUsage: 0, + billingUnits: 1, + }); + const proAnnualUsers = items.prepaidUsers({ + includedUsage: 0, + billingUnits: 1, + }); + + const pro = products.pro({ id: "pro", items: [proUsers] }); + const premium = products.premium({ id: "premium", items: [premiumUsers] }); + const proAnnual = products.proAnnual({ + id: "pro-annual", + items: [proAnnualUsers], + }); + + // Setup: Create customer and products + const { autumnV1 } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success", testClock: true }), + s.products({ list: [pro, premium, proAnnual] }), + ], + actions: [], + }); + + // Attach Pro with 4 users + // Invoice: Pro base ($20) + 4 users × $10 = $60 + await autumnV1.attach({ + customer_id: customerId, + product_id: pro.id, + options: [{ feature_id: TestFeature.Users, quantity: 4 }], + }); + + const customerInitial = + await autumnV1.customers.get(customerId); + + await expectCustomerProducts({ + customer: customerInitial, + active: [pro.id], + }); + + expectCustomerFeatureCorrect({ + customer: customerInitial, + featureId: TestFeature.Users, + includedUsage: 4, + balance: 4, // 4 prepaid users + usage: 0, + }); + + await expectCustomerInvoiceCorrect({ + customer: customerInitial, + count: 1, + latestTotal: 60, // Pro $20 + 4 × $10 = $60 + }); + + // Upgrade to Premium with 6 users + // New total: Premium base ($50) + 6 users × $10 = $110 + // Diff from previous: need to calculate based on proration + await autumnV1.attach({ + customer_id: customerId, + product_id: premium.id, + options: [{ feature_id: TestFeature.Users, quantity: 6 }], + }); + + const customerAfterPremium = + await autumnV1.customers.get(customerId); + + await expectCustomerProducts({ + customer: customerAfterPremium, + active: [premium.id], + }); + + expectCustomerFeatureCorrect({ + customer: customerAfterPremium, + featureId: TestFeature.Users, + includedUsage: 6, + balance: 6, // 6 prepaid users + usage: 0, + }); + + // Verify invoice count increased + await expectCustomerInvoiceCorrect({ + customer: customerAfterPremium, + count: 2, + }); + + // Upgrade to Pro Annual with 3 users + // New total: Pro Annual base ($200) + 3 users × $10 = $230 + await autumnV1.attach({ + customer_id: customerId, + product_id: proAnnual.id, + options: [{ feature_id: TestFeature.Users, quantity: 3 }], + }); + + const customerAfterProAnnual = + await autumnV1.customers.get(customerId); + + await expectCustomerProducts({ + customer: customerAfterProAnnual, + active: [proAnnual.id], + }); + + expectCustomerFeatureCorrect({ + customer: customerAfterProAnnual, + featureId: TestFeature.Users, + includedUsage: 3, + balance: 3, // 3 prepaid users + usage: 0, + }); + + // Verify invoice count increased + await expectCustomerInvoiceCorrect({ + customer: customerAfterProAnnual, + count: 3, + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════════ +// TEST 2: Prepaid messages (single use) - Pro → Premium +// (from upgrade5) +// +// Scenario: +// - Pro ($20/month) with prepaid Messages ($10/100 units, billingUnits: 100) +// - Premium ($50/month) with prepaid Messages ($10/100 units, billingUnits: 100) +// - Attach Pro with 300 messages (3 packs × $10 = $30) +// - Track some usage +// - Upgrade to Premium with 600 messages (6 packs × $10 = $60) +// +// Expected: +// - Customer has correct product and balance after each upgrade +// - Invoice totals include base price + prepaid message packs +// ═══════════════════════════════════════════════════════════════════════════════ + +test.concurrent(`${chalk.yellowBright("legacy-upgrade-prepaid 2: prepaid messages Pro → Premium")}`, async () => { + const customerId = "legacy-upgrade-prepaid-2"; + + // Prepaid messages: $10/100 units (default) + const proMessages = items.prepaidMessages({ + includedUsage: 0, + billingUnits: 100, + }); + const premiumMessages = items.prepaidMessages({ + includedUsage: 0, + billingUnits: 100, + }); + + const pro = products.pro({ id: "pro", items: [proMessages] }); + const premium = products.premium({ id: "premium", items: [premiumMessages] }); + + // Setup: Create customer and products + const { autumnV1 } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success", testClock: true }), + s.products({ list: [pro, premium] }), + ], + actions: [], + }); + + // Attach Pro with 300 messages (3 packs) + // Invoice: Pro base ($20) + 3 packs × $10 = $50 + await autumnV1.attach({ + customer_id: customerId, + product_id: pro.id, + options: [{ feature_id: TestFeature.Messages, quantity: 300 }], + }); + + const customerInitial = + await autumnV1.customers.get(customerId); + + await expectCustomerProducts({ + customer: customerInitial, + active: [pro.id], + }); + + expectCustomerFeatureCorrect({ + customer: customerInitial, + featureId: TestFeature.Messages, + includedUsage: 300, + balance: 300, // 300 prepaid messages + usage: 0, + }); + + await expectCustomerInvoiceCorrect({ + customer: customerInitial, + count: 1, + latestTotal: 50, // Pro $20 + 3 × $10 = $50 + }); + + // Track 100 messages usage + await autumnV1.track({ + customer_id: customerId, + feature_id: TestFeature.Messages, + value: 100, + }); + + await new Promise((r) => setTimeout(r, 2000)); + + // Verify state before upgrade + const customerBeforeUpgrade = + await autumnV1.customers.get(customerId); + + expectCustomerFeatureCorrect({ + customer: customerBeforeUpgrade, + featureId: TestFeature.Messages, + includedUsage: 300, + balance: 200, // 300 - 100 = 200 + usage: 100, + }); + + // Upgrade to Premium with 600 messages (6 packs) + // New prepaid: 6 packs × $10 = $60 + // Old prepaid refund: 3 packs × $10 = $30 + // Base price diff: $50 - $20 = $30 + // Expected invoice: $30 (base diff) + $60 (new prepaid) - $30 (refund) = $60 + await autumnV1.attach({ + customer_id: customerId, + product_id: premium.id, + options: [{ feature_id: TestFeature.Messages, quantity: 600 }], + }); + + const customerAfterPremium = + await autumnV1.customers.get(customerId); + + await expectCustomerProducts({ + customer: customerAfterPremium, + active: [premium.id], + }); + + // Balance should be new prepaid quantity (usage does not carry over for prepaid) + expectCustomerFeatureCorrect({ + customer: customerAfterPremium, + featureId: TestFeature.Messages, + includedUsage: 600, + balance: 600, // 600 new prepaid messages + usage: 0, + }); + + // Verify invoice count increased + await expectCustomerInvoiceCorrect({ + customer: customerAfterPremium, + count: 2, + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════════ +// TEST 3: UPGRADE (Pro → Premium) with explicit prepaid options +// V1 attach quantity does NOT include included usage +// ═══════════════════════════════════════════════════════════════════════════════ + +test.concurrent(`${chalk.yellowBright("legacy-upgrade-prepaid 3: pro → premium with explicit options (quantity excludes allowance)")}`, async () => { + const customerId = "legacy-upgrade-prepaid-3"; + const billingUnits = 100; + + // Pro: $10/pack, $20 base, 100 includedUsage + const proIncludedUsage = 100; + const proPricePerPack = 10; + const proPrepaidItem = items.prepaidMessages({ + includedUsage: proIncludedUsage, + billingUnits, + price: proPricePerPack, + config: { + on_increase: OnIncrease.ProrateImmediately, + on_decrease: OnDecrease.ProrateImmediately, + }, + }); + const proPriceItem = items.monthlyPrice({ price: 20 }); + const pro = products.base({ + id: "pro", + items: [proPrepaidItem, proPriceItem], + }); + + // Premium: $15/pack, $50 base, 200 includedUsage + const premiumIncludedUsage = 200; + const premiumPricePerPack = 15; + const premiumPrepaidItem = items.prepaidMessages({ + includedUsage: premiumIncludedUsage, + billingUnits, + price: premiumPricePerPack, + config: { + on_increase: OnIncrease.ProrateImmediately, + on_decrease: OnDecrease.ProrateImmediately, + }, + }); + const premiumPriceItem = items.monthlyPrice({ price: 50 }); + const premium = products.base({ + id: "premium", + items: [premiumPrepaidItem, premiumPriceItem], + }); + + // V1 attach: initial quantity = 3 packs (300 units EXCLUDING allowance) + // So total balance = 100 (allowance) + 300 (prepaid) = 400 + const initialPacks = 3; + const initialQuantityV1 = initialPacks * billingUnits; // 300 - V1 quantity excludes allowance + const initialTotalBalance = proIncludedUsage + initialQuantityV1; // 400 + + const { autumnV1 } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [pro, premium] }), + ], + actions: [ + // V1 attach to Pro with 3 packs (quantity excludes allowance) + s.attach({ + productId: pro.id, + options: [ + { feature_id: TestFeature.Messages, quantity: initialQuantityV1 }, + ], + }), + ], + }); + + // Verify initial state on Pro + const customerBefore = + await autumnV1.customers.get(customerId); + expectCustomerFeatureCorrect({ + customer: customerBefore, + featureId: TestFeature.Messages, + includedUsage: initialTotalBalance, // 100 + 300 = 400 + balance: initialTotalBalance, + usage: 0, + }); + + // Upgrade to Premium with 5 packs (500 units EXCLUDING allowance) + // V1 quantity still excludes allowance + const upgradePacks = 5; + const upgradeQuantityV1 = upgradePacks * billingUnits; // 500 - V1 quantity excludes allowance + const premiumTotalBalance = premiumIncludedUsage + upgradeQuantityV1; // 200 + 500 = 700 + + // V1 attach to Premium + await autumnV1.attach({ + customer_id: customerId, + product_id: premium.id, + options: [ + { + feature_id: TestFeature.Messages, + quantity: upgradeQuantityV1, // 500 (excluding allowance) + }, + ], + }); + + // Verify customer upgraded to Premium with correct total balance + const customerAfter = await autumnV1.customers.get(customerId); + expectCustomerFeatureCorrect({ + customer: customerAfter, + featureId: TestFeature.Messages, + includedUsage: premiumTotalBalance, // 200 + 500 = 700 + balance: premiumTotalBalance, + usage: 0, + }); + + await expectSubToBeCorrect({ + db: ctx.db, + customerId, + org: ctx.org, + env: ctx.env, + }); + + // Verify invoice: 2 invoices (initial attach + upgrade) + // Upgrade cost = (new base - old base) + (new packs * new price - old packs * old price) + // = (50 - 20) + (5 * 15 - 3 * 10) = 30 + (75 - 30) = 30 + 45 = 75 + await expectCustomerInvoiceCorrect({ + customerId, + count: 2, + latestTotal: + (premiumPriceItem.price ?? 0) - + (proPriceItem.price ?? 0) + + (upgradePacks * premiumPricePerPack - initialPacks * proPricePerPack), + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════════ +// TEST 4: UPGRADE (Pro → Premium) without options - prepaid quantity inherits +// ═══════════════════════════════════════════════════════════════════════════════ + +test.concurrent(`${chalk.yellowBright("legacy-upgrade-prepaid 4: pro → premium without options (quantity inherits)")}`, async () => { + const customerId = "legacy-upgrade-prepaid-4"; + const billingUnits = 100; + + // Pro: $10/pack, $20 base, 100 includedUsage + const proIncludedUsage = 100; + const proPricePerPack = 10; + const proPrepaidItem = items.prepaidMessages({ + includedUsage: proIncludedUsage, + billingUnits, + price: proPricePerPack, + config: { + on_increase: OnIncrease.ProrateImmediately, + on_decrease: OnDecrease.ProrateImmediately, + }, + }); + const proPriceItem = items.monthlyPrice({ price: 20 }); + const pro = products.base({ + id: "pro", + items: [proPrepaidItem, proPriceItem], + }); + + // Premium: $15/pack, $50 base, 200 includedUsage + const premiumIncludedUsage = 200; + const premiumPricePerPack = 15; + const premiumPrepaidItem = items.prepaidMessages({ + includedUsage: premiumIncludedUsage, + billingUnits, + price: premiumPricePerPack, + config: { + on_increase: OnIncrease.ProrateImmediately, + on_decrease: OnDecrease.ProrateImmediately, + }, + }); + const premiumPriceItem = items.monthlyPrice({ price: 50 }); + const premium = products.base({ + id: "premium", + items: [premiumPrepaidItem, premiumPriceItem], + }); + + // V1 attach: initial quantity = 4 packs (400 units EXCLUDING allowance) + // So total balance = 100 (allowance) + 400 (prepaid) = 500 + const initialPacks = 4; + const initialQuantityV1 = initialPacks * billingUnits; // 400 - V1 quantity excludes allowance + const initialTotalBalance = proIncludedUsage + initialQuantityV1; // 500 + + const { autumnV1 } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [pro, premium] }), + ], + actions: [ + // V1 attach to Pro with 4 packs (quantity excludes allowance) + s.attach({ + productId: pro.id, + options: [ + { feature_id: TestFeature.Messages, quantity: initialQuantityV1 }, + ], + }), + ], + }); + + // Verify initial state on Pro + const customerBefore = + await autumnV1.customers.get(customerId); + expectCustomerFeatureCorrect({ + customer: customerBefore, + featureId: TestFeature.Messages, + includedUsage: initialTotalBalance, // 100 + 400 = 500 + balance: initialTotalBalance, + usage: 0, + }); + + // Upgrade to Premium WITHOUT passing options + // The prepaid quantity should inherit from Pro (4 packs) + await autumnV1.attach({ + customer_id: customerId, + product_id: premium.id, + // NOTE: No options passed - should inherit from Pro + }); + + // When no options passed, the system should inherit the prepaid packs from Pro + // Premium total balance = 200 (allowance) + 400 (inherited prepaid) = 600 + const premiumTotalBalance = premiumIncludedUsage + initialQuantityV1; + + // Verify customer upgraded to Premium with inherited prepaid quantity + const customerAfter = await autumnV1.customers.get(customerId); + expectCustomerFeatureCorrect({ + customer: customerAfter, + featureId: TestFeature.Messages, + includedUsage: premiumTotalBalance, // 200 + 400 = 600 + balance: premiumTotalBalance, + usage: 0, + }); + + await expectSubToBeCorrect({ + db: ctx.db, + customerId, + org: ctx.org, + env: ctx.env, + }); + + // Verify invoice: 2 invoices (initial attach + upgrade) + // Upgrade cost = (new base - old base) + (packs * (new price - old price)) + // = (50 - 20) + (4 * (15 - 10)) = 30 + 20 = 50 + await expectCustomerInvoiceCorrect({ + customerId, + count: 2, + latestTotal: + (premiumPriceItem.price ?? 0) - + (proPriceItem.price ?? 0) + + initialPacks * (premiumPricePerPack - proPricePerPack), + }); +}); diff --git a/server/tests/integration/billing/legacy/attach/upgrade/legacy-upgrade-usage.test.ts b/server/tests/integration/billing/legacy/attach/upgrade/legacy-upgrade-usage.test.ts new file mode 100644 index 000000000..68b50637b --- /dev/null +++ b/server/tests/integration/billing/legacy/attach/upgrade/legacy-upgrade-usage.test.ts @@ -0,0 +1,523 @@ +/** + * Legacy Upgrade Tests - Usage-based Billing + * + * Migrated from: + * - server/tests/attach/upgrade/upgrade1.test.ts (Pro → Premium → Growth with consumable Words) + * - server/tests/attach/upgrade/upgrade2.test.ts (Pro monthly → Pro annual → Premium annual) + * - server/tests/attach/upgrade/upgrade3.test.ts (Arrear prorated seats with entities) + * + * Tests V1 attach behavior for product upgrades with usage-based billing: + * - Consumable (arrear) billing upgrades + * - Monthly to annual interval changes + * - Arrear prorated seat-based billing with entities + */ + +import { test } from "bun:test"; +import type { ApiCustomerV3 } from "@autumn/shared"; +import { expectCustomerFeatureCorrect } from "@tests/integration/billing/utils/expectCustomerFeatureCorrect"; +import { expectCustomerInvoiceCorrect } from "@tests/integration/billing/utils/expectCustomerInvoiceCorrect"; +import { expectCustomerProducts } from "@tests/integration/billing/utils/expectCustomerProductCorrect"; +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"; + +// ═══════════════════════════════════════════════════════════════════════════════ +// TEST 1: Upgrade with consumable (arrear) billing - Pro → Premium → Growth +// (from upgrade1) +// +// Scenario: +// - Pro ($20/month) with consumable Words (100 included, $0.05/overage) +// - Premium ($50/month) with consumable Words (100 included) +// - Growth ($100/month) with consumable Words (100 included) +// - Attach Pro, track 200 words (100 overage = $5), upgrade to Premium +// - Track 300 words (200 overage = $10), upgrade to Growth +// +// Expected: +// - Customer has correct product and balance after each upgrade +// - Invoice totals include base price diff + overage charges +// - Usage resets after each upgrade +// ═══════════════════════════════════════════════════════════════════════════════ + +test.concurrent(`${chalk.yellowBright("legacy-upgrade-usage 1: consumable upgrades Pro → Premium → Growth")}`, async () => { + const customerId = "legacy-upgrade-usage-1"; + + // Consumable words: 100 included, $0.05/overage + const proWords = items.consumableWords({ includedUsage: 100 }); + const premiumWords = items.consumableWords({ includedUsage: 100 }); + const growthWords = items.consumableWords({ includedUsage: 100 }); + + const pro = products.pro({ id: "pro", items: [proWords] }); + const premium = products.premium({ id: "premium", items: [premiumWords] }); + const growth = products.growth({ id: "growth", items: [growthWords] }); + + // Setup: Create customer and products, attach Pro + const { autumnV1 } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success", testClock: true }), + s.products({ list: [pro, premium, growth] }), + ], + actions: [s.attach({ productId: pro.id })], + }); + + // Verify initial state after Pro attach + const customerInitial = + await autumnV1.customers.get(customerId); + + await expectCustomerProducts({ + customer: customerInitial, + active: [pro.id], + }); + + expectCustomerFeatureCorrect({ + customer: customerInitial, + featureId: TestFeature.Words, + includedUsage: 100, + balance: 100, + usage: 0, + }); + + await expectCustomerInvoiceCorrect({ + customer: customerInitial, + count: 1, + latestTotal: 20, // Pro base price + }); + + // Track 200 words (100 overage at $0.05 = $5) + await autumnV1.track({ + customer_id: customerId, + feature_id: TestFeature.Words, + value: 200, + }); + + await new Promise((r) => setTimeout(r, 2000)); + + // Verify state before upgrade (in overage) + const customerBeforePremium = + await autumnV1.customers.get(customerId); + + expectCustomerFeatureCorrect({ + customer: customerBeforePremium, + featureId: TestFeature.Words, + includedUsage: 100, + balance: -100, // 100 - 200 = -100 (overage) + usage: 200, + }); + + // Upgrade to Premium + // Expected: $50 - $20 = $30 price diff + $5 overage = $35 + await autumnV1.attach({ + customer_id: customerId, + product_id: premium.id, + }); + + const customerAfterPremium = + await autumnV1.customers.get(customerId); + + await expectCustomerProducts({ + customer: customerAfterPremium, + active: [premium.id], + }); + + // Usage resets after upgrade + expectCustomerFeatureCorrect({ + customer: customerAfterPremium, + featureId: TestFeature.Words, + includedUsage: 100, + balance: 100, + usage: 0, + }); + + await expectCustomerInvoiceCorrect({ + customer: customerAfterPremium, + count: 2, + latestTotal: 35, // $30 price diff + $5 overage + }); + + // Track 300 words (200 overage at $0.05 = $10) + await autumnV1.track({ + customer_id: customerId, + feature_id: TestFeature.Words, + value: 300, + }); + + await new Promise((r) => setTimeout(r, 2000)); + + // Verify state before Growth upgrade (in overage) + const customerBeforeGrowth = + await autumnV1.customers.get(customerId); + + expectCustomerFeatureCorrect({ + customer: customerBeforeGrowth, + featureId: TestFeature.Words, + includedUsage: 100, + balance: -200, // 100 - 300 = -200 (overage) + usage: 300, + }); + + // Upgrade to Growth + // Expected: $100 - $50 = $50 price diff + $10 overage = $60 + await autumnV1.attach({ + customer_id: customerId, + product_id: growth.id, + }); + + const customerAfterGrowth = + await autumnV1.customers.get(customerId); + + await expectCustomerProducts({ + customer: customerAfterGrowth, + active: [growth.id], + }); + + // Usage resets after upgrade + expectCustomerFeatureCorrect({ + customer: customerAfterGrowth, + featureId: TestFeature.Words, + includedUsage: 100, + balance: 100, + usage: 0, + }); + + await expectCustomerInvoiceCorrect({ + customer: customerAfterGrowth, + count: 3, + latestTotal: 60, // $50 price diff + $10 overage + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════════ +// TEST 2: Upgrade with interval change - Monthly → Annual +// (from upgrade2) +// +// Scenario: +// - Pro monthly ($20/month) with consumable Words (100 included, $0.05/overage) +// - Pro annual ($200/year) with consumable Words (100 included) +// - Premium annual ($500/year) with consumable Words (100 included) +// - Attach Pro monthly, track 150 words (50 overage = $2.50), advance 2 weeks +// - Upgrade to Pro annual +// - Track 200 words (100 overage = $5), upgrade to Premium annual +// +// Expected: +// - Customer has correct product and balance after each upgrade +// - Interval changes correctly from monthly to annual +// - Invoice totals include prorated price diff + overage charges +// ═══════════════════════════════════════════════════════════════════════════════ + +test.concurrent(`${chalk.yellowBright("legacy-upgrade-usage 2: monthly → annual interval change")}`, async () => { + const customerId = "legacy-upgrade-usage-2"; + + const proMonthlyWords = items.consumableWords({ includedUsage: 100 }); + const proAnnualWords = items.consumableWords({ includedUsage: 100 }); + const premiumAnnualWords = items.consumableWords({ includedUsage: 100 }); + + const proMonthly = products.pro({ + id: "pro-monthly", + items: [proMonthlyWords], + }); + const proAnnual = products.proAnnual({ + id: "pro-annual", + items: [proAnnualWords], + }); + const premiumAnnual = constructProduct({ + id: "premium-annual", + items: [premiumAnnualWords], + type: "premium", + isAnnual: true, + }); + + // Setup: Create customer and products, attach Pro monthly + const { autumnV1 } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success", testClock: true }), + s.products({ list: [proMonthly, proAnnual, premiumAnnual] }), + ], + actions: [s.attach({ productId: proMonthly.id })], + }); + + // Verify initial state after Pro monthly attach + const customerInitial = + await autumnV1.customers.get(customerId); + + await expectCustomerProducts({ + customer: customerInitial, + active: [proMonthly.id], + }); + + expectCustomerFeatureCorrect({ + customer: customerInitial, + featureId: TestFeature.Words, + includedUsage: 100, + balance: 100, + usage: 0, + }); + + await expectCustomerInvoiceCorrect({ + customer: customerInitial, + count: 1, + latestTotal: 20, // Pro monthly base price + }); + + // Track 150 words (50 overage at $0.05 = $2.50) + await autumnV1.track({ + customer_id: customerId, + feature_id: TestFeature.Words, + value: 150, + }); + + await new Promise((r) => setTimeout(r, 2000)); + + // Verify state before Pro Annual upgrade + const customerBeforeProAnnual = + await autumnV1.customers.get(customerId); + + expectCustomerFeatureCorrect({ + customer: customerBeforeProAnnual, + featureId: TestFeature.Words, + includedUsage: 100, + balance: -50, // 100 - 150 = -50 (overage) + usage: 150, + }); + + // Upgrade to Pro Annual + // Price diff: $200 - $20 = $180 (but prorated based on remaining cycle) + // Overage: 50 × $0.05 = $2.50 + await autumnV1.attach({ + customer_id: customerId, + product_id: proAnnual.id, + }); + + const customerAfterProAnnual = + await autumnV1.customers.get(customerId); + + await expectCustomerProducts({ + customer: customerAfterProAnnual, + active: [proAnnual.id], + }); + + // Usage resets after upgrade + expectCustomerFeatureCorrect({ + customer: customerAfterProAnnual, + featureId: TestFeature.Words, + includedUsage: 100, + balance: 100, + usage: 0, + }); + + // Verify invoice count increased (exact total depends on proration) + await expectCustomerInvoiceCorrect({ + customer: customerAfterProAnnual, + count: 2, + }); + + // Track 200 words (100 overage at $0.05 = $5) + await autumnV1.track({ + customer_id: customerId, + feature_id: TestFeature.Words, + value: 200, + }); + + await new Promise((r) => setTimeout(r, 2000)); + + // Verify state before Premium Annual upgrade + const customerBeforePremiumAnnual = + await autumnV1.customers.get(customerId); + + expectCustomerFeatureCorrect({ + customer: customerBeforePremiumAnnual, + featureId: TestFeature.Words, + includedUsage: 100, + balance: -100, // 100 - 200 = -100 (overage) + usage: 200, + }); + + // Upgrade to Premium Annual + // Price diff: $500 - $200 = $300 (but prorated) + // Overage: 100 × $0.05 = $5 + await autumnV1.attach({ + customer_id: customerId, + product_id: premiumAnnual.id, + }); + + const customerAfterPremiumAnnual = + await autumnV1.customers.get(customerId); + + await expectCustomerProducts({ + customer: customerAfterPremiumAnnual, + active: [premiumAnnual.id], + }); + + // Usage resets after upgrade + expectCustomerFeatureCorrect({ + customer: customerAfterPremiumAnnual, + featureId: TestFeature.Words, + includedUsage: 100, + balance: 100, + usage: 0, + }); + + // Verify invoice count increased + await expectCustomerInvoiceCorrect({ + customer: customerAfterPremiumAnnual, + count: 3, + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════════ +// TEST 3: Upgrade with arrear prorated seats (entities) +// (from upgrade3) +// +// Scenario: +// - Pro ($20/month) with allocated Users ($10/user prorated, 0 included) +// - Premium ($50/month) with allocated Users ($10/user prorated, 0 included) +// - Pro annual ($200/year) with allocated Users ($10/user prorated, 0 included) +// - Create 2 entities, attach Pro (2 users × $10 = $20 seat charge) +// - Advance 1 week, create 3rd entity, upgrade to Premium +// - Upgrade to Pro Annual +// +// Expected: +// - Customer has correct product and usage after each upgrade +// - Entity count (usage) is tracked correctly +// - Invoice totals reflect seat charges +// ═══════════════════════════════════════════════════════════════════════════════ + +test.concurrent(`${chalk.yellowBright("legacy-upgrade-usage 3: arrear prorated seats with entities")}`, async () => { + const customerId = "legacy-upgrade-usage-3"; + + // Allocated users: $10/user prorated, 0 included + const proUsers = items.allocatedUsers({ includedUsage: 0 }); + const premiumUsers = items.allocatedUsers({ includedUsage: 0 }); + const proAnnualUsers = items.allocatedUsers({ includedUsage: 0 }); + + const pro = products.pro({ id: "pro", items: [proUsers] }); + const premium = products.premium({ id: "premium", items: [premiumUsers] }); + const proAnnual = products.proAnnual({ + id: "pro-annual", + items: [proAnnualUsers], + }); + + // Setup: Create customer, products, and 2 entities, attach Pro + const { autumnV1 } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success", testClock: true }), + s.products({ list: [pro, premium, proAnnual] }), + s.entities({ count: 2, featureId: TestFeature.Users }), + ], + actions: [s.attach({ productId: pro.id })], + }); + + // Verify initial state - Pro with 2 users + const customerInitial = + await autumnV1.customers.get(customerId); + + await expectCustomerProducts({ + customer: customerInitial, + active: [pro.id], + }); + + expectCustomerFeatureCorrect({ + customer: customerInitial, + featureId: TestFeature.Users, + includedUsage: 0, + usage: 2, + balance: -2, // 0 included - 2 usage = -2 + }); + + // Invoice: Pro base ($20) + 2 users × $10 = $40 + await expectCustomerInvoiceCorrect({ + customer: customerInitial, + count: 1, + latestTotal: 40, + }); + + // Create 3rd entity + await autumnV1.entities.create(customerId, [ + { id: "ent-3", name: "Entity 3", feature_id: TestFeature.Users }, + ]); + + await new Promise((r) => setTimeout(r, 3000)); + + // Verify state before Premium upgrade - now 3 users + const customerBefore = + await autumnV1.customers.get(customerId); + + expectCustomerFeatureCorrect({ + customer: customerBefore, + featureId: TestFeature.Users, + includedUsage: 0, + usage: 3, + balance: -3, // 0 included - 3 usage = -3 + }); + + await expectCustomerInvoiceCorrect({ + customer: customerBefore, + count: 2, + latestTotal: 10, + }); + + // Upgrade to Premium + // Base price diff: $50 - $20 = $30 + // Seat charge diff: 3 users × ($10 premium - $10 pro) = $0 (same rate) + // Plus prorated charge for the new seat + await autumnV1.attach({ + customer_id: customerId, + product_id: premium.id, + }); + + const customerAfterPremium = + await autumnV1.customers.get(customerId); + + await expectCustomerProducts({ + customer: customerAfterPremium, + active: [premium.id], + }); + + expectCustomerFeatureCorrect({ + customer: customerAfterPremium, + featureId: TestFeature.Users, + includedUsage: 0, + usage: 3, + balance: -3, + }); + + // Verify invoice count increased + await expectCustomerInvoiceCorrect({ + customer: customerAfterPremium, + count: 3, + latestTotal: 30, + }); + + // Upgrade to Pro Annual + await autumnV1.attach({ + customer_id: customerId, + product_id: proAnnual.id, + }); + + const customerAfterProAnnual = + await autumnV1.customers.get(customerId); + + await expectCustomerProducts({ + customer: customerAfterProAnnual, + active: [proAnnual.id], + }); + + expectCustomerFeatureCorrect({ + customer: customerAfterProAnnual, + featureId: TestFeature.Users, + includedUsage: 0, + usage: 3, + balance: -3, + }); + + // Verify invoice count increased + await expectCustomerInvoiceCorrect({ + customer: customerAfterProAnnual, + count: 4, + latestTotal: 150, + }); +}); diff --git a/server/tests/integration/billing/legacy/attach/upgrade/legacy-upgrade.test.ts b/server/tests/integration/billing/legacy/attach/upgrade/legacy-upgrade.test.ts index 60d8a7f90..8fc5f393e 100644 --- a/server/tests/integration/billing/legacy/attach/upgrade/legacy-upgrade.test.ts +++ b/server/tests/integration/billing/legacy/attach/upgrade/legacy-upgrade.test.ts @@ -2,95 +2,310 @@ * Legacy Attach V1 Upgrade Tests * * Migrated from: - * - server/tests/attach/upgradeOld/upgradeOld1.test.ts (trial to paid upgrade) + * - server/tests/attach/upgradeOld/upgradeOld4.test.ts (upgrade with payment method validation) + * - server/tests/interval/upgrade/interval1.test.ts (upgrade monthly to annual mid-cycle) + * - server/tests/interval/upgrade/interval2.test.ts (upgrade monthly to annual after 1.5 cycles) * * Tests V1 attach behavior for product upgrades: - * - Trial to paid upgrade - * - Invoice totals after upgrade + * - Payment method validation during upgrade + * - Billing interval changes (monthly to annual) + * - next_cycle verification */ -/** biome-ignore-all lint/suspicious/noExplicitAny: test file */ - import { expect, test } from "bun:test"; -import { type ApiCustomerV3, ApiVersion } from "@autumn/shared"; -import { expectCustomerV0Correct } from "@tests/utils/expectUtils/expectCustomerV0Correct"; +import { type ApiCustomerV3, ProductItemInterval } from "@autumn/shared"; +import { expectCustomerFeatureCorrect } from "@tests/integration/billing/utils/expectCustomerFeatureCorrect"; +import { expectCustomerInvoiceCorrect } from "@tests/integration/billing/utils/expectCustomerInvoiceCorrect"; +import { expectCustomerProducts } from "@tests/integration/billing/utils/expectCustomerProductCorrect"; +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 { timeout } from "@tests/utils/genUtils"; import ctx from "@tests/utils/testInitUtils/createTestContext"; import { initScenario, s } from "@tests/utils/testInitUtils/initScenario"; import chalk from "chalk"; -import { AutumnInt } from "@/external/autumn/autumnCli"; +import { addYears } from "date-fns"; +import { attachPmToCus } from "@/external/stripe/stripeCusUtils"; +import { constructFeatureItem } from "@/utils/scriptUtils/constructItem"; +import { constructProduct } from "@/utils/scriptUtils/createTestProducts"; +import { getCusSub } from "@/utils/scriptUtils/testUtils/cusTestUtils"; +import { toMilliseconds } from "@/utils/timeUtils"; // ═══════════════════════════════════════════════════════════════════════════════ -// TEST 1: Upgrade from trial product to paid product +// TEST 1: Upgrade with payment method validation +// (from upgradeOld4) // // Scenario: -// - Pro product ($20/month) with 7-day trial, 10 messages -// - Premium product ($50/month), 100 messages -// - Customer with payment method -// - Attach pro (starts trial) -// - Advance 3 days -// - Upgrade to premium → trial ends, premium starts +// - Pro product ($20/month) with Dashboard (boolean), Messages (10 included), Admin (unlimited) +// - Premium product ($50/month) with Messages (100 included) +// - Attach Pro without payment method +// - Try to upgrade with force_checkout (should fail) +// - Attach successful payment method +// - Upgrade to Premium (should succeed) // // Expected: -// - Customer has premium product after upgrade -// - Invoice total is $50 (premium price) +// - force_checkout fails without valid payment method +// - Upgrade succeeds after attaching payment method +// - Customer has Premium active with correct features // ═══════════════════════════════════════════════════════════════════════════════ -test.concurrent(`${chalk.yellowBright("legacy-upgrade 1: trial to paid upgrade")}`, async () => { +test.concurrent(`${chalk.yellowBright("legacy-upgrade 1: upgrade with payment method validation")}`, async () => { const customerId = "legacy-upgrade-1"; - const proWithTrial = products.proWithTrial({ - id: "pro-trial", - items: [items.dashboard(), items.monthlyMessages({ includedUsage: 10 })], - trialDays: 7, - cardRequired: true, + // Pro: $20/month with Dashboard, Messages (10), Admin (unlimited) + const proProduct = constructProduct({ + id: "pro", + type: "pro", + items: [ + constructFeatureItem({ + featureId: TestFeature.Dashboard, + isBoolean: true, + }), + constructFeatureItem({ + featureId: TestFeature.Messages, + includedUsage: 10, + interval: ProductItemInterval.Month, + }), + constructFeatureItem({ + featureId: TestFeature.Admin, + unlimited: true, + }), + ], }); - const premium = products.premium({ + // Premium: $50/month with Messages (100) + const premiumProduct = constructProduct({ id: "premium", - items: [items.monthlyMessages({ includedUsage: 100 })], + type: "premium", + + items: [ + constructFeatureItem({ + featureId: TestFeature.Messages, + includedUsage: 100, + interval: ProductItemInterval.Month, + }), + ], }); - const autumn = new AutumnInt({ secretKey: ctx.orgSecretKey }); - const autumnV1 = new AutumnInt({ - secretKey: ctx.orgSecretKey, - version: ApiVersion.V0_1, - }); - - await initScenario({ + // Setup without payment method initially + const { autumnV1, customer } = await initScenario({ customerId, setup: [ - s.customer({ testClock: true, paymentMethod: "success" }), - s.products({ list: [proWithTrial, premium] }), + s.customer({ testClock: true, paymentMethod: "success" }), // No payment method + s.products({ list: [proProduct, premiumProduct] }), + ], + actions: [], + }); + + // Attach Pro (no payment = free/trial) + await autumnV1.attach({ + customer_id: customerId, + product_id: proProduct.id, + }); + + const customerAfterPro = + await autumnV1.customers.get(customerId); + + await expectCustomerProducts({ + customer: customerAfterPro, + active: [proProduct.id], + }); + + expectCustomerFeatureCorrect({ + customer: customerAfterPro, + featureId: TestFeature.Messages, + includedUsage: 10, + balance: 10, + usage: 0, + }); + + // Try force_checkout without payment method - should fail + await expectAutumnError({ + func: async () => { + await autumnV1.attach({ + customer_id: customerId, + product_id: premiumProduct.id, + force_checkout: true, + }); + }, + }); + + // Attach successful payment method + await attachPmToCus({ + db: ctx.db, + customer, + org: ctx.org, + env: ctx.env, + }); + + // Now upgrade to Premium should succeed + await autumnV1.attach({ + customer_id: customerId, + product_id: premiumProduct.id, + }); + + const customerAfterPremium = + await autumnV1.customers.get(customerId); + + await expectCustomerProducts({ + customer: customerAfterPremium, + active: [premiumProduct.id], + }); + + expectCustomerFeatureCorrect({ + customer: customerAfterPremium, + featureId: TestFeature.Messages, + includedUsage: 100, + balance: 100, + usage: 0, + }); + + // Should have invoice for upgrade + await expectCustomerInvoiceCorrect({ + customer: customerAfterPremium, + count: 2, // Just the upgrade invoice since Pro was trial/free + latestTotal: 30, // Premium $50 + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════════ +// TEST 2: Upgrade from Pro monthly to Pro Annual mid-cycle +// (from interval1) +// +// Scenario: +// - Pro monthly ($20/month) with Words feature +// - Pro Annual ($200/year) with Words feature +// - Attach Pro monthly +// - Advance clock 2 weeks (mid-cycle) +// - Upgrade to Pro Annual +// +// Expected: +// - next_cycle.starts_at ≈ 1 year from now +// - Stripe subscription period_end matches next_cycle +// ═══════════════════════════════════════════════════════════════════════════════ + +test.concurrent(`${chalk.yellowBright("legacy-upgrade 2: upgrade monthly to annual mid-cycle")}`, async () => { + const customerId = "legacy-upgrade-2"; + + const wordsItem = items.monthlyWords({ includedUsage: 100 }); + const pro = products.pro({ id: "pro", items: [wordsItem] }); + const proAnnual = products.proAnnual({ + id: "pro-annual", + items: [wordsItem], + }); + + const { autumnV1 } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success", testClock: true }), + s.products({ list: [pro, proAnnual] }), ], actions: [ - // Attach pro with trial - s.attach({ productId: proWithTrial.id }), - // Advance 3 days into trial - s.advanceTestClock({ days: 3 }), + s.attach({ productId: pro.id }), + s.advanceTestClock({ weeks: 2 }), ], }); - // Upgrade to premium - await autumn.attach({ + // Get checkout preview with next_cycle + const checkoutRes = await autumnV1.checkout({ customer_id: customerId, - product_id: premium.id, + product_id: proAnnual.id, }); - await timeout(2500); + expect(checkoutRes.next_cycle).toBeDefined(); + expect(checkoutRes.next_cycle?.starts_at).toBeCloseTo( + addYears(new Date(), 1).getTime(), + -Math.log10(toMilliseconds.days(1)), + ); - // Check product, entitlements and invoices - const res = await autumnV1.customers.get(customerId); - - await expectCustomerV0Correct({ - sent: premium, - cusRes: res, + // Upgrade to Pro Annual + await autumnV1.attach({ + customer_id: customerId, + product_id: proAnnual.id, }); - const invoices = res.invoices; + // Verify Stripe subscription period_end matches checkout preview + const sub = await getCusSub({ + db: ctx.db, + org: ctx.org, + customerId, + productId: proAnnual.id, + }); - expect(invoices?.[0].total).toBe(50); - expect(invoices?.[0].product_ids).toContain(premium.id); + const subItem = sub!.items.data[0]; + expect(subItem.current_period_end * 1000).toBeCloseTo( + checkoutRes.next_cycle?.starts_at ?? 0, + -Math.log10(toMilliseconds.days(1)), + ); +}); + +// ═══════════════════════════════════════════════════════════════════════════════ +// TEST 3: Upgrade from Pro monthly to Pro Annual after 1.5 cycles +// (from interval2) +// +// Scenario: +// - Pro monthly ($20/month) with Words feature +// - Pro Annual ($200/year) with Words feature +// - Attach Pro monthly +// - Advance clock 1 month + 2 weeks (1.5 cycles) +// - Upgrade to Pro Annual +// +// Expected: +// - next_cycle.starts_at ≈ 1 year from now +// - Stripe subscription period_end matches next_cycle +// ═══════════════════════════════════════════════════════════════════════════════ + +test.concurrent(`${chalk.yellowBright("legacy-upgrade 3: upgrade monthly to annual after 1.5 cycles")}`, async () => { + const customerId = "legacy-upgrade-3"; + + const wordsItem = items.monthlyWords({ includedUsage: 100 }); + const pro = products.pro({ id: "pro", items: [wordsItem] }); + const proAnnual = products.proAnnual({ + id: "pro-annual", + items: [wordsItem], + }); + + const { autumnV1 } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success", testClock: true }), + s.products({ list: [pro, proAnnual] }), + ], + actions: [ + s.attach({ productId: pro.id }), + s.advanceTestClock({ months: 1, weeks: 2 }), + ], + }); + + // Get checkout preview with next_cycle + const checkoutRes = await autumnV1.checkout({ + customer_id: customerId, + product_id: proAnnual.id, + }); + + expect(checkoutRes.next_cycle).toBeDefined(); + expect(checkoutRes.next_cycle?.starts_at).toBeCloseTo( + addYears(new Date(), 1).getTime(), + -Math.log10(toMilliseconds.days(1)), + ); + + // Upgrade to Pro Annual + await autumnV1.attach({ + customer_id: customerId, + product_id: proAnnual.id, + }); + + // Verify Stripe subscription period_end matches checkout preview + const sub = await getCusSub({ + db: ctx.db, + org: ctx.org, + customerId, + productId: proAnnual.id, + }); + + const subItem = sub!.items.data[0]; + expect(subItem.current_period_end * 1000).toBeCloseTo( + checkoutRes.next_cycle?.starts_at ?? 0, + -Math.log10(toMilliseconds.days(1)), + ); }); diff --git a/server/tests/integration/billing/legacy/attach/upgrade/upgrade-prepaid.test.ts b/server/tests/integration/billing/legacy/attach/upgrade/upgrade-prepaid.test.ts deleted file mode 100644 index 7de330b2b..000000000 --- a/server/tests/integration/billing/legacy/attach/upgrade/upgrade-prepaid.test.ts +++ /dev/null @@ -1,273 +0,0 @@ -/** - * Legacy Attach V1 Upgrade - Prepaid Compatibility Tests - * - * Tests that verify V1's attach() (s.attach) works correctly for upgrade scenarios - * involving prepaid features. - * - * KEY DIFFERENCE between V1 and V2: - * - V1 attach (s.attach): quantity = packs * billingUnits (EXCLUDING allowance/includedUsage) - * - V2 attach (s.billing.attach): quantity = total units INCLUDING allowance/includedUsage - * - * Test flow: - * 1. Use s.attach() for initial V1 attach with prepaid - * 2. Use autumnV1.attach() for V1 product upgrade - */ - -import { test } from "bun:test"; -import { type ApiCustomerV3, OnDecrease, OnIncrease } from "@autumn/shared"; -import { expectCustomerFeatureCorrect } from "@tests/integration/billing/utils/expectCustomerFeatureCorrect"; -import { expectCustomerInvoiceCorrect } from "@tests/integration/billing/utils/expectCustomerInvoiceCorrect"; -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 ctx from "@tests/utils/testInitUtils/createTestContext"; -import { initScenario, s } from "@tests/utils/testInitUtils/initScenario"; -import chalk from "chalk"; - -// ═══════════════════════════════════════════════════════════════════════════════ -// TEST A: UPGRADE (Pro → Premium) with explicit prepaid options -// V1 attach quantity does NOT include included usage -// ═══════════════════════════════════════════════════════════════════════════════ - -test.concurrent(`${chalk.yellowBright("v1 upgrade: pro → premium with explicit prepaid options (quantity excludes allowance)")}`, async () => { - const customerId = "v1-upgrade-prepaid-explicit"; - const billingUnits = 100; - - // Pro: $10/pack, $20 base, 100 includedUsage - const proIncludedUsage = 100; - const proPricePerPack = 10; - const proPrepaidItem = items.prepaidMessages({ - includedUsage: proIncludedUsage, - billingUnits, - price: proPricePerPack, - config: { - on_increase: OnIncrease.ProrateImmediately, - on_decrease: OnDecrease.ProrateImmediately, - }, - }); - const proPriceItem = items.monthlyPrice({ price: 20 }); - const pro = products.base({ - id: "pro", - items: [proPrepaidItem, proPriceItem], - }); - - // Premium: $15/pack, $50 base, 200 includedUsage - const premiumIncludedUsage = 200; - const premiumPricePerPack = 15; - const premiumPrepaidItem = items.prepaidMessages({ - includedUsage: premiumIncludedUsage, - billingUnits, - price: premiumPricePerPack, - config: { - on_increase: OnIncrease.ProrateImmediately, - on_decrease: OnDecrease.ProrateImmediately, - }, - }); - const premiumPriceItem = items.monthlyPrice({ price: 50 }); - const premium = products.base({ - id: "premium", - items: [premiumPrepaidItem, premiumPriceItem], - }); - - // V1 attach: initial quantity = 3 packs (300 units EXCLUDING allowance) - // So total balance = 100 (allowance) + 300 (prepaid) = 400 - const initialPacks = 3; - const initialQuantityV1 = initialPacks * billingUnits; // 300 - V1 quantity excludes allowance - const initialTotalBalance = proIncludedUsage + initialQuantityV1; // 400 - - const { autumnV1 } = await initScenario({ - customerId, - setup: [ - s.customer({ paymentMethod: "success" }), - s.products({ list: [pro, premium] }), - ], - actions: [ - // V1 attach to Pro with 3 packs (quantity excludes allowance) - s.attach({ - productId: pro.id, - options: [ - { feature_id: TestFeature.Messages, quantity: initialQuantityV1 }, - ], - }), - ], - }); - - // Verify initial state on Pro - const customerBefore = - await autumnV1.customers.get(customerId); - expectCustomerFeatureCorrect({ - customer: customerBefore, - featureId: TestFeature.Messages, - includedUsage: initialTotalBalance, // 100 + 300 = 400 - balance: initialTotalBalance, - usage: 0, - }); - - // Upgrade to Premium with 5 packs (500 units EXCLUDING allowance) - // V1 quantity still excludes allowance - const upgradePacks = 5; - const upgradeQuantityV1 = upgradePacks * billingUnits; // 500 - V1 quantity excludes allowance - const premiumTotalBalance = premiumIncludedUsage + upgradeQuantityV1; // 200 + 500 = 700 - - // V1 attach to Premium - await autumnV1.attach({ - customer_id: customerId, - product_id: premium.id, - options: [ - { - feature_id: TestFeature.Messages, - quantity: upgradeQuantityV1, // 500 (excluding allowance) - }, - ], - }); - - // Verify customer upgraded to Premium with correct total balance - const customerAfter = await autumnV1.customers.get(customerId); - expectCustomerFeatureCorrect({ - customer: customerAfter, - featureId: TestFeature.Messages, - includedUsage: premiumTotalBalance, // 200 + 500 = 700 - balance: premiumTotalBalance, - usage: 0, - }); - - await expectSubToBeCorrect({ - db: ctx.db, - customerId, - org: ctx.org, - env: ctx.env, - }); - - // Verify invoice: 2 invoices (initial attach + upgrade) - // Upgrade cost = (new base - old base) + (new packs * new price - old packs * old price) - // = (50 - 20) + (5 * 15 - 3 * 10) = 30 + (75 - 30) = 30 + 45 = 75 - await expectCustomerInvoiceCorrect({ - customerId, - count: 2, - latestTotal: - (premiumPriceItem.price ?? 0) - - (proPriceItem.price ?? 0) + - (upgradePacks * premiumPricePerPack - initialPacks * proPricePerPack), - }); -}); - -// ═══════════════════════════════════════════════════════════════════════════════ -// TEST B: UPGRADE (Pro → Premium) without options - prepaid quantity inherits -// ═══════════════════════════════════════════════════════════════════════════════ - -test.concurrent(`${chalk.yellowBright("v1 upgrade: pro → premium without options (prepaid quantity inherits from pro)")}`, async () => { - const customerId = "v1-upgrade-prepaid-inherit"; - const billingUnits = 100; - - // Pro: $10/pack, $20 base, 100 includedUsage - const proIncludedUsage = 100; - const proPricePerPack = 10; - const proPrepaidItem = items.prepaidMessages({ - includedUsage: proIncludedUsage, - billingUnits, - price: proPricePerPack, - config: { - on_increase: OnIncrease.ProrateImmediately, - on_decrease: OnDecrease.ProrateImmediately, - }, - }); - const proPriceItem = items.monthlyPrice({ price: 20 }); - const pro = products.base({ - id: "pro", - items: [proPrepaidItem, proPriceItem], - }); - - // Premium: $15/pack, $50 base, 200 includedUsage - const premiumIncludedUsage = 200; - const premiumPricePerPack = 15; - const premiumPrepaidItem = items.prepaidMessages({ - includedUsage: premiumIncludedUsage, - billingUnits, - price: premiumPricePerPack, - config: { - on_increase: OnIncrease.ProrateImmediately, - on_decrease: OnDecrease.ProrateImmediately, - }, - }); - const premiumPriceItem = items.monthlyPrice({ price: 50 }); - const premium = products.base({ - id: "premium", - items: [premiumPrepaidItem, premiumPriceItem], - }); - - // V1 attach: initial quantity = 4 packs (400 units EXCLUDING allowance) - // So total balance = 100 (allowance) + 400 (prepaid) = 500 - const initialPacks = 4; - const initialQuantityV1 = initialPacks * billingUnits; // 400 - V1 quantity excludes allowance - const initialTotalBalance = proIncludedUsage + initialQuantityV1; // 500 - - const { autumnV1 } = await initScenario({ - customerId, - setup: [ - s.customer({ paymentMethod: "success" }), - s.products({ list: [pro, premium] }), - ], - actions: [ - // V1 attach to Pro with 4 packs (quantity excludes allowance) - s.attach({ - productId: pro.id, - options: [ - { feature_id: TestFeature.Messages, quantity: initialQuantityV1 }, - ], - }), - ], - }); - - // Verify initial state on Pro - const customerBefore = - await autumnV1.customers.get(customerId); - expectCustomerFeatureCorrect({ - customer: customerBefore, - featureId: TestFeature.Messages, - includedUsage: initialTotalBalance, // 100 + 400 = 500 - balance: initialTotalBalance, - usage: 0, - }); - - // Upgrade to Premium WITHOUT passing options - // The prepaid quantity should inherit from Pro (4 packs) - await autumnV1.attach({ - customer_id: customerId, - product_id: premium.id, - // NOTE: No options passed - should inherit from Pro - }); - - // When no options passed, the system should inherit the prepaid packs from Pro - // Premium total balance = 200 (allowance) + 400 (inherited prepaid) = 600 - const premiumTotalBalance = premiumIncludedUsage + initialQuantityV1; - - // Verify customer upgraded to Premium with inherited prepaid quantity - const customerAfter = await autumnV1.customers.get(customerId); - expectCustomerFeatureCorrect({ - customer: customerAfter, - featureId: TestFeature.Messages, - includedUsage: premiumTotalBalance, // 200 + 400 = 600 - balance: premiumTotalBalance, - usage: 0, - }); - - await expectSubToBeCorrect({ - db: ctx.db, - customerId, - org: ctx.org, - env: ctx.env, - }); - - // Verify invoice: 2 invoices (initial attach + upgrade) - // Upgrade cost = (new base - old base) + (packs * (new price - old price)) - // = (50 - 20) + (4 * (15 - 10)) = 30 + 20 = 50 - await expectCustomerInvoiceCorrect({ - customerId, - count: 2, - latestTotal: - (premiumPriceItem.price ?? 0) - - (proPriceItem.price ?? 0) + - initialPacks * (premiumPricePerPack - proPricePerPack), - }); -}); diff --git a/server/tests/integration/billing/legacy/toDelete.md b/server/tests/integration/billing/legacy/toDelete.md deleted file mode 100644 index ef2453734..000000000 --- a/server/tests/integration/billing/legacy/toDelete.md +++ /dev/null @@ -1,47 +0,0 @@ -# Files to Delete (migrated to integration/billing/legacy/) - -## Merged Add Tests → legacy/attach/new/legacy-new-merged.test.ts -- `server/tests/merged/add/mergedAdd1.test.ts` -- `server/tests/merged/add/mergedAdd3.test.ts` - -## Merged Add-on Tests → legacy/attach/new/legacy-addon-merged.test.ts -- `server/tests/merged/addOn/mergedAddOn2.test.ts` -- `server/tests/merged/addOn/mergedAddOn6.test.ts` - -## Merged Prepaid Tests → legacy/attach/update-quantity/legacy-update-quantity-entities.test.ts -- `server/tests/merged/prepaid/mergedPrepaid1.test.ts` -- `server/tests/merged/prepaid/mergedPrepaid2.test.ts` -- `server/tests/merged/prepaid/mergedPrepaid3.test.ts` - -## Merged Downgrade Tests → legacy/attach/downgrade/legacy-downgrade-merged-schedule.test.ts + legacy-downgrade-merged-clock.test.ts -- `server/tests/merged/downgrade/mergedDowngrade1.test.ts` -- `server/tests/merged/downgrade/mergedDowngrade2.test.ts` -- `server/tests/merged/downgrade/mergedDowngrade3.test.ts` -- `server/tests/merged/downgrade/mergedDowngrade4.test.ts` -- `server/tests/merged/downgrade/mergedDowngrade5.test.ts` -- `server/tests/merged/downgrade/mergedDowngrade6.test.ts` -- `server/tests/merged/downgrade/mergedDowngrade8.test.ts` -- `server/tests/merged/downgrade/mergedDowngrade9.test.ts` - -## Invoice Action Required Tests → legacy/attach/invoice/ + billing/cron/ -- `server/tests/integration/billing/invoice-action-required/invoice-action-required1.test.ts` -- `server/tests/integration/billing/invoice-action-required/invoice-action-required2.test.ts` -- `server/tests/integration/billing/invoice-action-required/invoice-action-required3.test.ts` -- `server/tests/integration/billing/invoice-action-required/invoice-action-required4.test.ts` -- `server/tests/integration/billing/invoice-action-required/invoice-action-required5.test.ts` -- `server/tests/integration/billing/invoice-action-required/new-subscription/new-subscription-action-required1.test.ts` - -## Merged Upgrade Tests → legacy/attach/upgrade/legacy-upgrade-merged.test.ts -- `server/tests/merged/upgrade/mergedUpgrade1.test.ts` -- `server/tests/merged/upgrade/mergedUpgrade2.test.ts` -- `server/tests/merged/upgrade/mergedUpgrade3.test.ts` -- `server/tests/merged/upgrade/mergedUpgrade4.test.ts` - -## Merged Group Tests → legacy/attach/group/legacy-group-merged.test.ts -- `server/tests/merged/group/mergedGroup1.test.ts` -- `server/tests/merged/group/mergedGroup2.test.ts` - - -## Separate Subscription Tests → legacy/attach/separate/legacy-separate.test.ts -- `server/tests/merged/separate/separate1.test.ts` -- `server/tests/merged/separate/separate2.test.ts` diff --git a/server/tests/integration/billing/stripe-webhooks/subscription-updated/subscription-updated-past-due.test.ts b/server/tests/integration/billing/stripe-webhooks/subscription-updated/subscription-updated-past-due.test.ts new file mode 100644 index 000000000..99b43a70b --- /dev/null +++ b/server/tests/integration/billing/stripe-webhooks/subscription-updated/subscription-updated-past-due.test.ts @@ -0,0 +1,80 @@ +/** + * Subscription Updated Webhook - Past Due Tests + * + * Tests for handling the `customer.subscription.updated` Stripe webhook event + * when a subscription enters past_due status due to failed payment. + * + * These tests simulate payment failures by attaching a failing payment method, + * then advancing to the next billing cycle where the renewal invoice fails. + * + * The webhook handler should: + * - Update customer product status to past_due + * - Maintain the product features while in past_due state + */ + +import { test } from "bun:test"; +import type { ApiCustomerV3 } from "@autumn/shared"; +import { expectCustomerInvoiceCorrect } from "@tests/integration/billing/utils/expectCustomerInvoiceCorrect"; +import { expectProductPastDue } from "@tests/integration/billing/utils/expectCustomerProductCorrect"; +import { items } from "@tests/utils/fixtures/items.js"; +import { products } from "@tests/utils/fixtures/products.js"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js"; +import chalk from "chalk"; + +// ═══════════════════════════════════════════════════════════════════════════════ +// TEST 1: Subscription enters past_due after failed payment at renewal +// ═══════════════════════════════════════════════════════════════════════════════ + +/** + * Scenario: + * - Init pro product ($20/mo) with messages feature + * - Attach pro to customer with successful payment method + * - Switch to a failing payment method + * - Advance to next billing cycle (invoice will fail) + * + * Expected Result: + * - Pro product status becomes past_due + * - Renewal invoice is open (unpaid) + */ +test.concurrent(`${chalk.yellowBright("sub.updated: product enters past_due after failed renewal payment")}`, async () => { + const customerId = "sub-updated-past-due-basic"; + + const messagesItem = items.monthlyMessages({ includedUsage: 10 }); + const dashboardItem = items.dashboard(); + const adminItem = items.adminRights(); + + const pro = products.pro({ + id: "pro", + items: [dashboardItem, messagesItem, adminItem], + }); + + const { autumnV1 } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [pro] }), + ], + actions: [ + s.attach({ productId: pro.id }), + s.removePaymentMethod(), + s.attachPaymentMethod({ type: "fail" }), + s.advanceTestClock({ toNextInvoice: true }), + ], + }); + + const customer = await autumnV1.customers.get(customerId); + + await expectProductPastDue({ + customer, + productId: pro.id, + }); + + await expectCustomerInvoiceCorrect({ + customer, + count: 2, + invoiceIndex: 0, + latestTotal: 20, + latestStatus: "open", + latestInvoiceProductId: pro.id, + }); +}); diff --git a/server/tests/integration/billing/utils/expectCustomerProductCorrect.ts b/server/tests/integration/billing/utils/expectCustomerProductCorrect.ts index 87507f3f8..e13be063c 100644 --- a/server/tests/integration/billing/utils/expectCustomerProductCorrect.ts +++ b/server/tests/integration/billing/utils/expectCustomerProductCorrect.ts @@ -5,7 +5,12 @@ import { AutumnInt } from "@/external/autumn/autumnCli"; const defaultAutumn = new AutumnInt({ version: ApiVersion.V1_2 }); -type ProductState = "active" | "canceled" | "scheduled" | "undefined"; +type ProductState = + | "active" + | "canceled" + | "scheduled" + | "past_due" + | "undefined"; type CustomerOrEntity = ApiCustomerV3 | ApiEntityV0; /** @@ -67,6 +72,8 @@ export const expectCustomerProductCorrect = async ({ ).toBe(true); } else if (state === "scheduled") { expect(String(product.status)).toBe("scheduled"); + } else if (state === "past_due") { + expect(String(product.status)).toBe("past_due"); } }; @@ -138,6 +145,15 @@ export const expectProductScheduled = async ({ } }; +/** + * Shorthand for checking product is past_due (payment failed). + */ +export const expectProductPastDue = async (params: { + customerId?: string; + customer?: ApiCustomerV3 | ApiEntityV0; + productId: string; +}) => expectCustomerProductCorrect({ ...params, state: "past_due" }); + /** * Shorthand for checking product does not exist */ @@ -157,6 +173,7 @@ export const expectProductNotPresent = async (params: { * active: [pro.id, addon.id], * canceling: [premium.id], * scheduled: [free.id], + * pastDue: [overdue.id], * notPresent: [oldProduct.id], * }); */ @@ -166,6 +183,7 @@ export const expectCustomerProducts = async ({ active = [], canceling = [], scheduled = [], + pastDue = [], notPresent = [], }: { customerId?: string; @@ -173,6 +191,7 @@ export const expectCustomerProducts = async ({ active?: string[]; canceling?: string[]; scheduled?: string[]; + pastDue?: string[]; notPresent?: string[]; }) => { const customer = providedCustomer @@ -203,6 +222,14 @@ export const expectCustomerProducts = async ({ }); } + for (const productId of pastDue) { + await expectCustomerProductCorrect({ + customer: customer as ApiCustomerV3, + productId, + state: "past_due", + }); + } + for (const productId of notPresent) { await expectCustomerProductCorrect({ customer: customer as ApiCustomerV3, diff --git a/server/tests/integration/customers/cross-version-list-customers.test.ts b/server/tests/integration/crud/customers/cross-version-list-customers.test.ts similarity index 94% rename from server/tests/integration/customers/cross-version-list-customers.test.ts rename to server/tests/integration/crud/customers/cross-version-list-customers.test.ts index a697b17b8..8a3fe9c55 100644 --- a/server/tests/integration/customers/cross-version-list-customers.test.ts +++ b/server/tests/integration/crud/customers/cross-version-list-customers.test.ts @@ -25,10 +25,11 @@ test.concurrent(`${chalk.yellowBright("cross-version-list-customers: list custom const autumnV2_0 = new AutumnInt({ version: ApiVersion.V2_0 }); // V2.1 - should return ApiCustomerV5 schema (V1 balances with granted/remaining) - const customersV2_1 = (await autumnV2_1.customers.list()) as { + const customersV2_1 = (await autumnV2_1.customers.listV2()) as { list: ApiCustomerV5[]; }; for (const customer of customersV2_1.list) { + console.log(customer); ApiCustomerV5Schema.parse(customer); } diff --git a/server/tests/integration/crud/customers/list-customers.test.ts b/server/tests/integration/crud/customers/list-customers.test.ts deleted file mode 100644 index 0dc8b8172..000000000 --- a/server/tests/integration/crud/customers/list-customers.test.ts +++ /dev/null @@ -1,462 +0,0 @@ -// import { beforeAll, describe, expect, test } from "bun:test"; -// import { -// type ApiCustomer, -// ApiVersion, -// ProductItemInterval, -// } 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"; - -// const testCase = "list-customers"; - -// // Different products for testing multi-plan filtering -// const productA = constructProduct({ -// id: `${testCase}-product-a`, -// type: "free", -// isDefault: false, -// version: 1, -// items: [ -// constructFeatureItem({ -// featureId: TestFeature.Messages, -// includedUsage: 100, -// interval: ProductItemInterval.Month, -// }), -// ], -// }); - -// const productB = constructProduct({ -// id: `${testCase}-product-b`, -// type: "free", -// isDefault: false, -// version: 1, -// items: [ -// constructFeatureItem({ -// featureId: TestFeature.Messages, -// includedUsage: 200, -// interval: ProductItemInterval.Month, -// }), -// ], -// }); - -// const otherProduct = constructProduct({ -// id: `${testCase}-other-product`, -// type: "free", -// isDefault: false, -// items: [ -// constructFeatureItem({ -// featureId: TestFeature.Dashboard, -// isBoolean: true, -// }), -// ], -// }); - -// const customerIds = { -// withProductA: `${testCase}-cus-a`, -// withProductB: `${testCase}-cus-b`, -// withOtherProduct: `${testCase}-cus-other`, -// searchable: `${testCase}-searchable-john`, -// }; - -// describe(`${chalk.yellowBright("list-customers: Testing list customers endpoint")}`, () => { -// const autumn = new AutumnInt({ -// secretKey: ctx.orgSecretKey, -// version: ApiVersion.V1_2, -// }); - -// beforeAll(async () => { -// // Create products -// await initProductsV0({ -// ctx, -// products: [productA, productB, otherProduct], -// prefix: "", -// customerId: customerIds.withProductA, -// }); - -// // Create customers -// for (const customerId of Object.values(customerIds)) { -// await initCustomerV3({ -// ctx, -// customerId, -// withTestClock: false, -// withDefault: false, -// }); -// } - -// // Attach products to customers -// await autumn.attach({ -// customer_id: customerIds.withProductA, -// product_id: productA.id, -// }); - -// await autumn.attach({ -// customer_id: customerIds.withProductB, -// product_id: productB.id, -// }); - -// await autumn.attach({ -// customer_id: customerIds.withOtherProduct, -// product_id: otherProduct.id, -// }); - -// // Attach product to searchable customer so it's not filtered out by default status -// await autumn.attach({ -// customer_id: customerIds.searchable, -// product_id: productA.id, -// }); -// }); - -// // Pagination Tests -// describe("pagination", () => { -// test("should return customers with default pagination", async () => { -// const result = await autumn.customers.list(); - -// expect(result.list).toBeDefined(); -// expect(Array.isArray(result.list)).toBe(true); -// expect(result.limit).toBe(10); -// expect(result.offset).toBe(0); -// expect(typeof result.total).toBe("number"); -// }); - -// test("should respect custom limit", async () => { -// const result = await autumn.customers.list({ limit: 20 }); - -// expect(result.limit).toBe(20); -// }); - -// test("should respect offset", async () => { -// const result = await autumn.customers.list({ offset: 5 }); - -// expect(result.offset).toBe(5); -// }); - -// test("should respect max limit of 100", async () => { -// const result = await autumn.customers.list({ limit: 100 }); - -// expect(result.limit).toBe(100); -// }); -// }); - -// // Search Tests (V2) -// describe("search", () => { -// test("should search by customer ID", async () => { -// const result = await autumn.customers.listV2({ -// search: "searchable-john", -// }); - -// expect(result.list.length).toBeGreaterThanOrEqual(1); -// const found = result.list.find( -// (customer: ApiCustomer) => customer.id === customerIds.searchable, -// ); -// expect(found).toBeDefined(); -// }); - -// test("should search by customer email", async () => { -// const result = await autumn.customers.listV2({ -// search: "searchable-john@example", -// }); - -// expect(result.list.length).toBeGreaterThanOrEqual(1); -// const found = result.list.find( -// (customer: ApiCustomer) => customer.id === customerIds.searchable, -// ); -// expect(found).toBeDefined(); -// }); - -// test("should return empty list for non-matching search", async () => { -// const result = await autumn.customers.listV2({ -// search: "nonexistent-customer-xyz-123", -// }); - -// expect(result.list.length).toBe(0); -// }); - -// test("should be case-insensitive", async () => { -// const result = await autumn.customers.listV2({ -// search: "SEARCHABLE-JOHN", -// }); - -// expect(result.list.length).toBeGreaterThanOrEqual(1); -// }); -// }); - -// // Response Structure Tests -// describe("response structure", () => { -// test("should have correct response structure", async () => { -// const result = await autumn.customers.list(); - -// expect(result).toHaveProperty("list"); -// expect(result).toHaveProperty("total"); -// expect(result).toHaveProperty("limit"); -// expect(result).toHaveProperty("offset"); -// }); - -// test("each customer should have expected fields", async () => { -// const result = await autumn.customers.list({ limit: 10 }); - -// if (result.list.length > 0) { -// const customer = result.list[0]; -// expect(customer).toHaveProperty("id"); -// expect(customer).toHaveProperty("created_at"); -// expect(customer).toHaveProperty("products"); -// expect(customer).toHaveProperty("features"); -// } -// }); -// }); - -// // V2 Plans Filter Tests -// describe("plans filter (V2)", () => { -// test("should filter by single plan and exclude non-matching customers", async () => { -// const result = await autumn.customers.listV2({ -// plans: [{ id: productA.id }], -// }); - -// // Should find customers with productA (withProductA and searchable) -// const foundA = result.list.find( -// (customer: ApiCustomer) => customer.id === customerIds.withProductA, -// ); -// const foundSearchable = result.list.find( -// (customer: ApiCustomer) => customer.id === customerIds.searchable, -// ); -// expect(foundA).toBeDefined(); -// expect(foundSearchable).toBeDefined(); - -// // Should NOT find customers with other products -// const foundB = result.list.find( -// (customer: ApiCustomer) => customer.id === customerIds.withProductB, -// ); -// const foundOther = result.list.find( -// (customer: ApiCustomer) => customer.id === customerIds.withOtherProduct, -// ); -// expect(foundB).toBeUndefined(); -// expect(foundOther).toBeUndefined(); -// }); - -// test("should filter by single plan with specific version", async () => { -// const result = await autumn.customers.listV2({ -// plans: [{ id: productA.id, versions: [1] }], -// }); - -// const found = result.list.find( -// (customer: ApiCustomer) => customer.id === customerIds.withProductA, -// ); -// expect(found).toBeDefined(); - -// // Should NOT find customers with productB (different product) -// const foundB = result.list.find( -// (customer: ApiCustomer) => customer.id === customerIds.withProductB, -// ); -// expect(foundB).toBeUndefined(); -// }); - -// test("should return empty list for non-matching version", async () => { -// // All products are version 1, so filtering for version 999 should return empty -// const result = await autumn.customers.listV2({ -// plans: [{ id: productA.id, versions: [999] }], -// }); - -// expect(result.list.length).toBe(0); -// }); - -// test("should filter by multiple plans (OR logic)", async () => { -// const result = await autumn.customers.listV2({ -// plans: [{ id: productA.id }, { id: productB.id }], -// }); - -// // Should find customers with productA OR productB -// const foundA = result.list.find( -// (customer: ApiCustomer) => customer.id === customerIds.withProductA, -// ); -// const foundB = result.list.find( -// (customer: ApiCustomer) => customer.id === customerIds.withProductB, -// ); -// expect(foundA).toBeDefined(); -// expect(foundB).toBeDefined(); - -// // Should NOT find customer with otherProduct (not in filter) -// const foundOther = result.list.find( -// (customer: ApiCustomer) => customer.id === customerIds.withOtherProduct, -// ); -// expect(foundOther).toBeUndefined(); -// }); - -// test("should filter by multiple plans including other product", async () => { -// const result = await autumn.customers.listV2({ -// plans: [{ id: productA.id }, { id: otherProduct.id }], -// }); - -// const foundA = result.list.find( -// (customer: ApiCustomer) => customer.id === customerIds.withProductA, -// ); -// const foundOther = result.list.find( -// (customer: ApiCustomer) => customer.id === customerIds.withOtherProduct, -// ); -// expect(foundA).toBeDefined(); -// expect(foundOther).toBeDefined(); - -// // Should NOT find customer with productB -// const foundB = result.list.find( -// (customer: ApiCustomer) => customer.id === customerIds.withProductB, -// ); -// expect(foundB).toBeUndefined(); -// }); - -// test("should filter by plan with version constraint", async () => { -// const result = await autumn.customers.listV2({ -// plans: [{ id: productA.id, versions: [1] }, { id: otherProduct.id }], -// }); - -// const foundA = result.list.find( -// (customer: ApiCustomer) => customer.id === customerIds.withProductA, -// ); -// const foundOther = result.list.find( -// (customer: ApiCustomer) => customer.id === customerIds.withOtherProduct, -// ); -// expect(foundA).toBeDefined(); -// expect(foundOther).toBeDefined(); - -// // Should NOT find customer with productB -// const foundB = result.list.find( -// (customer: ApiCustomer) => customer.id === customerIds.withProductB, -// ); -// expect(foundB).toBeUndefined(); -// }); - -// test("should combine plans filter with search", async () => { -// const result = await autumn.customers.listV2({ -// plans: [{ id: productA.id }], -// search: "cus-a", -// }); - -// // Should find exactly the customer matching both criteria -// expect(result.list.length).toBe(1); -// expect(result.list[0].id).toBe(customerIds.withProductA); -// }); - -// test("should return empty list for non-existent plan", async () => { -// const result = await autumn.customers.listV2({ -// plans: [{ id: "nonexistent-plan-xyz" }], -// }); - -// expect(result.list.length).toBe(0); -// }); - -// test("should return empty list for non-existent version", async () => { -// const result = await autumn.customers.listV2({ -// plans: [{ id: productA.id, versions: [999] }], -// }); - -// expect(result.list.length).toBe(0); -// }); - -// test("should have correct response structure", async () => { -// const result = await autumn.customers.listV2({ -// plans: [{ id: productA.id }], -// }); - -// expect(result).toHaveProperty("list"); -// expect(result).toHaveProperty("total"); -// expect(result).toHaveProperty("limit"); -// expect(result).toHaveProperty("offset"); -// }); - -// test("should return plan_version in product response", async () => { -// const result = await autumn.customers.listV2({ -// plans: [{ id: productA.id }], -// }); - -// const found = result.list.find( -// (customer: ApiCustomer) => customer.id === customerIds.withProductA, -// ); -// expect(found).toBeDefined(); - -// // Check that version is returned correctly (V1.2 returns as 'products' with 'version' field) -// const products = (found as any).products; -// expect(products).toBeDefined(); -// expect(Array.isArray(products)).toBe(true); - -// const matchingProduct = products.find((p: any) => p.id === productA.id); -// expect(matchingProduct).toBeDefined(); -// expect(matchingProduct.version).toBe(1); -// }); -// }); - -// // V2 Subscription Status Filter Tests -// describe("subscription_status filter (V2)", () => { -// test("should filter by active status", async () => { -// const result = await autumn.customers.listV2({ -// subscription_status: ["active"], -// }); - -// // All our test customers have active products -// const foundA = result.list.find( -// (customer: ApiCustomer) => customer.id === customerIds.withProductA, -// ); -// const foundB = result.list.find( -// (customer: ApiCustomer) => customer.id === customerIds.withProductB, -// ); -// expect(foundA).toBeDefined(); -// expect(foundB).toBeDefined(); -// }); - -// test("should filter by multiple statuses", async () => { -// const result = await autumn.customers.listV2({ -// subscription_status: ["active", "scheduled"], -// }); - -// // Should include active customers -// const foundA = result.list.find( -// (customer: ApiCustomer) => customer.id === customerIds.withProductA, -// ); -// expect(foundA).toBeDefined(); -// }); - -// test("should combine subscription_status with plans filter (AND logic)", async () => { -// const result = await autumn.customers.listV2({ -// plans: [{ id: productA.id }], -// subscription_status: ["active"], -// }); - -// // Should find customers with productA AND active status -// const foundA = result.list.find( -// (customer: ApiCustomer) => customer.id === customerIds.withProductA, -// ); -// expect(foundA).toBeDefined(); - -// // Should NOT find customers with other products even if active -// const foundB = result.list.find( -// (customer: ApiCustomer) => customer.id === customerIds.withProductB, -// ); -// const foundOther = result.list.find( -// (customer: ApiCustomer) => customer.id === customerIds.withOtherProduct, -// ); -// expect(foundB).toBeUndefined(); -// expect(foundOther).toBeUndefined(); -// }); - -// test("should require BOTH plan AND status to match when combined", async () => { -// // Filter for productA with active status -// const result = await autumn.customers.listV2({ -// plans: [{ id: productA.id, versions: [1] }], -// subscription_status: ["active"], -// }); - -// // Only customers with productA v1 AND active status should be returned -// const foundA = result.list.find( -// (customer: ApiCustomer) => customer.id === customerIds.withProductA, -// ); -// expect(foundA).toBeDefined(); - -// // ProductB customer should NOT be returned (wrong product) -// const foundB = result.list.find( -// (customer: ApiCustomer) => customer.id === customerIds.withProductB, -// ); -// expect(foundB).toBeUndefined(); -// }); -// }); -// }); diff --git a/server/tests/integration/features/rpc/feature-rpc-crud.test.ts b/server/tests/integration/crud/features/feature-rpc-crud.test.ts similarity index 100% rename from server/tests/integration/features/rpc/feature-rpc-crud.test.ts rename to server/tests/integration/crud/features/feature-rpc-crud.test.ts diff --git a/server/tests/integration/external-psps/revenuecat.test.ts b/server/tests/integration/external-psps/revenuecat.test.ts new file mode 100644 index 000000000..45be34d17 --- /dev/null +++ b/server/tests/integration/external-psps/revenuecat.test.ts @@ -0,0 +1,393 @@ +/** + * RevenueCat Integration Tests + * + * Migrated from: + * - server/tests/integration/external-psps/revenuecat/revenuecat-webhooks.test.ts + * - server/tests/integration/external-psps/revenuecat/revenuecat-migration.test.ts + * + * Tests RevenueCat webhook handling and customer migration: + * - Initial purchase, renewal, upgrade/downgrade + * - Cancellation, uncancellation, billing issues, expiration + * - Non-renewing (one-off) purchases + * - Product version migration + */ + +import { expect, test } from "bun:test"; +import { + AppEnv, + CusProductStatus, + customers, + ProcessorType, +} from "@autumn/shared"; +import { expectFeaturesCorrect } from "@tests/utils/expectUtils/expectFeaturesCorrect"; +import { items } from "@tests/utils/fixtures/items"; +import { products } from "@tests/utils/fixtures/products"; +import { timeout } from "@tests/utils/genUtils"; +import ctx from "@tests/utils/testInitUtils/createTestContext"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario"; +import chalk from "chalk"; +import { eq } from "drizzle-orm"; +import { RCMappingService } from "@/external/revenueCat/misc/RCMappingService"; +import { CusProductService } from "@/internal/customers/cusProducts/CusProductService"; +import { OrgService } from "@/internal/orgs/OrgService"; +import { encryptData } from "@/utils/encryptUtils"; +import { + expectWebhookSuccess, + RevenueCatWebhookClient, +} from "./utils/revenue-cat-webhook-client"; +import { TestFeature } from "@tests/setup/v2Features"; + +const RC_WEBHOOK_SECRET = "test_rc_webhook_secret_12345"; + +const setupRevenueCatOrg = async () => { + if ( + ctx.org.processor_configs?.revenuecat?.sandbox_webhook_secret !== + RC_WEBHOOK_SECRET + ) { + await OrgService.update({ + db: ctx.db, + orgId: ctx.org.id, + updates: { + processor_configs: { + ...ctx.org.processor_configs, + revenuecat: { + api_key: encryptData("mock_rc_api_key_live"), + sandbox_api_key: encryptData("mock_rc_api_key_sandbox"), + project_id: "mock_project_live", + sandbox_project_id: "mock_project_sandbox", + webhook_secret: RC_WEBHOOK_SECRET, + sandbox_webhook_secret: RC_WEBHOOK_SECRET, + }, + }, + }, + }); + } +}; + +// ═══════════════════════════════════════════════════════════════════════════════ +// TEST 1: RevenueCat webhook lifecycle (purchase, upgrade, cancel, expire, add-on) +// (from revenuecat-webhooks.test.ts) +// +// Scenario: +// - Pro Monthly, Pro Yearly, and Add-on products +// - Initial purchase → upgrade → downgrade → cancel → uncancel → billing issue → expire → add-on +// ═══════════════════════════════════════════════════════════════════════════════ + +test.concurrent(`${chalk.yellowBright("revenuecat 1: webhook lifecycle")}`, async () => { + const customerId = "rc-webhook-1"; + + // RevenueCat product IDs + const RC_PRO_MONTHLY_ID = "com.app.rc1_pro_monthly"; + const RC_PRO_YEARLY_ID = "com.app.rc1_pro_yearly"; + const RC_ADD_ON_ID = "com.app.rc1_add_on_pack"; + + // Autumn products + const messagesItem = items.monthlyMessages({ includedUsage: 1000 }); + const proMonthly = products.pro({ id: "pro-monthly", items: [messagesItem] }); + const proYearly = products.proAnnual({ id: "pro-yearly", items: [items.monthlyMessages({ includedUsage: 1000 })] }); + const addOnPack = products.base({ + id: "add-on", + items: [items.lifetimeMessages({ includedUsage: 100 })], + isAddOn: true, + }); + + // Setup org with RevenueCat config + await setupRevenueCatOrg(); + + // Initialize scenario + const { autumnV1 } = await initScenario({ + customerId, + setup: [ + s.customer({ testClock: false }), + s.products({ list: [proMonthly, proYearly, addOnPack] }), + ], + actions: [], + }); + + // Create RC mappings + await Promise.all([ + RCMappingService.upsert({ + db: ctx.db, + data: { + org_id: ctx.org.id, + env: AppEnv.Sandbox, + autumn_product_id: proMonthly.id, + revenuecat_product_ids: [RC_PRO_MONTHLY_ID], + }, + }), + RCMappingService.upsert({ + db: ctx.db, + data: { + org_id: ctx.org.id, + env: AppEnv.Sandbox, + autumn_product_id: proYearly.id, + revenuecat_product_ids: [RC_PRO_YEARLY_ID], + }, + }), + RCMappingService.upsert({ + db: ctx.db, + data: { + org_id: ctx.org.id, + env: AppEnv.Sandbox, + autumn_product_id: addOnPack.id, + revenuecat_product_ids: [RC_ADD_ON_ID], + }, + }), + ]); + + const rcClient = new RevenueCatWebhookClient({ + orgId: ctx.org.id, + env: ctx.env, + webhookSecret: RC_WEBHOOK_SECRET, + }); + + // Get internal customer ID + const dbCustomer = await ctx.db.query.customers.findFirst({ + where: eq(customers.id, customerId), + }); + expect(dbCustomer).toBeDefined(); + const internalCustomerId = dbCustomer!.internal_id; + + // Helper to fetch latest active cus_product + const fetchLatestActiveCusProductId = async () => { + const cusProducts = await CusProductService.list({ + db: ctx.db, + internalCustomerId, + inStatuses: [CusProductStatus.Active, CusProductStatus.PastDue], + }); + const sorted = cusProducts.sort((a, b) => (a.created_at ?? 0) - (b.created_at ?? 0)); + expect(sorted.length > 0).toBe(true); + return sorted[sorted.length - 1]!.id; + }; + + // 1. Initial Purchase - Pro Monthly + let result = await rcClient.initialPurchase({ + productId: RC_PRO_MONTHLY_ID, + appUserId: customerId, + originalTransactionId: "rc1_tx_001", + }); + expectWebhookSuccess(result); + + let customer = await autumnV1.customers.get(customerId); + expect(customer.products).toHaveLength(1); + expect(customer.products[0].id).toBe(proMonthly.id); + let baselineCusProductId = await fetchLatestActiveCusProductId(); + + // 2. Upgrade to Pro Yearly (via renewal) + result = await rcClient.renewal({ + productId: RC_PRO_YEARLY_ID, + appUserId: customerId, + originalTransactionId: "rc1_tx_001", + }); + expectWebhookSuccess(result); + + customer = await autumnV1.customers.get(customerId); + expect(customer.products).toHaveLength(1); + expect(customer.products[0].id).toBe(proYearly.id); + + // 3. Downgrade to Pro Monthly (via initial purchase) + result = await rcClient.initialPurchase({ + productId: RC_PRO_MONTHLY_ID, + appUserId: customerId, + originalTransactionId: "rc1_tx_001", + }); + expectWebhookSuccess(result); + + customer = await autumnV1.customers.get(customerId); + expect(customer.products).toHaveLength(1); + expect(customer.products[0].id).toBe(proMonthly.id); + + const newCusProductId = await fetchLatestActiveCusProductId(); + expect(newCusProductId).not.toBe(baselineCusProductId); + baselineCusProductId = newCusProductId; + + // 4. Cancellation + result = await rcClient.cancellation({ + productId: RC_PRO_MONTHLY_ID, + appUserId: customerId, + originalTransactionId: "rc1_tx_001", + expirationAtMs: Date.now() + 1000 * 60 * 60 * 24 * 30, + }); + expectWebhookSuccess(result); + + customer = await autumnV1.customers.get(customerId); + expect(customer.products).toHaveLength(1); + expect(customer.products[0].id).toBe(proMonthly.id); + expect(customer.products[0].canceled_at).toBeDefined(); + expect(Math.abs(Date.now() - (customer.products[0].canceled_at ?? 0))).toBeLessThanOrEqual(3000); + + // 5. Uncancellation + result = await rcClient.uncancellation({ + productId: RC_PRO_MONTHLY_ID, + appUserId: customerId, + }); + expectWebhookSuccess(result); + + customer = await autumnV1.customers.get(customerId); + expect(customer.products).toHaveLength(1); + expect(customer.products[0].canceled_at).toBeNull(); + + // 6. Billing Issue + result = await rcClient.billingIssue({ + productId: RC_PRO_MONTHLY_ID, + appUserId: customerId, + originalTransactionId: "rc1_tx_001", + }); + expectWebhookSuccess(result); + + customer = await autumnV1.customers.get(customerId); + expect(customer.products).toHaveLength(1); + expect(String(customer.products[0].status)).toBe("past_due"); + + // 7. Expiration + result = await rcClient.expiration({ + productId: RC_PRO_MONTHLY_ID, + appUserId: customerId, + originalTransactionId: "rc1_tx_001", + }); + expectWebhookSuccess(result); + + customer = await autumnV1.customers.get(customerId); + expect(customer.products).toHaveLength(0); + + // 8. Non-renewing purchase (add-on after expiration) + result = await rcClient.nonRenewingPurchase({ + productId: RC_ADD_ON_ID, + appUserId: customerId, + originalTransactionId: "rc1_addon_tx_001", + }); + expectWebhookSuccess(result); + + customer = await autumnV1.customers.get(customerId); + expect(customer.products).toHaveLength(1); + expect(customer.products[0].id).toBe(addOnPack.id); +}); + +// ═══════════════════════════════════════════════════════════════════════════════ +// TEST 2: RevenueCat customer migration (v1 to v2 product version) +// (from revenuecat-migration.test.ts) +// +// Scenario: +// - Customer purchases Pro Monthly v1 via RevenueCat +// - Product is updated to v2 with increased usage +// - Customer is migrated from v1 to v2, preserving usage +// ═══════════════════════════════════════════════════════════════════════════════ + +test.concurrent(`${chalk.yellowBright("revenuecat 2: customer migration v1 to v2")}`, async () => { + const customerId = "rc-migration-1"; + const RC_PRO_MONTHLY_ID = "com.app.rc_migration_pro_monthly"; + + // Pro Monthly v1 + const messagesItem = items.monthlyMessages({ includedUsage: 1000 }); + const proMonthly = products.pro({ id: "pro-monthly", items: [messagesItem] }); + + // Setup org with RevenueCat config + await setupRevenueCatOrg(); + + // Initialize scenario + const { autumnV1 } = await initScenario({ + customerId, + setup: [ + s.customer({ testClock: false }), + s.products({ list: [proMonthly] }), + ], + actions: [], + }); + + // Create RC mapping + await RCMappingService.upsert({ + db: ctx.db, + data: { + org_id: ctx.org.id, + env: AppEnv.Sandbox, + autumn_product_id: proMonthly.id, + revenuecat_product_ids: [RC_PRO_MONTHLY_ID], + }, + }); + + const rcClient = new RevenueCatWebhookClient({ + orgId: ctx.org.id, + env: ctx.env, + webhookSecret: RC_WEBHOOK_SECRET, + }); + + // Get internal customer ID + const dbCustomer = await ctx.db.query.customers.findFirst({ + where: eq(customers.id, customerId), + }); + expect(dbCustomer).toBeDefined(); + const internalCustomerId = dbCustomer!.internal_id; + + // 1. Initial purchase via RevenueCat + const result = await rcClient.initialPurchase({ + productId: RC_PRO_MONTHLY_ID, + appUserId: customerId, + originalTransactionId: "migration_tx_001", + }); + expectWebhookSuccess(result); + + let customer = await autumnV1.customers.get(customerId); + expect(customer.products).toHaveLength(1); + expect(customer.products[0].id).toBe(proMonthly.id); + + // Verify cus_product has RevenueCat processor + const cusProducts = await CusProductService.list({ + db: ctx.db, + internalCustomerId, + inStatuses: [CusProductStatus.Active], + }); + expect(cusProducts).toHaveLength(1); + expect(cusProducts[0].processor?.type).toBe(ProcessorType.RevenueCat); + + // 2. Update product to v2 with increased usage + await autumnV1.products.update(proMonthly.id, { + items: [ + items.monthlyMessages({ includedUsage: 2000 }), + items.monthlyPrice({ price: 20 }), + ], + }); + + // 3. Track some usage before migration + await autumnV1.track({ + customer_id: customerId, + value: 500, + feature_id: TestFeature.Messages, + }); + await timeout(2000); + + // 4. Run migration + await autumnV1.migrate({ + from_product_id: proMonthly.id, + to_product_id: proMonthly.id, + from_version: 1, + to_version: 2, + }); + await timeout(5000); + + // 5. Verify migration succeeded + customer = await autumnV1.customers.get(customerId); + expect(customer.products).toHaveLength(1); + expect(customer.products[0].id).toBe(proMonthly.id); + expect(customer.products[0].version).toBe(2); + + // Verify features reflect v2 (2000 included) with 500 usage + const proMonthlyV2 = { + ...proMonthly, + version: 2, + items: [ + items.monthlyMessages({ includedUsage: 2000 }), + items.monthlyPrice({ price: 20 }), + ], + }; + + expectFeaturesCorrect({ + customer, + product: proMonthlyV2, + usage: [ + { + featureId: TestFeature.Messages, + value: 500, + }, + ], + }); +}); diff --git a/server/tests/external-psps/revenuecat/utils/revenue-cat-webhook-client.ts b/server/tests/integration/external-psps/utils/revenue-cat-webhook-client.ts similarity index 100% rename from server/tests/external-psps/revenuecat/utils/revenue-cat-webhook-client.ts rename to server/tests/integration/external-psps/utils/revenue-cat-webhook-client.ts diff --git a/server/tests/external-psps/revenuecat/revenuecatWebhooks.test.ts b/server/tests/integration/external-psps/utils/revenuecatWebhooks.test.ts similarity index 99% rename from server/tests/external-psps/revenuecat/revenuecatWebhooks.test.ts rename to server/tests/integration/external-psps/utils/revenuecatWebhooks.test.ts index 0f036f7eb..225c3a708 100644 --- a/server/tests/external-psps/revenuecat/revenuecatWebhooks.test.ts +++ b/server/tests/integration/external-psps/utils/revenuecatWebhooks.test.ts @@ -21,7 +21,7 @@ import { initProductsV0 } from "@/utils/scriptUtils/testUtils/initProductsV0.js" import { expectWebhookSuccess, RevenueCatWebhookClient, -} from "./utils/revenue-cat-webhook-client.js"; +} from "./revenue-cat-webhook-client.js"; const testCase = "rc1"; const RC_WEBHOOK_SECRET = "test_rc_webhook_secret_12345"; diff --git a/server/tests/interval/multiSub/multiSubInterval1.test.ts b/server/tests/interval/multiSub/multiSubInterval1.test.ts deleted file mode 100644 index f8f0c6fa9..000000000 --- a/server/tests/interval/multiSub/multiSubInterval1.test.ts +++ /dev/null @@ -1,119 +0,0 @@ -import { beforeAll, describe, expect, test } from "bun:test"; -import { LegacyVersion } from "@autumn/shared"; -import { TestFeature } from "@tests/setup/v2Features.js"; -import { attachAndExpectCorrect } from "@tests/utils/expectUtils/expectAttach.js"; -import { advanceTestClock } from "@tests/utils/stripeUtils.js"; -import ctx from "@tests/utils/testInitUtils/createTestContext.js"; -import chalk from "chalk"; -import { addMonths, addWeeks } from "date-fns"; -import { AutumnInt } from "@/external/autumn/autumnCli.js"; -import { constructFeatureItem } from "@/utils/scriptUtils/constructItem.js"; -import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js"; -import { getCusSub } from "@/utils/scriptUtils/testUtils/cusTestUtils.js"; -import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js"; -import { initProductsV0 } from "@/utils/scriptUtils/testUtils/initProductsV0.js"; -import { toMilliseconds } from "@/utils/timeUtils.js"; - -const pro = constructProduct({ - id: "pro", - items: [constructFeatureItem({ featureId: TestFeature.Words })], - type: "pro", -}); - -const proAnnual = constructProduct({ - id: "proAnnual", - items: [constructFeatureItem({ featureId: TestFeature.Words })], - type: "pro", - isAnnual: true, -}); - -const testCase = "multiSubInterval1"; -describe(`${chalk.yellowBright("multiSubInterval1: Should attach pro and pro annual to entity mid cycle and have correct next cycle at")}`, () => { - const customerId = testCase; - const autumn: AutumnInt = new AutumnInt({ version: LegacyVersion.v1_4 }); - - let testClockId: string; - - beforeAll(async () => { - await initProductsV0({ - ctx, - products: [pro, proAnnual], - prefix: testCase, - customerId, - }); - const { testClockId: testClockId1 } = await initCustomerV3({ - ctx, - customerId, - attachPm: "success", - withTestClock: true, - }); - - testClockId = testClockId1!; - }); - - const entities = [ - { - id: "1", - name: "entity1", - feature_id: TestFeature.Users, - }, - { - id: "2", - name: "entity2", - feature_id: TestFeature.Users, - }, - ]; - - test("should attach pro and advance test clock", async () => { - await autumn.entities.create(customerId, entities); - - await attachAndExpectCorrect({ - autumn, - customerId, - product: pro, - stripeCli: ctx.stripeCli, - db: ctx.db, - org: ctx.org, - env: ctx.env, - }); - - await advanceTestClock({ - stripeCli: ctx.stripeCli, - testClockId, - advanceTo: addWeeks(new Date(), 2).getTime(), - }); - }); - - test("should attach pro to entity 2 and have correct next cycle at", async () => { - const checkoutRes = await autumn.checkout({ - customer_id: customerId, - product_id: pro.id, - entity_id: entities[1].id, - }); - - expect(checkoutRes.next_cycle).toBeDefined(); - expect(checkoutRes.next_cycle?.starts_at).toBeCloseTo( - addMonths(new Date(), 1).getTime(), - -Math.log10(toMilliseconds.days(1)), // +- 1 day - ); - - await autumn.attach({ - customer_id: customerId, - product_id: pro.id, - entity_id: entities[1].id, - }); - - const sub = await getCusSub({ - db: ctx.db, - org: ctx.org, - customerId, - productId: pro.id, - }); - - const subItem = sub!.items.data[0]; - expect(subItem.current_period_end * 1000).toBeCloseTo( - checkoutRes.next_cycle?.starts_at ?? 0, - -Math.log10(toMilliseconds.days(1)), // +- 1 day - ); - }); -}); diff --git a/server/tests/interval/multiSub/multiSubInterval2.test.ts b/server/tests/interval/multiSub/multiSubInterval2.test.ts deleted file mode 100644 index e58b16f86..000000000 --- a/server/tests/interval/multiSub/multiSubInterval2.test.ts +++ /dev/null @@ -1,133 +0,0 @@ -import { beforeAll, describe, expect, test } from "bun:test"; -import { LegacyVersion } from "@autumn/shared"; -import { TestFeature } from "@tests/setup/v2Features.js"; -import { attachAndExpectCorrect } from "@tests/utils/expectUtils/expectAttach.js"; -import { advanceTestClock } from "@tests/utils/stripeUtils.js"; -import ctx from "@tests/utils/testInitUtils/createTestContext.js"; -import chalk from "chalk"; -import { addMonths, addYears, differenceInDays } from "date-fns"; -import { AutumnInt } from "@/external/autumn/autumnCli.js"; -import { constructFeatureItem } from "@/utils/scriptUtils/constructItem.js"; -import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js"; -import { getCusSub } from "@/utils/scriptUtils/testUtils/cusTestUtils.js"; -import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js"; -import { initProductsV0 } from "@/utils/scriptUtils/testUtils/initProductsV0.js"; -import { toMilliseconds } from "@/utils/timeUtils.js"; -import { getLatestPeriodEnd } from "../../../src/external/stripe/stripeSubUtils/convertSubUtils"; - -const pro = constructProduct({ - id: "pro", - items: [constructFeatureItem({ featureId: TestFeature.Words })], - type: "pro", -}); - -const proAnnual = constructProduct({ - id: "proAnnual", - items: [constructFeatureItem({ featureId: TestFeature.Words })], - type: "pro", - isAnnual: true, -}); - -const testCase = "multiSubInterval2"; -describe(`${chalk.yellowBright("multiSubInterval2: Should attach pro and pro annual to entity mid cycle and have correct next cycle at")}`, () => { - const customerId = testCase; - const autumn: AutumnInt = new AutumnInt({ version: LegacyVersion.v1_4 }); - - let testClockId: string; - let curUnix: number; - - beforeAll(async () => { - await initProductsV0({ - ctx, - products: [pro, proAnnual], - prefix: testCase, - customerId, - }); - - const { testClockId: testClockId1 } = await initCustomerV3({ - ctx, - customerId, - attachPm: "success", - }); - - testClockId = testClockId1!; - }); - - const entities = [ - { - id: "1", - name: "entity1", - feature_id: TestFeature.Users, - }, - { - id: "2", - name: "entity2", - feature_id: TestFeature.Users, - }, - ]; - - test("should attach pro and advance test clock", async () => { - await autumn.entities.create(customerId, entities); - - await attachAndExpectCorrect({ - autumn, - customerId, - product: pro, - stripeCli: ctx.stripeCli, - db: ctx.db, - org: ctx.org, - env: ctx.env, - }); - - curUnix = await advanceTestClock({ - stripeCli: ctx.stripeCli, - testClockId, - advanceTo: addMonths(new Date(), 1).getTime(), - }); - }); - - test("should attach pro annual to entity 2 and have correct next cycle at", async () => { - const checkoutRes = await autumn.checkout({ - customer_id: customerId, - product_id: proAnnual.id, - entity_id: entities[1].id, - }); - - expect(checkoutRes.next_cycle).toBeDefined(); - const expectedDate = addYears(Date.now(), 1).getTime(); - const actualDate = checkoutRes.next_cycle?.starts_at ?? 0; - - const daysDiff = Math.abs(differenceInDays(expectedDate, actualDate)); - - expect(daysDiff).toBeLessThanOrEqual(1); - - // console.log( - // `Next cycle starts at: ${formatUnixToDateTime(checkoutRes.next_cycle?.starts_at ?? 0)}`, - // ); - // console.log(`Expected date: ${formatUnixToDateTime(expectedDate)}`); - // console.log(`Days diff: ${daysDiff}`); - - await autumn.attach({ - customer_id: customerId, - product_id: proAnnual.id, - entity_id: entities[1].id, - }); - - const sub = await getCusSub({ - db: ctx.db, - org: ctx.org, - customerId, - productId: proAnnual.id, - }); - - // Get period end of annual item - const latestPeriodEnd = getLatestPeriodEnd({ - sub: sub, - }); - - expect(latestPeriodEnd * 1000).toBeCloseTo( - checkoutRes.next_cycle?.starts_at ?? 0, - -Math.log10(toMilliseconds.days(1)), // +- 1 day - ); - }); -}); diff --git a/server/tests/interval/multiSub/multiSubInterval3.test.ts b/server/tests/interval/multiSub/multiSubInterval3.test.ts deleted file mode 100644 index 0305256ce..000000000 --- a/server/tests/interval/multiSub/multiSubInterval3.test.ts +++ /dev/null @@ -1,132 +0,0 @@ -import { beforeAll, describe, expect, test } from "bun:test"; -import { LegacyVersion } from "@autumn/shared"; -import { TestFeature } from "@tests/setup/v2Features.js"; -import { attachAndExpectCorrect } from "@tests/utils/expectUtils/expectAttach.js"; -import { advanceTestClock } from "@tests/utils/stripeUtils.js"; -import ctx from "@tests/utils/testInitUtils/createTestContext.js"; -import chalk from "chalk"; -import { addMonths, addYears, differenceInDays } from "date-fns"; -import { AutumnInt } from "@/external/autumn/autumnCli.js"; -import { - constructArrearItem, - constructFeatureItem, -} from "@/utils/scriptUtils/constructItem.js"; -import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js"; -import { getCusSub } from "@/utils/scriptUtils/testUtils/cusTestUtils.js"; -import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js"; -import { initProductsV0 } from "@/utils/scriptUtils/testUtils/initProductsV0.js"; -import { toMilliseconds } from "@/utils/timeUtils.js"; - -const pro = constructProduct({ - id: "pro", - items: [constructFeatureItem({ featureId: TestFeature.Words })], - type: "pro", -}); - -const proAnnual = constructProduct({ - id: "proAnnual", - items: [ - constructArrearItem({ featureId: TestFeature.Credits }), - constructFeatureItem({ featureId: TestFeature.Words }), - ], - type: "pro", - isAnnual: true, -}); - -const testCase = "multiSubInterval3"; -describe(`${chalk.yellowBright("multiSubInterval3: Should attach pro and pro annual (with monthly usage price) to entity mid cycle and have correct next cycle at")}`, () => { - const customerId = testCase; - const autumn: AutumnInt = new AutumnInt({ version: LegacyVersion.v1_4 }); - - let testClockId: string; - - beforeAll(async () => { - await initProductsV0({ - ctx, - products: [pro, proAnnual], - prefix: testCase, - customerId, - }); - - const { testClockId: testClockId1 } = await initCustomerV3({ - ctx, - customerId, - attachPm: "success", - withTestClock: true, - }); - - testClockId = testClockId1!; - }); - - const entities = [ - { - id: "1", - name: "entity1", - feature_id: TestFeature.Users, - }, - { - id: "2", - name: "entity2", - feature_id: TestFeature.Users, - }, - ]; - - test("should attach pro and advance test clock", async () => { - await autumn.entities.create(customerId, entities); - - await attachAndExpectCorrect({ - autumn, - customerId, - product: pro, - stripeCli: ctx.stripeCli, - db: ctx.db, - org: ctx.org, - env: ctx.env, - }); - - await advanceTestClock({ - stripeCli: ctx.stripeCli, - testClockId, - advanceTo: addMonths(new Date(), 1.5).getTime(), - }); - }); - - test("should attach pro annual to entity 2 and have correct next cycle at", async () => { - const checkoutRes = await autumn.checkout({ - customer_id: customerId, - product_id: proAnnual.id, - entity_id: entities[1].id, - }); - - expect(checkoutRes.next_cycle).toBeDefined(); - expect(checkoutRes.next_cycle?.starts_at).toBeCloseTo( - addYears(new Date(), 1).getTime(), - -Math.log10(toMilliseconds.days(1)), // +- 1 day - ); - - await autumn.attach({ - customer_id: customerId, - product_id: proAnnual.id, - entity_id: entities[1].id, - }); - - const sub = await getCusSub({ - db: ctx.db, - org: ctx.org, - customerId, - productId: proAnnual.id, - }); - - const periodEndExists = sub!.items.data.some( - (item) => - Math.abs( - differenceInDays( - item.current_period_end * 1000, - checkoutRes.next_cycle?.starts_at!, - ), - ) < 1, - ); - - expect(periodEndExists).toBe(true); - }); -}); diff --git a/server/tests/interval/upgrade/interval1.test.ts b/server/tests/interval/upgrade/interval1.test.ts deleted file mode 100644 index b4db25612..000000000 --- a/server/tests/interval/upgrade/interval1.test.ts +++ /dev/null @@ -1,102 +0,0 @@ -import { beforeAll, describe, expect, test } from "bun:test"; -import { LegacyVersion } from "@autumn/shared"; -import { TestFeature } from "@tests/setup/v2Features.js"; -import { attachAndExpectCorrect } from "@tests/utils/expectUtils/expectAttach.js"; -import { advanceTestClock } from "@tests/utils/stripeUtils.js"; -import ctx from "@tests/utils/testInitUtils/createTestContext.js"; -import chalk from "chalk"; -import { addWeeks, addYears } from "date-fns"; -import { AutumnInt } from "@/external/autumn/autumnCli.js"; -import { constructFeatureItem } from "@/utils/scriptUtils/constructItem.js"; -import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js"; -import { getCusSub } from "@/utils/scriptUtils/testUtils/cusTestUtils.js"; -import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js"; -import { initProductsV0 } from "@/utils/scriptUtils/testUtils/initProductsV0.js"; -import { toMilliseconds } from "@/utils/timeUtils.js"; - -const pro = constructProduct({ - id: "pro", - items: [constructFeatureItem({ featureId: TestFeature.Words })], - type: "pro", -}); - -const proAnnual = constructProduct({ - id: "proAnnual", - items: [constructFeatureItem({ featureId: TestFeature.Words })], - type: "pro", - isAnnual: true, -}); - -const testCase = "interval1"; -describe(`${chalk.yellowBright("interval1: Should upgrade from pro to pro annual mid cycle and have correct next cycle at")}`, () => { - const customerId = testCase; - const autumn: AutumnInt = new AutumnInt({ version: LegacyVersion.v1_4 }); - - let testClockId: string; - - beforeAll(async () => { - await initProductsV0({ - ctx, - products: [pro, proAnnual], - prefix: testCase, - customerId, - }); - - const { testClockId: testClockId1 } = await initCustomerV3({ - ctx, - customerId, - attachPm: "success", - }); - - testClockId = testClockId1!; - }); - - test("should attach pro and advance test clock", async () => { - await attachAndExpectCorrect({ - autumn, - customerId, - product: pro, - stripeCli: ctx.stripeCli, - db: ctx.db, - org: ctx.org, - env: ctx.env, - }); - - await advanceTestClock({ - stripeCli: ctx.stripeCli, - testClockId, - advanceTo: addWeeks(new Date(), 2).getTime(), - }); - }); - - test("should upgrade to pro annual and have correct next cycle at", async () => { - const checkoutRes = await autumn.checkout({ - customer_id: customerId, - product_id: proAnnual.id, - }); - - expect(checkoutRes.next_cycle).toBeDefined(); - expect(checkoutRes.next_cycle?.starts_at).toBeCloseTo( - addYears(new Date(), 1).getTime(), - -Math.log10(toMilliseconds.days(1)), // +- 1 day - ); - - await autumn.attach({ - customer_id: customerId, - product_id: proAnnual.id, - }); - - const sub = await getCusSub({ - db: ctx.db, - org: ctx.org, - customerId, - productId: proAnnual.id, - }); - - const subItem = sub!.items.data[0]; - expect(subItem.current_period_end * 1000).toBeCloseTo( - checkoutRes.next_cycle?.starts_at ?? 0, - -Math.log10(toMilliseconds.days(1)), // +- 1 day - ); - }); -}); diff --git a/server/tests/interval/upgrade/interval2.test.ts b/server/tests/interval/upgrade/interval2.test.ts deleted file mode 100644 index 5a76160d1..000000000 --- a/server/tests/interval/upgrade/interval2.test.ts +++ /dev/null @@ -1,103 +0,0 @@ -import { beforeAll, describe, expect, test } from "bun:test"; -import { LegacyVersion } from "@autumn/shared"; -import { TestFeature } from "@tests/setup/v2Features.js"; -import { attachAndExpectCorrect } from "@tests/utils/expectUtils/expectAttach.js"; -import { advanceTestClock } from "@tests/utils/stripeUtils.js"; -import ctx from "@tests/utils/testInitUtils/createTestContext.js"; -import chalk from "chalk"; -import { addMonths, addWeeks, addYears } from "date-fns"; -import { AutumnInt } from "@/external/autumn/autumnCli.js"; -import { constructFeatureItem } from "@/utils/scriptUtils/constructItem.js"; -import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js"; -import { getCusSub } from "@/utils/scriptUtils/testUtils/cusTestUtils.js"; -import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js"; -import { initProductsV0 } from "@/utils/scriptUtils/testUtils/initProductsV0.js"; -import { toMilliseconds } from "@/utils/timeUtils.js"; - -const pro = constructProduct({ - id: "pro", - items: [constructFeatureItem({ featureId: TestFeature.Words })], - type: "pro", -}); - -const proAnnual = constructProduct({ - id: "proAnnual", - items: [constructFeatureItem({ featureId: TestFeature.Words })], - type: "pro", - isAnnual: true, -}); - -const testCase = "interval2"; -describe(`${chalk.yellowBright("interval2: Should upgrade from pro to pro annual after 1.5 cycles and have correct next cycle at")}`, () => { - const customerId = testCase; - const autumn: AutumnInt = new AutumnInt({ version: LegacyVersion.v1_4 }); - - let testClockId: string; - - beforeAll(async () => { - await initProductsV0({ - ctx, - products: [pro, proAnnual], - prefix: testCase, - customerId, - }); - - const { testClockId: testClockId1 } = await initCustomerV3({ - ctx, - customerId, - attachPm: "success", - withTestClock: true, - }); - - testClockId = testClockId1!; - }); - - test("should attach pro and advance test clock", async () => { - await attachAndExpectCorrect({ - autumn, - customerId, - product: pro, - stripeCli: ctx.stripeCli, - db: ctx.db, - org: ctx.org, - env: ctx.env, - }); - - await advanceTestClock({ - stripeCli: ctx.stripeCli, - testClockId, - advanceTo: addWeeks(addMonths(new Date(), 1), 2).getTime(), - }); - }); - - test("should upgrade to pro annual and have correct next cycle at", async () => { - const checkoutRes = await autumn.checkout({ - customer_id: customerId, - product_id: proAnnual.id, - }); - - expect(checkoutRes.next_cycle).toBeDefined(); - expect(checkoutRes.next_cycle?.starts_at).toBeCloseTo( - addYears(new Date(), 1).getTime(), - -Math.log10(toMilliseconds.days(1)), // +- 1 day - ); - - await autumn.attach({ - customer_id: customerId, - product_id: proAnnual.id, - }); - - const sub = await getCusSub({ - db: ctx.db, - org: ctx.org, - customerId, - productId: proAnnual.id, - }); - - const subItem = sub!.items.data[0]; - expect(subItem.current_period_end * 1000).toBeCloseTo( - checkoutRes.next_cycle?.starts_at ?? 0, - -Math.log10(toMilliseconds.days(1)), // +- 1 day - ); - }); -}); diff --git a/server/tests/interval/upgrade/interval3.test.ts b/server/tests/interval/upgrade/interval3.test.ts deleted file mode 100644 index fb58c0b2a..000000000 --- a/server/tests/interval/upgrade/interval3.test.ts +++ /dev/null @@ -1,105 +0,0 @@ -import { beforeAll, describe, expect, test } from "bun:test"; -import { LegacyVersion } from "@autumn/shared"; -import { TestFeature } from "@tests/setup/v2Features.js"; -import { attachAndExpectCorrect } from "@tests/utils/expectUtils/expectAttach.js"; -import { advanceTestClock } from "@tests/utils/stripeUtils.js"; -import ctx from "@tests/utils/testInitUtils/createTestContext.js"; -import chalk from "chalk"; -import { addDays } from "date-fns"; -import { AutumnInt } from "@/external/autumn/autumnCli.js"; -import { constructFeatureItem } from "@/utils/scriptUtils/constructItem.js"; -import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js"; -import { getCusSub } from "@/utils/scriptUtils/testUtils/cusTestUtils.js"; -import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js"; -import { initProductsV0 } from "@/utils/scriptUtils/testUtils/initProductsV0.js"; -import { toMilliseconds } from "@/utils/timeUtils.js"; - -const pro = constructProduct({ - id: "pro", - items: [constructFeatureItem({ featureId: TestFeature.Words })], - type: "pro", - trial: true, -}); - -const premium = constructProduct({ - id: "premium", - items: [constructFeatureItem({ featureId: TestFeature.Words })], - type: "premium", - trial: true, -}); - -const testCase = "interval3"; -describe(`${chalk.yellowBright("interval3: Should upgrade from pro trial to premium trial and have correct next cycle at")}`, () => { - const customerId = testCase; - const autumn: AutumnInt = new AutumnInt({ version: LegacyVersion.v1_4 }); - - let testClockId: string; - let curUnix: number; - - beforeAll(async () => { - await initProductsV0({ - ctx, - products: [pro, premium], - prefix: testCase, - customerId, - }); - - const { testClockId: testClockId1 } = await initCustomerV3({ - ctx, - customerId, - attachPm: "success", - withTestClock: true, - }); - - testClockId = testClockId1!; - }); - - test("should attach pro and advance test clock", async () => { - await attachAndExpectCorrect({ - autumn, - customerId, - product: pro, - stripeCli: ctx.stripeCli, - db: ctx.db, - org: ctx.org, - env: ctx.env, - }); - - curUnix = await advanceTestClock({ - stripeCli: ctx.stripeCli, - testClockId, - advanceTo: addDays(new Date(), 3).getTime(), - }); - }); - - test("should upgrade to premium and have correct next cycle at", async () => { - const checkoutRes = await autumn.checkout({ - customer_id: customerId, - product_id: premium.id, - }); - - expect(checkoutRes.next_cycle).toBeDefined(); - expect(checkoutRes.next_cycle?.starts_at).toBeCloseTo( - addDays(curUnix, 7).getTime(), - -Math.log10(toMilliseconds.days(1)), // +- 1 day - ); - - await autumn.attach({ - customer_id: customerId, - product_id: premium.id, - }); - - const sub = await getCusSub({ - db: ctx.db, - org: ctx.org, - customerId, - productId: premium.id, - }); - - const subItem = sub!.items.data[0]; - expect(subItem.current_period_end * 1000).toBeCloseTo( - checkoutRes.next_cycle?.starts_at ?? 0, - -Math.log10(toMilliseconds.days(1)), // +- 1 day - ); - }); -}); diff --git a/server/tests/merged/separate/separate1.test.ts b/server/tests/merged/separate/separate1.test.ts deleted file mode 100644 index c87d76c81..000000000 --- a/server/tests/merged/separate/separate1.test.ts +++ /dev/null @@ -1,161 +0,0 @@ -import { beforeAll, describe, expect, test } from "bun:test"; -import { type AppEnv, LegacyVersion, type Organization } from "@autumn/shared"; -import { TestFeature } from "@tests/setup/v2Features.js"; -import { completeInvoiceCheckoutV2 as completeInvoiceCheckout } from "@tests/utils/browserPool/completeInvoiceCheckoutV2"; -import ctx from "@tests/utils/testInitUtils/createTestContext.js"; -import chalk from "chalk"; -import type { DrizzleCli } from "@/db/initDrizzle.js"; -import { AutumnInt } from "@/external/autumn/autumnCli.js"; -import { CusService } from "@/internal/customers/CusService.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 "../mergeUtils/expectSubCorrect.js"; - -export const pro = constructProduct({ - items: [ - constructFeatureItem({ - featureId: TestFeature.Messages, - includedUsage: 100, - }), - ], - type: "pro", -}); - -export const premium = constructProduct({ - items: [ - constructFeatureItem({ - featureId: TestFeature.Messages, - includedUsage: 100, - }), - ], - type: "premium", -}); - -const entities = [ - { - id: "1", - name: "Entity 1", - feature_id: TestFeature.Users, - }, - { - id: "2", - name: "Entity 2", - feature_id: TestFeature.Users, - }, -]; - -const ops = [ - { - product: pro, - entityId: "1", - }, - { - product: pro, - entityId: "2", - }, -]; - -const testCase = "separate1"; -describe(`${chalk.yellowBright(`${testCase}: Testing separate subscriptions because of invoice checkout`)}`, () => { - const customerId = testCase; - const autumn: AutumnInt = new AutumnInt({ version: LegacyVersion.v1_2 }); - let db: DrizzleCli; - let org: Organization; - let env: AppEnv; - - beforeAll(async () => { - await initProductsV0({ - ctx, - products: [pro, premium], - prefix: testCase, - customerId, - }); - - await initCustomerV3({ - ctx, - customerId, - customerData: {}, - withTestClock: true, - }); - - db = ctx.db; - org = ctx.org; - env = ctx.env; - - await autumn.entities.create(customerId, entities); - }); - - const subIds: string[] = []; - test("should attach pro product", async () => { - for (const op of ops) { - const res = await autumn.attach({ - customer_id: customerId, - product_id: op.product.id, - invoice: true, - entity_id: op.entityId, - }); - - await completeInvoiceCheckout({ - url: res.checkout_url, - }); - } - - const fullCus = await CusService.getFull({ - idOrInternalId: customerId, - db, - orgId: org.id, - env, - }); - const cusProducts = fullCus.customer_products; - const entity1Prod = cusProducts.find((cp) => cp.entity_id === "1"); - const entity2Prod = cusProducts.find((cp) => cp.entity_id === "2"); - - const entity1SubId = entity1Prod?.subscription_ids?.[0]; - const entity2SubId = entity2Prod?.subscription_ids?.[0]; - - expect(entity1SubId).not.toBe(entity2SubId); - - subIds.push(entity1SubId!); - subIds.push(entity2SubId!); - - await expectSubToBeCorrect({ - db, - customerId, - org, - env, - subId: entity1SubId, - }); - }); - - test("should upgrade both entities to premium", async () => { - await autumn.attach({ - customer_id: customerId, - product_id: premium.id, - entity_id: "1", - }); - - await autumn.attach({ - customer_id: customerId, - product_id: premium.id, - entity_id: "2", - }); - - await expectSubToBeCorrect({ - db, - customerId, - org, - env, - subId: subIds[0], - }); - - await expectSubToBeCorrect({ - db, - customerId, - org, - env, - subId: subIds[1], - }); - }); -}); diff --git a/server/tests/merged/separate/separate2.test.ts b/server/tests/merged/separate/separate2.test.ts deleted file mode 100644 index 7b396ce0e..000000000 --- a/server/tests/merged/separate/separate2.test.ts +++ /dev/null @@ -1,217 +0,0 @@ -import { beforeAll, describe, expect, test } from "bun:test"; -import { type AppEnv, LegacyVersion, type Organization } from "@autumn/shared"; -import { TestFeature } from "@tests/setup/v2Features.js"; -import { completeStripeCheckoutFormV2 as completeStripeCheckoutForm } from "@tests/utils/browserPool/completeStripeCheckoutFormV2"; -import ctx from "@tests/utils/testInitUtils/createTestContext.js"; -import chalk from "chalk"; -import type Stripe from "stripe"; -import type { DrizzleCli } from "@/db/initDrizzle.js"; -import { AutumnInt } from "@/external/autumn/autumnCli.js"; -import { CusService } from "@/internal/customers/CusService.js"; -import { - constructFeatureItem, - constructPrepaidItem, -} from "@/utils/scriptUtils/constructItem.js"; -import { - constructProduct, - constructRawProduct, -} from "@/utils/scriptUtils/createTestProducts.js"; -import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js"; -import { initProductsV0 } from "@/utils/scriptUtils/testUtils/initProductsV0.js"; -import { expectSubToBeCorrect } from "../mergeUtils/expectSubCorrect.js"; - -export const pro = constructProduct({ - items: [ - constructFeatureItem({ - featureId: TestFeature.Messages, - includedUsage: 100, - }), - ], - type: "pro", -}); - -export const premium = constructProduct({ - items: [ - constructFeatureItem({ - featureId: TestFeature.Messages, - includedUsage: 100, - }), - ], - type: "premium", -}); - -const addOnBillingUnits = 100; -export const addOn = constructRawProduct({ - id: "creditsAddOn", - isAddOn: true, - items: [ - constructPrepaidItem({ - featureId: TestFeature.Credits, - billingUnits: addOnBillingUnits, - includedUsage: 0, - price: 10, - }), - ], -}); - -const entities = [ - { - id: "1", - name: "Entity 1", - feature_id: TestFeature.Users, - }, - { - id: "2", - name: "Entity 2", - feature_id: TestFeature.Users, - }, -]; - -const ops = [ - { - product: pro, - entityId: "1", - }, - { - product: pro, - entityId: "2", - }, -]; - -const testCase = "separate2"; -describe(`${chalk.yellowBright(`${testCase}: Testing separate subscriptions because of force checkout`)}`, () => { - const customerId = testCase; - const autumn: AutumnInt = new AutumnInt({ version: LegacyVersion.v1_2 }); - let stripeCli: Stripe; - let testClockId: string; - let db: DrizzleCli; - let org: Organization; - let env: AppEnv; - - beforeAll(async () => { - await initProductsV0({ - ctx, - products: [pro, premium, addOn], - prefix: testCase, - customerId, - }); - - const res = await initCustomerV3({ - ctx, - customerId, - customerData: {}, - withTestClock: true, - }); - - stripeCli = ctx.stripeCli; - db = ctx.db; - org = ctx.org; - env = ctx.env; - testClockId = res.testClockId!; - - await autumn.entities.create(customerId, entities); - }); - - const subIds: string[] = []; - test("should attach pro product", async () => { - for (const op of ops) { - const res = await autumn.attach({ - customer_id: customerId, - product_id: op.product.id, - force_checkout: true, - entity_id: op.entityId, - }); - - expect(res.checkout_url).toBeDefined(); - - await completeStripeCheckoutForm({ url: res.checkout_url }); - } - - const fullCus = await CusService.getFull({ - idOrInternalId: customerId, - db, - orgId: org.id, - env, - }); - const cusProducts = fullCus.customer_products; - const entity1Prod = cusProducts.find((cp) => cp.entity_id === "1"); - const entity2Prod = cusProducts.find((cp) => cp.entity_id === "2"); - - const entity1SubId = entity1Prod?.subscription_ids?.[0]; - const entity2SubId = entity2Prod?.subscription_ids?.[0]; - - expect(entity1SubId).not.toBe(entity2SubId); - - subIds.push(entity1SubId!); - subIds.push(entity2SubId!); - - await expectSubToBeCorrect({ - db, - customerId, - org, - env, - subId: entity1SubId, - }); - }); - - test("should upgrade both entities to premium", async () => { - for (const id of ["1", "2"]) { - await autumn.attach({ - customer_id: customerId, - product_id: premium.id, - entity_id: id, - }); - - await expectSubToBeCorrect({ - db, - customerId, - org, - env, - subId: subIds[0]!, - }); - - await expectSubToBeCorrect({ - db, - customerId, - org, - env, - subId: subIds[1], - }); - } - }); - - test("should attach add on to entity 2 and correct sub", async () => { - await autumn.attach({ - customer_id: customerId, - product_id: addOn.id, - entity_id: "2", - options: [ - { - feature_id: TestFeature.Credits, - quantity: addOnBillingUnits * 2, - }, - ], - }); - - const fullCus = await CusService.getFull({ - idOrInternalId: customerId, - db, - orgId: org.id, - env, - }); - const cusProducts = fullCus.customer_products; - const addOnProd = cusProducts.find((cp) => cp.product.id === addOn.id); - - expect(addOnProd).toBeDefined(); - const addOnSubId = addOnProd?.subscription_ids?.[0]; - expect(addOnSubId).toBe(subIds[1]); - - await expectSubToBeCorrect({ - db, - customerId, - org, - env, - subId: subIds[1], - }); - }); -}); diff --git a/server/tests/utils/fixtures/products.ts b/server/tests/utils/fixtures/products.ts index 753de9ac4..20273b827 100644 --- a/server/tests/utils/fixtures/products.ts +++ b/server/tests/utils/fixtures/products.ts @@ -108,17 +108,20 @@ const proAnnual = ({ * @param id - Product ID (default: "pro-trial") * @param trialDays - Number of trial days (default: 7) * @param cardRequired - Whether card is required for trial (default: true) + * @param uniqueFingerprint - Deduplicate trials by fingerprint (default: false) */ const proWithTrial = ({ items, id = "pro-trial", trialDays = 7, cardRequired = true, + uniqueFingerprint = false, }: { items: ProductItem[]; id?: string; trialDays?: number; cardRequired?: boolean; + uniqueFingerprint?: boolean; }): ProductV2 => constructProduct({ id, @@ -128,7 +131,7 @@ const proWithTrial = ({ freeTrial: { length: trialDays, duration: FreeTrialDuration.Day, - unique_fingerprint: false, + unique_fingerprint: uniqueFingerprint, card_required: cardRequired, }, }); diff --git a/server/tests/utils/testInitUtils/initScenario.ts b/server/tests/utils/testInitUtils/initScenario.ts index fd066e909..36cfbacbc 100644 --- a/server/tests/utils/testInitUtils/initScenario.ts +++ b/server/tests/utils/testInitUtils/initScenario.ts @@ -18,7 +18,7 @@ import { attachPaymentMethod as attachPaymentMethodFn } from "@/utils/scriptUtil import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js"; import { initProductsV0 } from "@/utils/scriptUtils/testUtils/initProductsV0.js"; import { hoursToFinalizeInvoice } from "../constants.js"; -import { createReferralProgram } from "../productUtils.js"; +import { createReferralProgram, createReward } from "../productUtils.js"; import { advanceTestClock as advanceTestClockFn } from "../stripeUtils.js"; import defaultCtx, { type TestContext } from "./createTestContext.js"; @@ -53,6 +53,11 @@ type ReferralProgramConfig = { program: CreateRewardProgram; }; +type RewardConfig = { + reward: CreateReward; + productId: string; +}; + // Discriminated union for all action types type AttachAction = { type: "attach"; @@ -180,6 +185,7 @@ type ScenarioConfig = { actions: ScenarioAction[]; otherCustomers: OtherCustomerConfig[]; referralProgram?: ReferralProgramConfig; + rewards: RewardConfig[]; }; type ConfigFn = (config: ScenarioConfig) => ScenarioConfig; @@ -329,6 +335,26 @@ const referralProgramSetup = ({ return (config) => ({ ...config, referralProgram: { reward, program } }); }; +/** + * Define a reward/coupon for this test scenario. + * Reward ID is auto-suffixed with the product prefix for test isolation. + * @param reward - The reward configuration (from constructCoupon) + * @param productId - The product ID to apply this reward to + * @example s.reward({ reward: constructCoupon({ id: "50-off", discountValue: 50, ... }), productId: "pro" }) + */ +const rewardSetup = ({ + reward, + productId, +}: { + reward: CreateReward; + productId: string; +}): ConfigFn => { + return (config) => ({ + ...config, + rewards: [...config.rewards, { reward, productId }], + }); +}; + /** * Attach a product to the customer or a specific entity. * Product ID is auto-prefixed with customerId. @@ -797,6 +823,7 @@ export const s = { products, entities, referralProgram: referralProgramSetup, + reward: rewardSetup, attach, cancel, advanceTestClock, @@ -834,6 +861,7 @@ const defaultConfig: ScenarioConfig = { actions: [], otherCustomers: [], referralProgram: undefined, + rewards: [], }; /** @@ -1033,6 +1061,26 @@ export async function initScenario({ }); } + // 1.6. Initialize standalone rewards (if configured) + for (const rewardConfig of config.rewards) { + const { reward, productId } = rewardConfig; + + // Suffix reward ID with productPrefix for isolation + reward.id = `${reward.id}_${productPrefix}`; + + // Suffix productId to match prefixed products + const prefixedProductId = `${productId}_${productPrefix}`; + + await createReward({ + db: ctx.db, + orgId: ctx.org.id, + env: ctx.env, + autumn: cleanupAutumn, + reward, + productId: prefixedProductId, + }); + } + // 2. Initialize customer (only if customerId is provided) let testClockId: string | undefined; let customer: Awaited>["customer"] | null = diff --git a/shared/api/products/crud/mappers/planParamsV1ToProductItems.ts b/shared/api/products/crud/mappers/planParamsV1ToProductItems.ts index 6a4d86a6a..0da18d9ec 100644 --- a/shared/api/products/crud/mappers/planParamsV1ToProductItems.ts +++ b/shared/api/products/crud/mappers/planParamsV1ToProductItems.ts @@ -27,12 +27,10 @@ export function planParamsV1ToProductItems({ }); if (params.price === undefined && params.items === undefined) { - console.log("price and items undefined!"); return undefined; } if (params.price !== undefined && params.items !== undefined) { - console.log("price and items provided!"); // 1. If price AND items provided, return full items array return planV1ToProductItems({ ctx, @@ -41,7 +39,6 @@ export function planParamsV1ToProductItems({ } if (params.price !== undefined && params.items === undefined) { - console.log("items undefined!"); // 2. If price provided, but no items, then customize base price and carry over feature items const featureItems = currentProductItems.filter( (item) => !isPriceItem(item), @@ -56,7 +53,6 @@ export function planParamsV1ToProductItems({ return basePriceItem ? [basePriceItem, ...featureItems] : featureItems; } - console.log("price and items undefined!"); // 3. If no price provided, then carry over base price const basePriceItem = currentProductItems.filter((item) => isPriceItem(item));