diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 4d2469274..a90f3796c 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -36,7 +36,7 @@ jobs: - name: Run TypeScript type check run: | - cd server && bun ts + bun ts build-and-push: name: Build and Push Docker Image diff --git a/apps/checkout/src/components/checkout/plan/PlanItemTierDetails.tsx b/apps/checkout/src/components/checkout/plan/PlanItemTierDetails.tsx index e92fe91df..20cec8f15 100644 --- a/apps/checkout/src/components/checkout/plan/PlanItemTierDetails.tsx +++ b/apps/checkout/src/components/checkout/plan/PlanItemTierDetails.tsx @@ -127,7 +127,13 @@ function getTierRangeText({ } const previousTier = tiers[index - 1]; - if (!previousTier || typeof previousTier.to !== "number") return ""; + if ( + !previousTier || + typeof previousTier.to !== "number" || + typeof tier.to !== "number" + ) { + return ""; + } return `for the next ${tier.to - previousTier.to}`; } @@ -232,8 +238,6 @@ export function PlanItemTierDetails({ return ( , ): { feature_id: string; quantity: number }[] { const featureQuantities: { feature_id: string; quantity: number }[] = []; @@ -123,18 +125,18 @@ export function useCheckoutState({ const isUpdateQuantityIntent = preview?.object === "update_subscription_preview" && preview.intent === "update_quantity"; - const matchingOutgoingChange = incoming?.[0] - ? outgoing?.find((change) => change.plan_id === incoming[0].plan_id) + const incomingChange = incoming?.[0]; + const matchingOutgoingChange = incomingChange + ? outgoing?.find((change) => change.plan_id === incomingChange.plan_id) : undefined; const isUnchangedQuantityUpdate = - isUpdateQuantityIntent && - Boolean(incoming?.[0]) && - Boolean(matchingOutgoingChange) && - haveMatchingQuantities({ - incoming: incoming[0], - outgoing: matchingOutgoingChange, - }); - const incomingPlan = incoming?.[0]?.plan; + isUpdateQuantityIntent && incomingChange && matchingOutgoingChange + ? haveMatchingQuantities({ + incoming: incomingChange, + outgoing: matchingOutgoingChange, + }) + : false; + const incomingPlan = incomingChange?.plan; const freeTrial = incomingPlan?.free_trial; const hasActiveTrial = !!freeTrial; diff --git a/apps/checkout/src/utils/buildHeaderDescription.ts b/apps/checkout/src/utils/buildHeaderDescription.ts index deddd3e89..25b09df52 100644 --- a/apps/checkout/src/utils/buildHeaderDescription.ts +++ b/apps/checkout/src/utils/buildHeaderDescription.ts @@ -1,15 +1,16 @@ import type { ApiFreeTrialV2, - AttachPreviewResponse, - BillingPreviewChange, BillingPreviewResponse, CheckoutEntity, - PreviewUpdateSubscriptionResponse, + GetCheckoutResponse, } from "@autumn/shared"; import { format } from "date-fns"; import { formatAmount } from "./formatUtils"; import { getCheckoutPreviewIntent } from "./getCheckoutPreviewIntent"; +type CheckoutPreview = GetCheckoutResponse["preview"]; +type CheckoutPreviewChange = CheckoutPreview["incoming"][number]; + /** * Builds a phrase describing applied discounts. * Examples: "Discount code 20OFF applied for 20% off.", "Discount codes 20OFF (20% off) and SAVE10 ($10 off) applied." @@ -130,9 +131,9 @@ export function buildHeaderDescription({ freeTrial, hasActiveTrial, }: { - preview?: AttachPreviewResponse | PreviewUpdateSubscriptionResponse; - incoming?: BillingPreviewChange[]; - outgoing?: BillingPreviewChange[]; + preview?: CheckoutPreview; + incoming?: CheckoutPreviewChange[]; + outgoing?: CheckoutPreviewChange[]; entity?: CheckoutEntity; freeTrial?: ApiFreeTrialV2 | null; hasActiveTrial?: boolean; diff --git a/apps/sdk-test/sdk.ts b/apps/sdk-test/sdk.ts index 61852f712..c142bfc45 100644 --- a/apps/sdk-test/sdk.ts +++ b/apps/sdk-test/sdk.ts @@ -8,7 +8,7 @@ const res = await autumn.customers.getOrCreate({ customerId: "john", }); -await autumn.entities.create({ +const entity = await autumn.entities.create({ customerId: "john", entityId: "name", featureId: "user", @@ -23,4 +23,4 @@ await autumn.entities.create({ }, }); -console.log(JSON.stringify(res, null, 2)); +// console.log(JSON.stringify(entity.billingControls, null, 2)); diff --git a/server/src/internal/checkouts/utils/augmentCheckoutParams.ts b/server/src/internal/checkouts/utils/augmentCheckoutParams.ts index b8b4b3205..f6793c0e8 100644 --- a/server/src/internal/checkouts/utils/augmentCheckoutParams.ts +++ b/server/src/internal/checkouts/utils/augmentCheckoutParams.ts @@ -51,6 +51,10 @@ export function augmentCheckoutParams({ | AttachParamsV1["feature_quantities"] | UpdateSubscriptionV1Params["feature_quantities"]; }) => { + if (!body.feature_quantities) { + return originalFeatureQuantities; + } + return body.feature_quantities.map((featureQuantity) => { const originalFeatureQuantity = originalFeatureQuantities?.find( (original) => original.feature_id === featureQuantity.feature_id, diff --git a/server/tests/_groups/temp.ts b/server/tests/_groups/temp.ts index 3460e8b56..cff186167 100644 --- a/server/tests/_groups/temp.ts +++ b/server/tests/_groups/temp.ts @@ -6,29 +6,12 @@ export const temp: TestGroup = { tier: "domain", paths: [ // Cancel immediately with default tests - - "integration/billing/stripe-webhooks/subscription-deleted/subscription-deleted-invoice.test.ts", - "integration/billing/attach/scheduled-switch/scheduled-switch-consumable.test.ts", - "integration/billing/legacy/attach/invoice/legacy-attach-invoice-mode.test.ts", - "integration/billing/legacy/attach/upgrade/legacy-upgrade-merged.test.ts", - "integration/billing/legacy/attach/upgrade/legacy-upgrade-usage.test.ts", - "integration/billing/migrations/migrate-trials.test.ts", - "integration/billing/update-subscription/custom-plan/update-paid-basic.test.ts", - "integration/billing/legacy/attach/new/legacy-new-merged.test.ts", - "integration/billing/legacy/attach/update-quantity/legacy-update-quantity.test.ts", - "integration/billing/migrations/migrate-free.test.ts", - "integration/billing/migrations/migrate-states.test.ts", - "integration/billing/update-subscription/cancel/uncancel/uncancel-combined.test.ts", - "integration/billing/update-subscription/custom-plan/update-paid-prepaid.test.ts", - "integration/billing/attach/invoice/attach-invoice-draft-deferred.test.ts", - "integration/billing/legacy/attach/invoice/payment-failure/legacy-attach-payment-failed.test.ts", - "integration/billing/setup-payment/setup-payment-with-customize.test.ts", - "integration/billing/update-subscription/custom-plan/update-free-to-paid.test.ts", - "integration/billing/update-subscription/free-trial/update-paid-trials.test.ts", - "integration/billing/legacy/attach/checkout/legacy-checkout-basic.test.ts", - "integration/billing/migrations/migrate-paid.test.ts", - "integration/billing/multi-attach/checkout/multi-attach-checkout-basic.test.ts", - "integration/billing/update-subscription/cancel/uncancel/uncancel-basic.test.ts", - "integration/billing/update-subscription/custom-plan/update-paid-features.test.ts", + "integration/billing/attach/checkout/autumn-checkout/autumn-checkout-basic.test.ts", + "integration/billing/attach/immediate-switch/paid-features/immediate-switch-prepaid-no-options-basic.test.ts", + "integration/billing/attach/immediate-switch/paid-features/immediate-switch-prepaid-no-options-advanced.test.ts", + "integration/billing/attach/free-trial/override/trial-override-basic.test.ts", + "integration/billing/attach/free-trial/override/trial-override-merge.test.ts", + "integration/billing/attach/free-trial/trial-upgrade.test.ts", + "integration/billing/attach/free-trial/trial-entity-upgrade.test.ts", ], }; diff --git a/server/tests/integration/billing/attach/free-trial/override/trial-override-basic.test.ts b/server/tests/integration/billing/attach/free-trial/override/trial-override-basic.test.ts index c90593c2f..597f42803 100644 --- a/server/tests/integration/billing/attach/free-trial/override/trial-override-basic.test.ts +++ b/server/tests/integration/billing/attach/free-trial/override/trial-override-basic.test.ts @@ -309,6 +309,7 @@ test.concurrent(`${chalk.yellowBright("trial-override-basic 3: override bypasses customer, productId: proTrial.id, trialEndsAt: advancedTo + ms.days(14), + toleranceMs: ms.hours(2), }); // Verify feature reset aligns with fresh trial (14 days from now) @@ -319,6 +320,7 @@ test.concurrent(`${chalk.yellowBright("trial-override-basic 3: override bypasses balance: 500, usage: 0, resetsAt: advancedTo + ms.days(14), // Fresh trial, reset at trial end + toleranceMs: ms.hours(2), }); // Verify $0 invoice during trial (Stripe creates invoice for trial subscriptions) diff --git a/server/tests/integration/billing/attach/free-trial/override/trial-override-merge.test.ts b/server/tests/integration/billing/attach/free-trial/override/trial-override-merge.test.ts index 77d9c846f..b0e64b5ff 100644 --- a/server/tests/integration/billing/attach/free-trial/override/trial-override-merge.test.ts +++ b/server/tests/integration/billing/attach/free-trial/override/trial-override-merge.test.ts @@ -330,12 +330,14 @@ test.concurrent(`${chalk.yellowBright("trial-override-merge 3: add-on with free_ customer, productId: proTrial.id, trialEndsAt: advancedTo + ms.days(14), + toleranceMs: ms.hours(1) + ms.minutes(10), }); await expectProductTrialing({ customer, productId: addon.id, trialEndsAt: advancedTo + ms.days(14), + toleranceMs: ms.hours(1) + ms.minutes(10), }); // Verify $0 invoice during trial (Stripe creates invoice for trial subscriptions) diff --git a/server/tests/integration/billing/attach/free-trial/trial-upgrade.test.ts b/server/tests/integration/billing/attach/free-trial/trial-upgrade.test.ts index bf9a810c8..c26e90f79 100644 --- a/server/tests/integration/billing/attach/free-trial/trial-upgrade.test.ts +++ b/server/tests/integration/billing/attach/free-trial/trial-upgrade.test.ts @@ -454,6 +454,7 @@ test.concurrent(`${chalk.yellowBright("trial-upgrade 4: mid-trial upgrade to pre customer, productId: premiumTrial.id, trialEndsAt: advancedTo + ms.days(14), + toleranceMs: ms.hours(1) + ms.minutes(10), }); // Verify feature balance is premium's balance with resetsAt aligned to new trial end @@ -464,6 +465,7 @@ test.concurrent(`${chalk.yellowBright("trial-upgrade 4: mid-trial upgrade to pre balance: 1000, usage: 0, resetsAt: advancedTo + ms.days(14), + toleranceMs: ms.hours(1) + ms.minutes(10), }); // Verify NO paid invoice generated - both are $0 trial invoices diff --git a/server/tests/integration/billing/attach/immediate-switch/paid-features/immediate-switch-prepaid-no-options-advanced.test.ts b/server/tests/integration/billing/attach/immediate-switch/paid-features/immediate-switch-prepaid-no-options-advanced.test.ts new file mode 100644 index 000000000..ae4e2b57b --- /dev/null +++ b/server/tests/integration/billing/attach/immediate-switch/paid-features/immediate-switch-prepaid-no-options-advanced.test.ts @@ -0,0 +1,479 @@ +/** + * Immediate Switch Prepaid No-Options Tests (Attach V2) + * + * Advanced prepaid no-options scenarios, including price changes, usage, + * multi-feature behavior, and explicit overrides. + */ + +import { expect, test } from "bun:test"; +import type { ApiCustomerV3 } from "@autumn/shared"; +import { expectCustomerFeatureCorrect } from "@tests/integration/billing/utils/expectCustomerFeatureCorrect"; +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.concurrent(`${chalk.yellowBright("immediate-switch-prepaid-no-options advanced 1: price increases")}`, async () => { + const customerId = "imm-switch-prepaid-no-opts-price-inc"; + + const proPrepaid = items.prepaidMessages({ + includedUsage: 0, + billingUnits: 100, + price: 10, + }); + const pro = products.pro({ + id: "pro", + items: [proPrepaid], + }); + + const premiumPrepaid = items.prepaidMessages({ + includedUsage: 0, + billingUnits: 100, + price: 15, + }); + const premium = products.premium({ + id: "premium", + items: [premiumPrepaid], + }); + + const { autumnV1 } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [pro, premium] }), + ], + actions: [ + s.billing.attach({ + productId: pro.id, + options: [{ feature_id: TestFeature.Messages, quantity: 200 }], + }), + ], + }); + + const preview = await autumnV1.billing.previewAttach({ + customer_id: customerId, + product_id: premium.id, + }); + expect(preview.total).toBe(40); + + await autumnV1.billing.attach({ + customer_id: customerId, + product_id: premium.id, + redirect_mode: "if_required", + }); + + const customer = await autumnV1.customers.get(customerId); + + await expectCustomerProducts({ + customer, + active: [premium.id], + notPresent: [pro.id], + }); + + expectCustomerFeatureCorrect({ + customer, + featureId: TestFeature.Messages, + balance: 200, + usage: 0, + }); +}); + +test.concurrent(`${chalk.yellowBright("immediate-switch-prepaid-no-options advanced 2: price decreases")}`, async () => { + const customerId = "imm-switch-prepaid-no-opts-price-dec"; + + const proPrepaid = items.prepaidMessages({ + includedUsage: 0, + billingUnits: 100, + price: 15, + }); + const pro = products.pro({ + id: "pro", + items: [proPrepaid], + }); + + const premiumPrepaid = items.prepaidMessages({ + includedUsage: 0, + billingUnits: 100, + price: 10, + }); + const premium = products.premium({ + id: "premium", + items: [premiumPrepaid], + }); + + const { autumnV1 } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [pro, premium] }), + ], + actions: [ + s.billing.attach({ + productId: pro.id, + options: [{ feature_id: TestFeature.Messages, quantity: 200 }], + }), + ], + }); + + const preview = await autumnV1.billing.previewAttach({ + customer_id: customerId, + product_id: premium.id, + }); + expect(preview.total).toBe(20); + + await autumnV1.billing.attach({ + customer_id: customerId, + product_id: premium.id, + redirect_mode: "if_required", + }); + + const customer = await autumnV1.customers.get(customerId); + + await expectCustomerProducts({ + customer, + active: [premium.id], + notPresent: [pro.id], + }); + + expectCustomerFeatureCorrect({ + customer, + featureId: TestFeature.Messages, + balance: 200, + usage: 0, + }); +}); + +test.concurrent(`${chalk.yellowBright("immediate-switch-prepaid-no-options advanced 3: with usage")}`, async () => { + const customerId = "imm-switch-prepaid-no-opts-with-usage"; + + const proPrepaid = items.prepaidMessages({ + includedUsage: 0, + billingUnits: 100, + price: 10, + }); + const pro = products.pro({ + id: "pro", + items: [proPrepaid], + }); + + const premiumPrepaid = items.prepaidMessages({ + includedUsage: 0, + billingUnits: 100, + price: 10, + }); + const premium = products.premium({ + id: "premium", + items: [premiumPrepaid], + }); + + const { autumnV1 } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [pro, premium] }), + ], + actions: [ + s.billing.attach({ + productId: pro.id, + options: [{ feature_id: TestFeature.Messages, quantity: 200 }], + }), + ], + }); + + await new Promise((r) => setTimeout(r, 4000)); + + await autumnV1.track({ + customer_id: customerId, + feature_id: TestFeature.Messages, + value: 50, + }); + + await new Promise((r) => setTimeout(r, 2000)); + + const customerBefore = + await autumnV1.customers.get(customerId); + expectCustomerFeatureCorrect({ + customer: customerBefore, + featureId: TestFeature.Messages, + balance: 150, + usage: 50, + }); + + const preview = await autumnV1.billing.previewAttach({ + customer_id: customerId, + product_id: premium.id, + }); + expect(preview.total).toBe(30); + + await autumnV1.billing.attach({ + customer_id: customerId, + product_id: premium.id, + redirect_mode: "if_required", + }); + + const customer = await autumnV1.customers.get(customerId); + + await expectCustomerProducts({ + customer, + active: [premium.id], + notPresent: [pro.id], + }); + + expectCustomerFeatureCorrect({ + customer, + featureId: TestFeature.Messages, + balance: 200, + usage: 0, + }); +}); + +test.concurrent(`${chalk.yellowBright("immediate-switch-prepaid-no-options advanced 4: multiple prepaid partial options")}`, async () => { + const customerId = "imm-switch-prepaid-partial-opts"; + + const proMessagesPrepaid = items.prepaidMessages({ + includedUsage: 0, + billingUnits: 100, + price: 10, + }); + const proWordsPrepaid = items.prepaid({ + featureId: TestFeature.Words, + includedUsage: 0, + billingUnits: 100, + price: 5, + }); + const pro = products.pro({ + id: "pro", + items: [proMessagesPrepaid, proWordsPrepaid], + }); + + const premiumMessagesPrepaid = items.prepaidMessages({ + includedUsage: 0, + billingUnits: 100, + price: 10, + }); + const premiumWordsPrepaid = items.prepaid({ + featureId: TestFeature.Words, + includedUsage: 0, + billingUnits: 100, + price: 5, + }); + const premium = products.premium({ + id: "premium", + items: [premiumMessagesPrepaid, premiumWordsPrepaid], + }); + + const { autumnV1 } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [pro, premium] }), + ], + actions: [ + s.billing.attach({ + productId: pro.id, + options: [ + { feature_id: TestFeature.Messages, quantity: 200 }, + { feature_id: TestFeature.Words, quantity: 500 }, + ], + }), + ], + }); + + const customerBefore = + await autumnV1.customers.get(customerId); + expectCustomerFeatureCorrect({ + customer: customerBefore, + featureId: TestFeature.Messages, + balance: 200, + }); + expectCustomerFeatureCorrect({ + customer: customerBefore, + featureId: TestFeature.Words, + balance: 500, + }); + + const preview = await autumnV1.billing.previewAttach({ + customer_id: customerId, + product_id: premium.id, + options: [{ feature_id: TestFeature.Messages, quantity: 300 }], + }); + expect(preview.total).toBe(40); + + await autumnV1.billing.attach({ + customer_id: customerId, + product_id: premium.id, + options: [{ feature_id: TestFeature.Messages, quantity: 300 }], + redirect_mode: "if_required", + }); + + const customer = await autumnV1.customers.get(customerId); + + await expectCustomerProducts({ + customer, + active: [premium.id], + notPresent: [pro.id], + }); + + expectCustomerFeatureCorrect({ + customer, + featureId: TestFeature.Messages, + balance: 300, + }); + + expectCustomerFeatureCorrect({ + customer, + featureId: TestFeature.Words, + balance: 500, + }); +}); + +test.concurrent(`${chalk.yellowBright("immediate-switch-prepaid-no-options advanced 5: all config changes")}`, async () => { + const customerId = "imm-switch-prepaid-no-opts-all-change"; + + const proPrepaid = items.prepaidMessages({ + includedUsage: 0, + billingUnits: 100, + price: 10, + }); + const pro = products.pro({ + id: "pro", + items: [proPrepaid], + }); + + const premiumPrepaid = items.prepaidMessages({ + includedUsage: 50, + billingUnits: 50, + price: 15, + }); + const premium = products.premium({ + id: "premium", + items: [premiumPrepaid], + }); + + const { autumnV1 } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [pro, premium] }), + ], + actions: [ + s.billing.attach({ + productId: pro.id, + options: [{ feature_id: TestFeature.Messages, quantity: 200 }], + }), + ], + }); + + const customerBefore = + await autumnV1.customers.get(customerId); + expectCustomerFeatureCorrect({ + customer: customerBefore, + featureId: TestFeature.Messages, + balance: 200, + usage: 0, + }); + + const preview = await autumnV1.billing.previewAttach({ + customer_id: customerId, + product_id: premium.id, + }); + expect(preview.total).toBe(55); + + await autumnV1.billing.attach({ + customer_id: customerId, + product_id: premium.id, + redirect_mode: "if_required", + }); + + const customer = await autumnV1.customers.get(customerId); + + await expectCustomerProducts({ + customer, + active: [premium.id], + notPresent: [pro.id], + }); + + expectCustomerFeatureCorrect({ + customer, + featureId: TestFeature.Messages, + balance: 200, + usage: 0, + }); +}); + +test.concurrent(`${chalk.yellowBright("immediate-switch-prepaid-no-options advanced 6: options explicitly set to 0")}`, async () => { + const customerId = "imm-switch-prepaid-opts-zero"; + + const proPrepaid = items.prepaidMessages({ + includedUsage: 0, + billingUnits: 100, + price: 10, + }); + const pro = products.pro({ + id: "pro", + items: [proPrepaid], + }); + + const premiumPrepaid = items.prepaidMessages({ + includedUsage: 0, + billingUnits: 100, + price: 10, + }); + const premium = products.premium({ + id: "premium", + items: [premiumPrepaid], + }); + + const { autumnV1 } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [pro, premium] }), + ], + actions: [ + s.billing.attach({ + productId: pro.id, + options: [{ feature_id: TestFeature.Messages, quantity: 200 }], + }), + ], + }); + + const customerBefore = + await autumnV1.customers.get(customerId); + expectCustomerFeatureCorrect({ + customer: customerBefore, + featureId: TestFeature.Messages, + balance: 200, + }); + + const preview = await autumnV1.billing.previewAttach({ + customer_id: customerId, + product_id: premium.id, + options: [{ feature_id: TestFeature.Messages, quantity: 0 }], + }); + expect(preview.total).toBe(10); + + await autumnV1.billing.attach({ + customer_id: customerId, + product_id: premium.id, + options: [{ feature_id: TestFeature.Messages, quantity: 0 }], + redirect_mode: "if_required", + }); + + const customer = await autumnV1.customers.get(customerId); + + await expectCustomerProducts({ + customer, + active: [premium.id], + notPresent: [pro.id], + }); + + expectCustomerFeatureCorrect({ + customer, + featureId: TestFeature.Messages, + balance: 0, + usage: 0, + }); +}); diff --git a/server/tests/integration/billing/attach/immediate-switch/paid-features/immediate-switch-prepaid-no-options-basic.test.ts b/server/tests/integration/billing/attach/immediate-switch/paid-features/immediate-switch-prepaid-no-options-basic.test.ts new file mode 100644 index 000000000..630326e98 --- /dev/null +++ b/server/tests/integration/billing/attach/immediate-switch/paid-features/immediate-switch-prepaid-no-options-basic.test.ts @@ -0,0 +1,384 @@ +/** + * Immediate Switch Prepaid No-Options Tests (Attach V2) + * + * Basic carry-over scenarios for upgrades involving prepaid features where + * options are not passed on upgrade. + */ + +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 } 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.concurrent(`${chalk.yellowBright("immediate-switch-prepaid-no-options basic 1: quantity carries over")}`, async () => { + const customerId = "imm-switch-prepaid-no-opts-carry"; + + const proPrepaid = items.prepaidMessages({ + includedUsage: 0, + billingUnits: 100, + price: 10, + }); + const pro = products.pro({ + id: "pro", + items: [proPrepaid], + }); + + const premiumPrepaid = items.prepaidMessages({ + includedUsage: 0, + billingUnits: 100, + price: 10, + }); + const premium = products.premium({ + id: "premium", + items: [premiumPrepaid], + }); + + const { autumnV1 } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [pro, premium] }), + ], + actions: [ + s.billing.attach({ + productId: pro.id, + options: [{ feature_id: TestFeature.Messages, quantity: 200 }], + }), + ], + }); + + const customerBefore = + await autumnV1.customers.get(customerId); + expectCustomerFeatureCorrect({ + customer: customerBefore, + featureId: TestFeature.Messages, + balance: 200, + usage: 0, + }); + + const preview = await autumnV1.billing.previewAttach({ + customer_id: customerId, + product_id: premium.id, + }); + expect(preview.total).toBe(30); + + await autumnV1.billing.attach({ + customer_id: customerId, + product_id: premium.id, + redirect_mode: "if_required", + }); + + const customer = await autumnV1.customers.get(customerId); + + await expectCustomerProducts({ + customer, + active: [premium.id], + notPresent: [pro.id], + }); + + expectCustomerFeatureCorrect({ + customer, + featureId: TestFeature.Messages, + balance: 200, + usage: 0, + }); + + await expectCustomerInvoiceCorrect({ + customer, + count: 2, + latestTotal: 30, + }); +}); + +test.concurrent(`${chalk.yellowBright("immediate-switch-prepaid-no-options basic 2: billing units 100 to 50")}`, async () => { + const customerId = "imm-switch-prepaid-no-opts-units-100-50"; + + const proPrepaid = items.prepaidMessages({ + includedUsage: 0, + billingUnits: 100, + price: 10, + }); + const pro = products.pro({ + id: "pro", + items: [proPrepaid], + }); + + const premiumPrepaid = items.prepaidMessages({ + includedUsage: 0, + billingUnits: 50, + price: 10, + }); + const premium = products.premium({ + id: "premium", + items: [premiumPrepaid], + }); + + const { autumnV1 } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [pro, premium] }), + ], + actions: [ + s.billing.attach({ + productId: pro.id, + options: [{ feature_id: TestFeature.Messages, quantity: 200 }], + }), + ], + }); + + const customerBefore = + await autumnV1.customers.get(customerId); + expectCustomerFeatureCorrect({ + customer: customerBefore, + featureId: TestFeature.Messages, + balance: 200, + usage: 0, + }); + + const preview = await autumnV1.billing.previewAttach({ + customer_id: customerId, + product_id: premium.id, + }); + expect(preview.total).toBe(50); + + await autumnV1.billing.attach({ + customer_id: customerId, + product_id: premium.id, + redirect_mode: "if_required", + }); + + const customer = await autumnV1.customers.get(customerId); + + await expectCustomerProducts({ + customer, + active: [premium.id], + notPresent: [pro.id], + }); + + expectCustomerFeatureCorrect({ + customer, + featureId: TestFeature.Messages, + balance: 200, + usage: 0, + }); +}); + +test.concurrent(`${chalk.yellowBright("immediate-switch-prepaid-no-options basic 3: billing units 50 to 100")}`, async () => { + const customerId = "imm-switch-prepaid-no-opts-units-50-100"; + + const proPrepaid = items.prepaidMessages({ + includedUsage: 0, + billingUnits: 50, + price: 10, + }); + const pro = products.pro({ + id: "pro", + items: [proPrepaid], + }); + + const premiumPrepaid = items.prepaidMessages({ + includedUsage: 0, + billingUnits: 100, + price: 10, + }); + const premium = products.premium({ + id: "premium", + items: [premiumPrepaid], + }); + + const { autumnV1 } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [pro, premium] }), + ], + actions: [ + s.billing.attach({ + productId: pro.id, + options: [{ feature_id: TestFeature.Messages, quantity: 200 }], + }), + ], + }); + + const preview = await autumnV1.billing.previewAttach({ + customer_id: customerId, + product_id: premium.id, + }); + expect(preview.total).toBe(10); + + await autumnV1.billing.attach({ + customer_id: customerId, + product_id: premium.id, + redirect_mode: "if_required", + }); + + const customer = await autumnV1.customers.get(customerId); + + await expectCustomerProducts({ + customer, + active: [premium.id], + notPresent: [pro.id], + }); + + expectCustomerFeatureCorrect({ + customer, + featureId: TestFeature.Messages, + balance: 200, + usage: 0, + }); +}); + +test.concurrent(`${chalk.yellowBright("immediate-switch-prepaid-no-options basic 4: included usage increases")}`, async () => { + const customerId = "imm-switch-prepaid-no-opts-incl-inc"; + + const proPrepaid = items.prepaidMessages({ + includedUsage: 0, + billingUnits: 100, + price: 10, + }); + const pro = products.pro({ + id: "pro", + items: [proPrepaid], + }); + + const premiumPrepaid = items.prepaidMessages({ + includedUsage: 100, + billingUnits: 100, + price: 10, + }); + const premium = products.premium({ + id: "premium", + items: [premiumPrepaid], + }); + + const { autumnV1 } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [pro, premium] }), + ], + actions: [ + s.billing.attach({ + productId: pro.id, + options: [{ feature_id: TestFeature.Messages, quantity: 200 }], + }), + ], + }); + + const customerBefore = + await autumnV1.customers.get(customerId); + expectCustomerFeatureCorrect({ + customer: customerBefore, + featureId: TestFeature.Messages, + balance: 200, + usage: 0, + }); + + const preview = await autumnV1.billing.previewAttach({ + customer_id: customerId, + product_id: premium.id, + }); + expect(preview.total).toBe(20); + + await autumnV1.billing.attach({ + customer_id: customerId, + product_id: premium.id, + redirect_mode: "if_required", + }); + + const customer = await autumnV1.customers.get(customerId); + + await expectCustomerProducts({ + customer, + active: [premium.id], + notPresent: [pro.id], + }); + + expectCustomerFeatureCorrect({ + customer, + featureId: TestFeature.Messages, + balance: 200, + usage: 0, + }); +}); + +test.concurrent(`${chalk.yellowBright("immediate-switch-prepaid-no-options basic 5: included usage decreases")}`, async () => { + const customerId = "imm-switch-prepaid-no-opts-incl-dec"; + + const proPrepaid = items.prepaidMessages({ + includedUsage: 100, + billingUnits: 100, + price: 10, + }); + const pro = products.pro({ + id: "pro", + items: [proPrepaid], + }); + + const premiumPrepaid = items.prepaidMessages({ + includedUsage: 0, + billingUnits: 100, + price: 10, + }); + const premium = products.premium({ + id: "premium", + items: [premiumPrepaid], + }); + + const { autumnV1 } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [pro, premium] }), + ], + actions: [ + s.billing.attach({ + productId: pro.id, + options: [{ feature_id: TestFeature.Messages, quantity: 200 }], + }), + ], + }); + + const customerBefore = + await autumnV1.customers.get(customerId); + expectCustomerFeatureCorrect({ + customer: customerBefore, + featureId: TestFeature.Messages, + balance: 200, + usage: 0, + }); + + const preview = await autumnV1.billing.previewAttach({ + customer_id: customerId, + product_id: premium.id, + }); + expect(preview.total).toBe(40); + + await autumnV1.billing.attach({ + customer_id: customerId, + product_id: premium.id, + redirect_mode: "if_required", + }); + + const customer = await autumnV1.customers.get(customerId); + + await expectCustomerProducts({ + customer, + active: [premium.id], + notPresent: [pro.id], + }); + + expectCustomerFeatureCorrect({ + customer, + featureId: TestFeature.Messages, + balance: 200, + usage: 0, + }); +}); diff --git a/server/tests/integration/billing/attach/immediate-switch/paid-features/immediate-switch-prepaid-no-options.test.ts b/server/tests/integration/billing/attach/immediate-switch/paid-features/immediate-switch-prepaid-no-options.test.ts deleted file mode 100644 index 7af4db3d9..000000000 --- a/server/tests/integration/billing/attach/immediate-switch/paid-features/immediate-switch-prepaid-no-options.test.ts +++ /dev/null @@ -1,1082 +0,0 @@ -/** - * Immediate Switch Prepaid No-Options Tests (Attach V2) - * - * Tests for upgrades involving prepaid features where options are NOT passed on upgrade. - * - * IMPORTANT: Immediate switch always involves a DIFFERENT product. - * You cannot update quantity on the same product via attach. - * - * Key behaviors: - * - When no options passed on upgrade, quantity carries over from previous product - * - When partial options passed, only specified features change - * - Balance should be recalculated based on new product config + carried-over quantity - */ - -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 } 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: Pro with prepaid (200 units), upgrade to Premium with NO options -// ═══════════════════════════════════════════════════════════════════════════════ - -/** - * Scenario: - * - Pro with prepaid (200 units purchased) - * - Upgrade to Premium with prepaid, NO options passed - * - * Expected Result: - * - Quantity carries over (200 units) - * - Balance = 200 (same as before) - * - Only base price difference charged (prepaid quantity unchanged) - */ -test.concurrent(`${chalk.yellowBright("immediate-switch-prepaid-no-options 1: quantity carries over")}`, async () => { - const customerId = "imm-switch-prepaid-no-opts-carry"; - - const proPrepaid = items.prepaidMessages({ - includedUsage: 0, - billingUnits: 100, - price: 10, - }); - const pro = products.pro({ - id: "pro", - items: [proPrepaid], - }); - - const premiumPrepaid = items.prepaidMessages({ - includedUsage: 0, - billingUnits: 100, - price: 10, - }); - const premium = products.premium({ - id: "premium", - items: [premiumPrepaid], - }); - - const { autumnV1 } = await initScenario({ - customerId, - setup: [ - s.customer({ paymentMethod: "success" }), - s.products({ list: [pro, premium] }), - ], - actions: [ - s.billing.attach({ - productId: pro.id, - options: [{ feature_id: TestFeature.Messages, quantity: 200 }], - }), - ], - }); - - // Verify initial state: 200 units - const customerBefore = - await autumnV1.customers.get(customerId); - expectCustomerFeatureCorrect({ - customer: customerBefore, - featureId: TestFeature.Messages, - balance: 200, - usage: 0, - }); - - // 1. Preview upgrade - NO options passed - const preview = await autumnV1.billing.previewAttach({ - customer_id: customerId, - product_id: premium.id, - // No options - quantity should carry over - }); - // Base diff: $50 - $20 = $30 (prepaid quantity same, no prepaid diff) - expect(preview.total).toBe(30); - - // 2. Attach premium - NO options - await autumnV1.billing.attach({ - customer_id: customerId, - product_id: premium.id, - redirect_mode: "if_required", - // No options - }); - - const customer = await autumnV1.customers.get(customerId); - - await expectCustomerProducts({ - customer, - active: [premium.id], - notPresent: [pro.id], - }); - - // Verify balance carried over: 200 - expectCustomerFeatureCorrect({ - customer, - featureId: TestFeature.Messages, - balance: 200, - usage: 0, - }); - - // Invoices: initial ($20 base + $20 prepaid = $40) + upgrade ($30 base only) - await expectCustomerInvoiceCorrect({ - customer, - count: 2, - latestTotal: 30, - }); -}); - -// ═══════════════════════════════════════════════════════════════════════════════ -// TEST 2: Prepaid upgrade no options, billing units change (100 → 50) -// ═══════════════════════════════════════════════════════════════════════════════ - -/** - * Scenario: - * - Pro with prepaid (200 units @ 100 units/pack = 2 packs) - * - Upgrade to Premium with prepaid (50 units/pack), NO options - * - * Expected Result: - * - Quantity carries over: 200 units - * - New product has 4 packs (200 / 50) - * - Prepaid cost increases (4 packs vs 2 packs) - */ -test.concurrent(`${chalk.yellowBright("immediate-switch-prepaid-no-options 2: billing units 100 to 50")}`, async () => { - const customerId = "imm-switch-prepaid-no-opts-units-100-50"; - - const proPrepaid = items.prepaidMessages({ - includedUsage: 0, - billingUnits: 100, - price: 10, - }); - const pro = products.pro({ - id: "pro", - items: [proPrepaid], - }); - - const premiumPrepaid = items.prepaidMessages({ - includedUsage: 0, - billingUnits: 50, - price: 10, - }); - const premium = products.premium({ - id: "premium", - items: [premiumPrepaid], - }); - - const { autumnV1 } = await initScenario({ - customerId, - setup: [ - s.customer({ paymentMethod: "success" }), - s.products({ list: [pro, premium] }), - ], - actions: [ - s.billing.attach({ - productId: pro.id, - options: [{ feature_id: TestFeature.Messages, quantity: 200 }], - }), - ], - }); - - // Verify initial: 200 units - const customerBefore = - await autumnV1.customers.get(customerId); - expectCustomerFeatureCorrect({ - customer: customerBefore, - featureId: TestFeature.Messages, - balance: 200, - usage: 0, - }); - - // 1. Preview upgrade - NO options - const preview = await autumnV1.billing.previewAttach({ - customer_id: customerId, - product_id: premium.id, - }); - // Base diff: $50 - $20 = $30 - // Prepaid diff: (4 packs - 2 packs) * $10 = $20 - // Total: $50 - expect(preview.total).toBe(50); - - // 2. Attach premium - NO options - await autumnV1.billing.attach({ - customer_id: customerId, - product_id: premium.id, - redirect_mode: "if_required", - }); - - const customer = await autumnV1.customers.get(customerId); - - await expectCustomerProducts({ - customer, - active: [premium.id], - notPresent: [pro.id], - }); - - // Verify balance carried over: 200 units (same quantity) - expectCustomerFeatureCorrect({ - customer, - featureId: TestFeature.Messages, - balance: 200, - usage: 0, - }); -}); - -// ═══════════════════════════════════════════════════════════════════════════════ -// TEST 3: Prepaid upgrade no options, billing units change (50 → 100) -// ═══════════════════════════════════════════════════════════════════════════════ - -/** - * Scenario: - * - Pro with prepaid (200 units @ 50 units/pack = 4 packs) - * - Upgrade to Premium with prepaid (100 units/pack), NO options - * - * Expected Result: - * - Quantity carries over: 200 units - * - New product has 2 packs (200 / 100) - * - Prepaid cost decreases (2 packs vs 4 packs) - */ -test.concurrent(`${chalk.yellowBright("immediate-switch-prepaid-no-options 3: billing units 50 to 100")}`, async () => { - const customerId = "imm-switch-prepaid-no-opts-units-50-100"; - - const proPrepaid = items.prepaidMessages({ - includedUsage: 0, - billingUnits: 50, - price: 10, - }); - const pro = products.pro({ - id: "pro", - items: [proPrepaid], - }); - - const premiumPrepaid = items.prepaidMessages({ - includedUsage: 0, - billingUnits: 100, - price: 10, - }); - const premium = products.premium({ - id: "premium", - items: [premiumPrepaid], - }); - - const { autumnV1 } = await initScenario({ - customerId, - setup: [ - s.customer({ paymentMethod: "success" }), - s.products({ list: [pro, premium] }), - ], - actions: [ - s.billing.attach({ - productId: pro.id, - options: [{ feature_id: TestFeature.Messages, quantity: 200 }], - }), - ], - }); - - // 1. Preview upgrade - NO options - const preview = await autumnV1.billing.previewAttach({ - customer_id: customerId, - product_id: premium.id, - }); - // Base diff: $50 - $20 = $30 - // Prepaid diff: (2 packs - 4 packs) * $10 = -$20 - // Total: $10 - expect(preview.total).toBe(10); - - // 2. Attach premium - NO options - await autumnV1.billing.attach({ - customer_id: customerId, - product_id: premium.id, - redirect_mode: "if_required", - }); - - const customer = await autumnV1.customers.get(customerId); - - await expectCustomerProducts({ - customer, - active: [premium.id], - notPresent: [pro.id], - }); - - // Verify balance carried over: 200 units - expectCustomerFeatureCorrect({ - customer, - featureId: TestFeature.Messages, - balance: 200, - usage: 0, - }); -}); - -// ═══════════════════════════════════════════════════════════════════════════════ -// TEST 4: Prepaid upgrade no options, included usage increases -// ═══════════════════════════════════════════════════════════════════════════════ - -/** - * Scenario: - * - Pro with prepaid (0 included, 200 purchased = 200 total) - * - Upgrade to Premium with prepaid (100 included), NO options - * - * Expected Result: - * - Quantity carries over: 200 purchased - * - Balance = 100 (included) + 200 (purchased) = 300 - */ -test.concurrent(`${chalk.yellowBright("immediate-switch-prepaid-no-options 4: included usage increases")}`, async () => { - const customerId = "imm-switch-prepaid-no-opts-incl-inc"; - - const proPrepaid = items.prepaidMessages({ - includedUsage: 0, - billingUnits: 100, - price: 10, - }); - const pro = products.pro({ - id: "pro", - items: [proPrepaid], - }); - - const premiumPrepaid = items.prepaidMessages({ - includedUsage: 100, - billingUnits: 100, - price: 10, - }); - const premium = products.premium({ - id: "premium", - items: [premiumPrepaid], - }); - - const { autumnV1 } = await initScenario({ - customerId, - setup: [ - s.customer({ paymentMethod: "success" }), - s.products({ list: [pro, premium] }), - ], - actions: [ - s.billing.attach({ - productId: pro.id, - options: [{ feature_id: TestFeature.Messages, quantity: 200 }], - }), - ], - }); - - // Verify initial: 200 units - const customerBefore = - await autumnV1.customers.get(customerId); - expectCustomerFeatureCorrect({ - customer: customerBefore, - featureId: TestFeature.Messages, - balance: 200, - usage: 0, - }); - - // 1. Preview upgrade - NO options - const preview = await autumnV1.billing.previewAttach({ - customer_id: customerId, - product_id: premium.id, - }); - // Base price difference: $50 - $20 = $30 - // Prepaid refund: -$10 (prorated unused from old product, new product no paid units) - expect(preview.total).toBe(20); - - // 2. Attach premium - NO options - await autumnV1.billing.attach({ - customer_id: customerId, - product_id: premium.id, - redirect_mode: "if_required", - }); - - const customer = await autumnV1.customers.get(customerId); - - await expectCustomerProducts({ - customer, - active: [premium.id], - notPresent: [pro.id], - }); - - // Verify balance: 100 included + 200 carried over = 300 - expectCustomerFeatureCorrect({ - customer, - featureId: TestFeature.Messages, - balance: 200, - usage: 0, - }); -}); - -// ═══════════════════════════════════════════════════════════════════════════════ -// TEST 5: Prepaid upgrade no options, included usage decreases -// ═══════════════════════════════════════════════════════════════════════════════ - -/** - * Scenario: - * - Pro with prepaid (100 included, 200 purchased = 300 total) - * - Upgrade to Premium with prepaid (0 included), NO options - * - * Expected Result: - * - Quantity carries over: 200 purchased - * - Balance = 0 (included) + 200 (purchased) = 200 - */ -test.concurrent(`${chalk.yellowBright("immediate-switch-prepaid-no-options 5: included usage decreases")}`, async () => { - const customerId = "imm-switch-prepaid-no-opts-incl-dec"; - - const proPrepaid = items.prepaidMessages({ - includedUsage: 100, - billingUnits: 100, - price: 10, - }); - const pro = products.pro({ - id: "pro", - items: [proPrepaid], - }); - - const premiumPrepaid = items.prepaidMessages({ - includedUsage: 0, - billingUnits: 100, - price: 10, - }); - const premium = products.premium({ - id: "premium", - items: [premiumPrepaid], - }); - - const { autumnV1 } = await initScenario({ - customerId, - setup: [ - s.customer({ paymentMethod: "success" }), - s.products({ list: [pro, premium] }), - ], - actions: [ - s.billing.attach({ - productId: pro.id, - options: [{ feature_id: TestFeature.Messages, quantity: 200 }], - }), - ], - }); - - // Verify initial: 100 included + 200 purchased = 300 - const customerBefore = - await autumnV1.customers.get(customerId); - expectCustomerFeatureCorrect({ - customer: customerBefore, - featureId: TestFeature.Messages, - balance: 200, - usage: 0, - }); - - // 1. Preview upgrade - NO options - const preview = await autumnV1.billing.previewAttach({ - customer_id: customerId, - product_id: premium.id, - }); - // Base diff: $50 + $20 - $20 - $10 - // Prepaid: same 2 packs, no diff - expect(preview.total).toBe(40); - - // 2. Attach premium - NO options - await autumnV1.billing.attach({ - customer_id: customerId, - product_id: premium.id, - redirect_mode: "if_required", - }); - - const customer = await autumnV1.customers.get(customerId); - - await expectCustomerProducts({ - customer, - active: [premium.id], - notPresent: [pro.id], - }); - - // Verify balance: 0 included + 200 purchased = 200 - expectCustomerFeatureCorrect({ - customer, - featureId: TestFeature.Messages, - balance: 200, - usage: 0, - }); -}); - -// ═══════════════════════════════════════════════════════════════════════════════ -// TEST 6: Prepaid upgrade no options, price change -// ═══════════════════════════════════════════════════════════════════════════════ - -/** - * Scenario: - * - Pro with prepaid (200 units @ $10/pack = 2 packs = $20) - * - Upgrade to Premium with prepaid ($15/pack), NO options - * - * Expected Result: - * - Quantity carries over: 200 units (2 packs) - * - Price diff charged: 2 packs * ($15 - $10) = $10 - */ -test.concurrent(`${chalk.yellowBright("immediate-switch-prepaid-no-options 6: price increases")}`, async () => { - const customerId = "imm-switch-prepaid-no-opts-price-inc"; - - const proPrepaid = items.prepaidMessages({ - includedUsage: 0, - billingUnits: 100, - price: 10, - }); - const pro = products.pro({ - id: "pro", - items: [proPrepaid], - }); - - const premiumPrepaid = items.prepaidMessages({ - includedUsage: 0, - billingUnits: 100, - price: 15, - }); - const premium = products.premium({ - id: "premium", - items: [premiumPrepaid], - }); - - const { autumnV1 } = await initScenario({ - customerId, - setup: [ - s.customer({ paymentMethod: "success" }), - s.products({ list: [pro, premium] }), - ], - actions: [ - s.billing.attach({ - productId: pro.id, - options: [{ feature_id: TestFeature.Messages, quantity: 200 }], - }), - ], - }); - - // 1. Preview upgrade - NO options - const preview = await autumnV1.billing.previewAttach({ - customer_id: customerId, - product_id: premium.id, - }); - // Base diff: $50 - $20 = $30 - // Prepaid diff: 2 packs * ($15 - $10) = $10 - // Total: $40 - expect(preview.total).toBe(40); - - // 2. Attach premium - NO options - await autumnV1.billing.attach({ - customer_id: customerId, - product_id: premium.id, - redirect_mode: "if_required", - }); - - const customer = await autumnV1.customers.get(customerId); - - await expectCustomerProducts({ - customer, - active: [premium.id], - notPresent: [pro.id], - }); - - // Verify balance carried over: 200 - expectCustomerFeatureCorrect({ - customer, - featureId: TestFeature.Messages, - balance: 200, - usage: 0, - }); -}); - -// ═══════════════════════════════════════════════════════════════════════════════ -// TEST 7: Prepaid upgrade no options, price decreases -// ═══════════════════════════════════════════════════════════════════════════════ - -/** - * Scenario: - * - Pro with prepaid (200 units @ $15/pack = 2 packs = $30) - * - Upgrade to Premium with prepaid ($10/pack), NO options - * - * Expected Result: - * - Quantity carries over: 200 units - * - Credit for price diff: 2 packs * ($10 - $15) = -$10 - */ -test.concurrent(`${chalk.yellowBright("immediate-switch-prepaid-no-options 7: price decreases")}`, async () => { - const customerId = "imm-switch-prepaid-no-opts-price-dec"; - - const proPrepaid = items.prepaidMessages({ - includedUsage: 0, - billingUnits: 100, - price: 15, - }); - const pro = products.pro({ - id: "pro", - items: [proPrepaid], - }); - - const premiumPrepaid = items.prepaidMessages({ - includedUsage: 0, - billingUnits: 100, - price: 10, - }); - const premium = products.premium({ - id: "premium", - items: [premiumPrepaid], - }); - - const { autumnV1 } = await initScenario({ - customerId, - setup: [ - s.customer({ paymentMethod: "success" }), - s.products({ list: [pro, premium] }), - ], - actions: [ - s.billing.attach({ - productId: pro.id, - options: [{ feature_id: TestFeature.Messages, quantity: 200 }], - }), - ], - }); - - // 1. Preview upgrade - NO options - const preview = await autumnV1.billing.previewAttach({ - customer_id: customerId, - product_id: premium.id, - }); - // Base diff: $50 - $20 = $30 - // Prepaid diff: 2 packs * ($10 - $15) = -$10 - // Total: $20 - expect(preview.total).toBe(20); - - // 2. Attach premium - NO options - await autumnV1.billing.attach({ - customer_id: customerId, - product_id: premium.id, - redirect_mode: "if_required", - }); - - const customer = await autumnV1.customers.get(customerId); - - await expectCustomerProducts({ - customer, - active: [premium.id], - notPresent: [pro.id], - }); - - // Verify balance carried over: 200 - expectCustomerFeatureCorrect({ - customer, - featureId: TestFeature.Messages, - balance: 200, - usage: 0, - }); -}); - -// ═══════════════════════════════════════════════════════════════════════════════ -// TEST 8: Prepaid with usage, upgrade no options - balance preserved -// ═══════════════════════════════════════════════════════════════════════════════ - -/** - * Scenario: - * - Pro with prepaid (200 purchased) - * - Track 50 usage (balance = 150) - * - Upgrade to Premium with prepaid, NO options - * - * Expected Result: - * - Quantity carries over: 200 purchased (usage resets for prepaid on upgrade) - * - Balance = 200 (prepaid resets usage) - */ -test.concurrent(`${chalk.yellowBright("immediate-switch-prepaid-no-options 8: with usage")}`, async () => { - const customerId = "imm-switch-prepaid-no-opts-with-usage"; - - const proPrepaid = items.prepaidMessages({ - includedUsage: 0, - billingUnits: 100, - price: 10, - }); - const pro = products.pro({ - id: "pro", - items: [proPrepaid], - }); - - const premiumPrepaid = items.prepaidMessages({ - includedUsage: 0, - billingUnits: 100, - price: 10, - }); - const premium = products.premium({ - id: "premium", - items: [premiumPrepaid], - }); - - const { autumnV1 } = await initScenario({ - customerId, - setup: [ - s.customer({ paymentMethod: "success" }), - s.products({ list: [pro, premium] }), - ], - actions: [ - s.billing.attach({ - productId: pro.id, - options: [{ feature_id: TestFeature.Messages, quantity: 200 }], - }), - ], - }); - - // Wait for webhooks to process and cache to reset - await new Promise((r) => setTimeout(r, 4000)); - - // Track 50 usage - await autumnV1.track({ - customer_id: customerId, - feature_id: TestFeature.Messages, - value: 50, - }); - - await new Promise((r) => setTimeout(r, 2000)); - - // Verify state before upgrade: balance = 150 - const customerBefore = - await autumnV1.customers.get(customerId); - expectCustomerFeatureCorrect({ - customer: customerBefore, - featureId: TestFeature.Messages, - balance: 150, - usage: 50, - }); - - // 1. Preview upgrade - NO options - const preview = await autumnV1.billing.previewAttach({ - customer_id: customerId, - product_id: premium.id, - }); - // Base diff: $50 - $20 = $30 - // Prepaid: same quantity carried over, no diff - expect(preview.total).toBe(30); - - // 2. Attach premium - NO options - await autumnV1.billing.attach({ - customer_id: customerId, - product_id: premium.id, - redirect_mode: "if_required", - }); - - const customer = await autumnV1.customers.get(customerId); - - await expectCustomerProducts({ - customer, - active: [premium.id], - notPresent: [pro.id], - }); - - // Verify balance: 200 (usage resets on upgrade) - expectCustomerFeatureCorrect({ - customer, - featureId: TestFeature.Messages, - balance: 200, - usage: 0, - }); -}); - -// ═══════════════════════════════════════════════════════════════════════════════ -// TEST 9: Multiple prepaid features, upgrade with partial options -// ═══════════════════════════════════════════════════════════════════════════════ - -/** - * Scenario: - * - Pro with 2 prepaid features: - * - Messages: 200 purchased - * - Words: 500 purchased - * - Upgrade to Premium, only specify messages option (increase to 300) - * - * Expected Result: - * - Messages: changed to 300 - * - Words: carries over at 500 - */ -test.concurrent(`${chalk.yellowBright("immediate-switch-prepaid-no-options 9: multiple prepaid partial options")}`, async () => { - const customerId = "imm-switch-prepaid-partial-opts"; - - const proMessagesPrepaid = items.prepaidMessages({ - includedUsage: 0, - billingUnits: 100, - price: 10, - }); - const proWordsPrepaid = items.prepaid({ - featureId: TestFeature.Words, - includedUsage: 0, - billingUnits: 100, - price: 5, - }); - const pro = products.pro({ - id: "pro", - items: [proMessagesPrepaid, proWordsPrepaid], - }); - - const premiumMessagesPrepaid = items.prepaidMessages({ - includedUsage: 0, - billingUnits: 100, - price: 10, - }); - const premiumWordsPrepaid = items.prepaid({ - featureId: TestFeature.Words, - includedUsage: 0, - billingUnits: 100, - price: 5, - }); - const premium = products.premium({ - id: "premium", - items: [premiumMessagesPrepaid, premiumWordsPrepaid], - }); - - const { autumnV1 } = await initScenario({ - customerId, - setup: [ - s.customer({ paymentMethod: "success" }), - s.products({ list: [pro, premium] }), - ], - actions: [ - s.billing.attach({ - productId: pro.id, - options: [ - { feature_id: TestFeature.Messages, quantity: 200 }, - { feature_id: TestFeature.Words, quantity: 500 }, - ], - }), - ], - }); - - // Verify initial state - const customerBefore = - await autumnV1.customers.get(customerId); - expectCustomerFeatureCorrect({ - customer: customerBefore, - featureId: TestFeature.Messages, - balance: 200, - }); - expectCustomerFeatureCorrect({ - customer: customerBefore, - featureId: TestFeature.Words, - balance: 500, - }); - - // 1. Preview upgrade - ONLY messages option (words should carry over) - const preview = await autumnV1.billing.previewAttach({ - customer_id: customerId, - product_id: premium.id, - options: [{ feature_id: TestFeature.Messages, quantity: 300 }], - }); - // Base diff: $50 - $20 = $30 - // Messages: (3 packs - 2 packs) * $10 = $10 - // Words: carried over (no change) - expect(preview.total).toBe(40); - - // 2. Attach premium - only messages option - await autumnV1.billing.attach({ - customer_id: customerId, - product_id: premium.id, - options: [{ feature_id: TestFeature.Messages, quantity: 300 }], - redirect_mode: "if_required", - }); - - const customer = await autumnV1.customers.get(customerId); - - await expectCustomerProducts({ - customer, - active: [premium.id], - notPresent: [pro.id], - }); - - // Verify messages changed to 300 - expectCustomerFeatureCorrect({ - customer, - featureId: TestFeature.Messages, - balance: 300, - }); - - // Verify words carried over at 500 - expectCustomerFeatureCorrect({ - customer, - featureId: TestFeature.Words, - balance: 500, - }); -}); - -// ═══════════════════════════════════════════════════════════════════════════════ -// TEST 10: Prepaid upgrade no options, all config changes (billing units, price, included) -// ═══════════════════════════════════════════════════════════════════════════════ - -/** - * Scenario: - * - Pro with prepaid: - * - 0 included, 200 purchased @ 100 units/pack @ $10/pack - * - Total: 200 units, 2 packs, $20 prepaid - * - Upgrade to Premium with prepaid (NO options): - * - 50 included, 50 units/pack @ $15/pack - * - * Expected Result: - * - Quantity carries over: 200 purchased - * - New packs: 200 / 50 = 4 packs - * - Balance = 50 (included) + 200 (purchased) = 250 - * - Prepaid diff: (4 packs * $15) - (2 packs * $10) = $60 - $20 = $40 - */ -test.concurrent(`${chalk.yellowBright("immediate-switch-prepaid-no-options 10: all config changes")}`, async () => { - const customerId = "imm-switch-prepaid-no-opts-all-change"; - - const proPrepaid = items.prepaidMessages({ - includedUsage: 0, - billingUnits: 100, - price: 10, - }); - const pro = products.pro({ - id: "pro", - items: [proPrepaid], - }); - - const premiumPrepaid = items.prepaidMessages({ - includedUsage: 50, - billingUnits: 50, - price: 15, - }); - const premium = products.premium({ - id: "premium", - items: [premiumPrepaid], - }); - - const { autumnV1 } = await initScenario({ - customerId, - setup: [ - s.customer({ paymentMethod: "success" }), - s.products({ list: [pro, premium] }), - ], - actions: [ - s.billing.attach({ - productId: pro.id, - options: [{ feature_id: TestFeature.Messages, quantity: 200 }], - }), - ], - }); - - // Verify initial: 200 units - const customerBefore = - await autumnV1.customers.get(customerId); - expectCustomerFeatureCorrect({ - customer: customerBefore, - featureId: TestFeature.Messages, - balance: 200, - usage: 0, - }); - - // 1. Preview upgrade - NO options - const preview = await autumnV1.billing.previewAttach({ - customer_id: customerId, - product_id: premium.id, - }); - // Base diff: $50 - $20 = $30 - // Prepaid diff: (3 * $15) - (2 * $10) = $45 - $20 = $25 - // Total: $55 - expect(preview.total).toBe(55); - - // 2. Attach premium - NO options - await autumnV1.billing.attach({ - customer_id: customerId, - product_id: premium.id, - redirect_mode: "if_required", - }); - - const customer = await autumnV1.customers.get(customerId); - - await expectCustomerProducts({ - customer, - active: [premium.id], - notPresent: [pro.id], - }); - - expectCustomerFeatureCorrect({ - customer, - featureId: TestFeature.Messages, - balance: 200, - usage: 0, - }); -}); - -// ═══════════════════════════════════════════════════════════════════════════════ -// TEST 11: Prepaid upgrade with options set to 0 (explicit reset) -// ═══════════════════════════════════════════════════════════════════════════════ - -/** - * Scenario: - * - Pro with prepaid (200 purchased) - * - Upgrade to Premium with options explicitly set to 0 - * - * Expected Result: - * - Quantity set to 0 (not carried over because explicit) - * - Credit for removed prepaid - */ -test.concurrent(`${chalk.yellowBright("immediate-switch-prepaid-no-options 11: options explicitly set to 0")}`, async () => { - const customerId = "imm-switch-prepaid-opts-zero"; - - const proPrepaid = items.prepaidMessages({ - includedUsage: 0, - billingUnits: 100, - price: 10, - }); - const pro = products.pro({ - id: "pro", - items: [proPrepaid], - }); - - const premiumPrepaid = items.prepaidMessages({ - includedUsage: 0, - billingUnits: 100, - price: 10, - }); - const premium = products.premium({ - id: "premium", - items: [premiumPrepaid], - }); - - const { autumnV1 } = await initScenario({ - customerId, - setup: [ - s.customer({ paymentMethod: "success" }), - s.products({ list: [pro, premium] }), - ], - actions: [ - s.billing.attach({ - productId: pro.id, - options: [{ feature_id: TestFeature.Messages, quantity: 200 }], - }), - ], - }); - - // Verify initial: 200 units - const customerBefore = - await autumnV1.customers.get(customerId); - expectCustomerFeatureCorrect({ - customer: customerBefore, - featureId: TestFeature.Messages, - balance: 200, - }); - - // 1. Preview upgrade - options explicitly 0 - const preview = await autumnV1.billing.previewAttach({ - customer_id: customerId, - product_id: premium.id, - options: [{ feature_id: TestFeature.Messages, quantity: 0 }], - }); - // Base diff: $50 - $20 = $30 - // Prepaid diff: (0 packs - 2 packs) * $10 = -$20 - // Total: $10 - expect(preview.total).toBe(10); - - // 2. Attach premium - options explicitly 0 - await autumnV1.billing.attach({ - customer_id: customerId, - product_id: premium.id, - options: [{ feature_id: TestFeature.Messages, quantity: 0 }], - redirect_mode: "if_required", - }); - - const customer = await autumnV1.customers.get(customerId); - - await expectCustomerProducts({ - customer, - active: [premium.id], - notPresent: [pro.id], - }); - - // Verify balance: 0 (explicitly set) - expectCustomerFeatureCorrect({ - customer, - featureId: TestFeature.Messages, - balance: 0, - usage: 0, - }); -}); diff --git a/server/tests/integration/billing/utils/checkout/autumnCheckoutUtils.ts b/server/tests/integration/billing/utils/checkout/autumnCheckoutUtils.ts index da1f744ed..34fe83557 100644 --- a/server/tests/integration/billing/utils/checkout/autumnCheckoutUtils.ts +++ b/server/tests/integration/billing/utils/checkout/autumnCheckoutUtils.ts @@ -26,15 +26,23 @@ export const confirmAutumnCheckout = async ({ checkoutId, customerId, productId, + featureQuantities, }: { checkoutId: string; customerId: string; productId: string; + featureQuantities?: Array<{ feature_id: string; quantity: number }>; }): Promise => { const response = await fetch( `${CHECKOUT_BASE_URL}/checkouts/${checkoutId}/confirm`, { method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify( + featureQuantities ? { feature_quantities: featureQuantities } : {}, + ), signal: AbortSignal.timeout(CHECKOUT_TIMEOUT_MS), }, ); @@ -56,6 +64,7 @@ export const confirmAutumnCheckoutAndGetCustomer = async ({ checkoutId, customerId, productId, + featureQuantities, }: { autumnV1: { customers: { @@ -65,11 +74,13 @@ export const confirmAutumnCheckoutAndGetCustomer = async ({ checkoutId: string; customerId: string; productId: string; + featureQuantities?: Array<{ feature_id: string; quantity: number }>; }) => { const confirmData = await confirmAutumnCheckout({ checkoutId, customerId, productId, + featureQuantities, }); const customer = await autumnV1.customers.get(customerId); diff --git a/shared/internal/checkout/confirmCheckoutParams.ts b/shared/internal/checkout/confirmCheckoutParams.ts index 4b9e2a3b9..4b06fdfeb 100644 --- a/shared/internal/checkout/confirmCheckoutParams.ts +++ b/shared/internal/checkout/confirmCheckoutParams.ts @@ -2,12 +2,14 @@ import { FeatureOptionsSchema } from "@models/cusProductModels/cusProductModels" import { z } from "zod/v4"; export const ConfirmCheckoutParamsSchema = z.object({ - feature_quantities: z.array( - FeatureOptionsSchema.pick({ - feature_id: true, - quantity: true, - }), - ), + feature_quantities: z + .array( + FeatureOptionsSchema.pick({ + feature_id: true, + quantity: true, + }), + ) + .optional(), }); export type ConfirmCheckoutParams = z.infer;