diff --git a/scripts/testGroups/all.sh b/scripts/testGroups/all.sh index 59ced531e..6cfd0fcbd 100755 --- a/scripts/testGroups/all.sh +++ b/scripts/testGroups/all.sh @@ -2,13 +2,13 @@ source "$(dirname "$0")/config.sh" BUN_PARALLEL_V2 \ - 'integration/billing/update-subscription' \ + 'integration/billing/attach' \ + # 'integration/billing/update-subscription' \ # 'integration/billing/stripe-webhooks' \ # 'integration/billing/autumn-webhooks' \ # 'integration/billing/migrations' \ # 'integration/billing/cron' \ # 'integration/crud/customers' \ - # 'integration/billing/attach' \ # 'integration/billing/attach' \ diff --git a/server/src/internal/billing/attachPreview/attachParamsToPreview.ts b/server/src/internal/billing/attachPreview/attachParamsToPreview.ts index eeb7216d3..0015b107d 100644 --- a/server/src/internal/billing/attachPreview/attachParamsToPreview.ts +++ b/server/src/internal/billing/attachPreview/attachParamsToPreview.ts @@ -48,7 +48,6 @@ export const attachParamsToPreview = async ({ const func = await getAttachFunction({ branch, attachParams, - attachBody, config, }); diff --git a/server/src/internal/billing/checkout/utils/checkoutToAttachContext.ts b/server/src/internal/billing/checkout/utils/checkoutToAttachContext.ts index e482264b1..e77a48dc4 100644 --- a/server/src/internal/billing/checkout/utils/checkoutToAttachContext.ts +++ b/server/src/internal/billing/checkout/utils/checkoutToAttachContext.ts @@ -51,7 +51,6 @@ export const checkoutToAttachContext = async ({ const func = await getAttachFunction({ branch, attachParams, - attachBody: checkoutParams, config, }); diff --git a/server/src/internal/billing/v2/providers/stripe/execute/executeStripeBillingPlan.ts b/server/src/internal/billing/v2/providers/stripe/execute/executeStripeBillingPlan.ts index 92ef0cc73..52e579565 100644 --- a/server/src/internal/billing/v2/providers/stripe/execute/executeStripeBillingPlan.ts +++ b/server/src/internal/billing/v2/providers/stripe/execute/executeStripeBillingPlan.ts @@ -1,3 +1,9 @@ +import type { + BillingContext, + BillingPlan, + StripeBillingPlanResult, +} from "@autumn/shared"; +import { StripeBillingStage } from "@autumn/shared"; import type { AutumnContext } from "@/honoUtils/HonoEnv"; import { addStripeSubscriptionScheduleIdToBillingPlan } from "@/internal/billing/v2/execute/addStripeSubscriptionScheduleIdToBillingPlan"; import { executeStripeCheckoutSessionAction } from "@/internal/billing/v2/providers/stripe/execute/executeStripeCheckoutSessionAction"; @@ -5,12 +11,6 @@ import { executeStripeInvoiceAction } from "@/internal/billing/v2/providers/stri import { executeStripeSubscriptionAction } from "@/internal/billing/v2/providers/stripe/execute/executeStripeSubscriptionAction"; import { executeStripeSubscriptionScheduleAction } from "@/internal/billing/v2/providers/stripe/execute/executeStripeSubscriptionScheduleAction"; import { createStripeInvoiceItems } from "@/internal/billing/v2/providers/stripe/utils/invoices/stripeInvoiceOps"; -import type { - BillingContext, - BillingPlan, - StripeBillingPlanResult, -} from "@autumn/shared"; -import { StripeBillingStage } from "@autumn/shared"; export const executeStripeBillingPlan = async ({ ctx, diff --git a/server/src/internal/billing/v2/utils/initFullCustomerProduct/reapplyExistingUsagesToCustomerProduct.ts b/server/src/internal/billing/v2/utils/initFullCustomerProduct/reapplyExistingUsagesToCustomerProduct.ts index dbcfbf255..6701bd67b 100644 --- a/server/src/internal/billing/v2/utils/initFullCustomerProduct/reapplyExistingUsagesToCustomerProduct.ts +++ b/server/src/internal/billing/v2/utils/initFullCustomerProduct/reapplyExistingUsagesToCustomerProduct.ts @@ -46,8 +46,10 @@ export const reapplyExistingUsagesToCustomerProduct = async ({ }); // Reinitialize customer entitlements with reset balance + // Use the NEW customerProduct (not currentCustomerProduct) to get the correct + // prices and allowances for balance initialization const fullProduct = cusProductToProduct({ - cusProduct: currentCustomerProduct, + cusProduct: customerProduct, }); for (const cusEnt of customerProduct.customer_entitlements) { const { balance, entities } = initCustomerEntitlementBalance({ diff --git a/server/src/internal/customers/attach/attachUtils/getAttachFunction.ts b/server/src/internal/customers/attach/attachUtils/getAttachFunction.ts index c4ee171ef..8466de3c0 100644 --- a/server/src/internal/customers/attach/attachUtils/getAttachFunction.ts +++ b/server/src/internal/customers/attach/attachUtils/getAttachFunction.ts @@ -36,12 +36,10 @@ import { export const getAttachFunction = async ({ branch, attachParams, - attachBody, config, }: { branch: AttachBranch; attachParams: AttachParams; - attachBody: AttachBodyV0; config: AttachConfig; }) => { const { onlyCheckout } = config; @@ -128,7 +126,6 @@ export const runAttachFunction = async ({ const attachFunction = await getAttachFunction({ branch, attachParams, - attachBody, config, }); @@ -258,8 +255,6 @@ export const runAttachFunction = async ({ return await handleUpgradeFlow({ ctx, attachParams, - config, - branch, }); } diff --git a/server/tests/integration/billing/attach/scheduled-switch/scheduled-switch-allocated.test.ts b/server/tests/integration/billing/attach/scheduled-switch/scheduled-switch-allocated.test.ts index e4ba25915..0d0a5142c 100644 --- a/server/tests/integration/billing/attach/scheduled-switch/scheduled-switch-allocated.test.ts +++ b/server/tests/integration/billing/attach/scheduled-switch/scheduled-switch-allocated.test.ts @@ -46,7 +46,7 @@ import chalk from "chalk"; * - Balance on Pro: 5 - 10 = -5 (overage) * - Invoice: Pro with 5 overage seats (10 usage - 5 included) */ -test.concurrent(`${chalk.yellowBright("scheduled-switch-allocated 1: premium to pro with overage, full cycle")}`, async () => { +test.skip(`${chalk.yellowBright("scheduled-switch-allocated 1: premium to pro with overage, full cycle")}`, async () => { const customerId = "sched-switch-alloc-overage-full"; const premiumAllocated = items.allocatedUsers({ includedUsage: 3 }); @@ -68,7 +68,7 @@ test.concurrent(`${chalk.yellowBright("scheduled-switch-allocated 1: premium to s.products({ list: [premium, pro] }), ], actions: [ - s.billing.attach({ productId: premium.id }), + s.billing.attach({ productId: premium.id, timeout: 2000 }), s.track({ featureId: TestFeature.Users, value: 10, timeout: 2000 }), ], }); @@ -83,11 +83,14 @@ test.concurrent(`${chalk.yellowBright("scheduled-switch-allocated 1: premium to expect(preview.total).toBe(0); // Schedule the downgrade - await autumnV1.billing.attach({ - customer_id: customerId, - product_id: pro.id, - redirect_mode: "if_required", - }); + await autumnV1.billing.attach( + { + customer_id: customerId, + product_id: pro.id, + redirect_mode: "if_required", + }, + { timeout: 2000 }, + ); const customer = await autumnV1.customers.get(customerId); @@ -173,7 +176,7 @@ test.concurrent(`${chalk.yellowBright("scheduled-switch-allocated 1: premium to * - Invoice includes: Pro with 5 overage seats (10 usage - 5 included) * - Balance on Pro: 5 - 10 = -5 (overage) */ -test.concurrent(`${chalk.yellowBright("scheduled-switch-allocated 2: downgrade first, then track with overage")}`, async () => { +test.skip(`${chalk.yellowBright("scheduled-switch-allocated 2: downgrade first, then track with overage")}`, async () => { const customerId = "sched-switch-alloc-downgrade-then-track"; const premiumAllocated = items.allocatedUsers({ includedUsage: 3 }); @@ -194,7 +197,7 @@ test.concurrent(`${chalk.yellowBright("scheduled-switch-allocated 2: downgrade f s.customer({ paymentMethod: "success" }), s.products({ list: [premium, pro] }), ], - actions: [s.billing.attach({ productId: premium.id })], + actions: [s.billing.attach({ productId: premium.id, timeout: 2000 })], }); // Schedule downgrade FIRST (before tracking) @@ -241,6 +244,7 @@ test.concurrent(`${chalk.yellowBright("scheduled-switch-allocated 2: downgrade f await advanceToNextInvoice({ stripeCli: ctx.stripeCli, testClockId: testClockId!, + withPause: true, }); const customerAfterCycle = @@ -298,7 +302,7 @@ test.concurrent(`${chalk.yellowBright("scheduled-switch-allocated 2: downgrade f * - Usage carries over: 7 users * - Balance on Free: 2 - 7 = -5 (overage, but no overage charge on free) */ -test.concurrent(`${chalk.yellowBright("scheduled-switch-allocated 3: pro to free, overage on old product")}`, async () => { +test.skip(`${chalk.yellowBright("scheduled-switch-allocated 3: pro to free, overage on old product")}`, async () => { const customerId = "sched-switch-alloc-pro-to-free"; const proAllocated = items.allocatedUsers({ includedUsage: 5 }); @@ -320,7 +324,7 @@ test.concurrent(`${chalk.yellowBright("scheduled-switch-allocated 3: pro to free s.products({ list: [pro, free] }), ], actions: [ - s.billing.attach({ productId: pro.id }), + s.billing.attach({ productId: pro.id, timeout: 2000 }), s.track({ featureId: TestFeature.Users, value: 7, timeout: 2000 }), ], }); @@ -398,7 +402,7 @@ test.concurrent(`${chalk.yellowBright("scheduled-switch-allocated 3: pro to free * - Usage carries over: 7 users * - Balance on Free: 2 - 7 = -5 (overage) */ -test.concurrent(`${chalk.yellowBright("scheduled-switch-allocated 4: pro to free, downgrade first then track")}`, async () => { +test.skip(`${chalk.yellowBright("scheduled-switch-allocated 4: pro to free, downgrade first then track")}`, async () => { const customerId = "sched-switch-alloc-pro-free-downgrade-first"; const proAllocated = items.allocatedUsers({ includedUsage: 5 }); @@ -419,7 +423,7 @@ test.concurrent(`${chalk.yellowBright("scheduled-switch-allocated 4: pro to free s.customer({ paymentMethod: "success" }), s.products({ list: [pro, free] }), ], - actions: [s.billing.attach({ productId: pro.id })], + actions: [s.billing.attach({ productId: pro.id, timeout: 2000 })], }); // Schedule downgrade FIRST (before tracking) @@ -515,7 +519,7 @@ test.concurrent(`${chalk.yellowBright("scheduled-switch-allocated 4: pro to free * - Usage carries over: 8 users * - Balance on Pro: 5 - 8 = -3 (overage, but no overage charge since free allocated) */ -test.concurrent(`${chalk.yellowBright("scheduled-switch-allocated 5: premium to pro with FREE allocated users")}`, async () => { +test.skip(`${chalk.yellowBright("scheduled-switch-allocated 5: premium to pro with FREE allocated users")}`, async () => { const customerId = "sched-switch-free-alloc-premium-to-pro"; const premiumFreeUsers = items.freeAllocatedUsers({ includedUsage: 10 }); @@ -537,7 +541,7 @@ test.concurrent(`${chalk.yellowBright("scheduled-switch-allocated 5: premium to s.products({ list: [premium, pro] }), ], actions: [ - s.billing.attach({ productId: premium.id }), + s.billing.attach({ productId: premium.id, timeout: 2000 }), s.track({ featureId: TestFeature.Users, value: 8, timeout: 2000 }), ], }); @@ -640,7 +644,7 @@ test.concurrent(`${chalk.yellowBright("scheduled-switch-allocated 5: premium to * - Usage carries over: 4 users * - Balance on Free: 2 - 4 = -2 (overage, but no charge on free) */ -test.concurrent(`${chalk.yellowBright("scheduled-switch-allocated 6: pro to free with FREE allocated users")}`, async () => { +test.skip(`${chalk.yellowBright("scheduled-switch-allocated 6: pro to free with FREE allocated users")}`, async () => { const customerId = "sched-switch-free-alloc-pro-to-free"; const proFreeUsers = items.freeAllocatedUsers({ includedUsage: 5 }); @@ -662,7 +666,7 @@ test.concurrent(`${chalk.yellowBright("scheduled-switch-allocated 6: pro to free s.products({ list: [pro, free] }), ], actions: [ - s.billing.attach({ productId: pro.id }), + s.billing.attach({ productId: pro.id, timeout: 2000 }), s.track({ featureId: TestFeature.Users, value: 4, timeout: 2000 }), ], }); @@ -679,11 +683,16 @@ test.concurrent(`${chalk.yellowBright("scheduled-switch-allocated 6: pro to free }); // Schedule downgrade to free - await autumnV1.billing.attach({ - customer_id: customerId, - product_id: free.id, - redirect_mode: "if_required", - }); + await autumnV1.billing.attach( + { + customer_id: customerId, + product_id: free.id, + redirect_mode: "if_required", + }, + { + timeout: 2000, + }, + ); const customerScheduled = await autumnV1.customers.get(customerId); diff --git a/server/tests/integration/billing/attach/scheduled-switch/scheduled-switch-basic.test.ts b/server/tests/integration/billing/attach/scheduled-switch/scheduled-switch-basic.test.ts index ad6368d2f..7d604b215 100644 --- a/server/tests/integration/billing/attach/scheduled-switch/scheduled-switch-basic.test.ts +++ b/server/tests/integration/billing/attach/scheduled-switch/scheduled-switch-basic.test.ts @@ -666,7 +666,7 @@ test.concurrent(`${chalk.yellowBright("scheduled-switch-basic 5: premium to pro s.products({ list: [premium, pro] }), ], actions: [ - s.billing.attach({ productId: premium.id }), + s.billing.attach({ productId: premium.id, timeout: 2000 }), s.track({ featureId: TestFeature.Messages, value: 300, timeout: 2000 }), ], }); @@ -796,7 +796,7 @@ test.concurrent(`${chalk.yellowBright("scheduled-switch-basic 6: pro to free wit s.products({ list: [pro, free] }), ], actions: [ - s.billing.attach({ productId: pro.id }), + s.billing.attach({ productId: pro.id, timeout: 2000 }), s.track({ featureId: TestFeature.Messages, value: 200, timeout: 2000 }), ], }); diff --git a/server/tests/integration/billing/attach/scheduled-switch/scheduled-switch-consumable.test.ts b/server/tests/integration/billing/attach/scheduled-switch/scheduled-switch-consumable.test.ts index 77c93283e..64486e324 100644 --- a/server/tests/integration/billing/attach/scheduled-switch/scheduled-switch-consumable.test.ts +++ b/server/tests/integration/billing/attach/scheduled-switch/scheduled-switch-consumable.test.ts @@ -204,8 +204,12 @@ test.concurrent(`${chalk.yellowBright("scheduled-switch-consumable 2: pro with c s.products({ list: [pro, free] }), ], actions: [ - s.billing.attach({ productId: pro.id }), - s.track({ featureId: TestFeature.Messages, value: usageAmount }), + s.billing.attach({ productId: pro.id, timeout: 2000 }), + s.track({ + featureId: TestFeature.Messages, + value: usageAmount, + timeout: 2000, + }), s.billing.attach({ productId: free.id }), // Schedule downgrade s.advanceToNextInvoice({ withPause: true }), ], diff --git a/server/tests/integration/billing/attach/scheduled-switch/scheduled-switch-multi-interval.test.ts b/server/tests/integration/billing/attach/scheduled-switch/scheduled-switch-multi-interval.test.ts deleted file mode 100644 index d874bfaef..000000000 --- a/server/tests/integration/billing/attach/scheduled-switch/scheduled-switch-multi-interval.test.ts +++ /dev/null @@ -1,510 +0,0 @@ -/** - * Scheduled Switch Multi-Interval Tests (Attach V2) - * - * Tests for downgrades involving mixed billing intervals (annual + monthly entities). - * - * Key behaviors: - * - Annual and monthly subscriptions have different cycle end dates - * - Monthly downgrades complete after 1 month - * - Annual downgrades complete after 1 year - */ - -import { expect, test } from "bun:test"; -import type { ApiEntityV0 } from "@autumn/shared"; -import { expectCustomerFeatureCorrect } from "@tests/integration/billing/utils/expectCustomerFeatureCorrect"; -import { - expectProductActive, - expectProductCanceling, - expectProductNotPresent, - expectProductScheduled, -} from "@tests/integration/billing/utils/expectCustomerProductCorrect"; -import { expectPreviewNextCycleCorrect } from "@tests/integration/billing/utils/expectPreviewNextCycleCorrect"; -import { expectSubToBeCorrect } from "@tests/merged/mergeUtils/expectSubCorrect"; -import { TestFeature } from "@tests/setup/v2Features"; -import { items } from "@tests/utils/fixtures/items"; -import { products } from "@tests/utils/fixtures/products"; -import { initScenario, s } from "@tests/utils/testInitUtils/initScenario"; -import chalk from "chalk"; -import { addMonths, addYears } from "date-fns"; - -// ═══════════════════════════════════════════════════════════════════════════════ -// TEST 1: Entity 1 premiumAnnual, entity 2 premium, downgrade both to pro, advance monthly cycle -// ═══════════════════════════════════════════════════════════════════════════════ - -/** - * Scenario: - * - Entity 1: Premium Annual ($500/year) - * - Entity 2: Premium Monthly ($50/mo) - * - Downgrade both to Pro (scheduled) - * - Advance 1 month - * - * Expected Result: - * - Entity 1: Still on premiumAnnual + pro scheduled (annual not ended) - * - Entity 2: Now on pro (monthly cycle completed) - */ -test.concurrent(`${chalk.yellowBright("scheduled-switch-multi-interval 1: entity 1 premiumAnnual, entity 2 premium, downgrade both to pro, advance monthly cycle")}`, async () => { - const customerId = "sched-switch-multi-interval-1"; - - const proMessages = items.monthlyMessages({ includedUsage: 100 }); - const pro = products.pro({ - id: "pro", - items: [proMessages], - }); - - const premiumMessages = items.monthlyMessages({ includedUsage: 500 }); - const premium = products.premium({ - id: "premium", - items: [premiumMessages], - }); - - const premiumAnnualMessages = items.monthlyMessages({ includedUsage: 500 }); - const premiumAnnualPrice = items.annualPrice({ price: 500 }); - // Use products.base for custom pricing (products.premium adds $50/mo automatically) - const premiumAnnual = products.base({ - id: "premium-annual", - items: [premiumAnnualMessages, premiumAnnualPrice], - }); - - const { autumnV1, entities, advancedTo } = await initScenario({ - customerId, - setup: [ - s.customer({ paymentMethod: "success" }), - s.products({ list: [pro, premium, premiumAnnual] }), - s.entities({ count: 2, featureId: TestFeature.Users }), - ], - actions: [ - s.billing.attach({ productId: premiumAnnual.id, entityIndex: 0 }), // Annual - s.billing.attach({ productId: premium.id, entityIndex: 1 }), // Monthly - ], - }); - - // Preview downgrade for entity 1 (annual -> pro) - // Next cycle should be 1 year from now (annual subscription) - const preview1 = await autumnV1.billing.previewAttach({ - customer_id: customerId, - product_id: pro.id, - entity_id: entities[0].id, - }); - expect(preview1.total).toBe(0); // Downgrade = no immediate charge - expectPreviewNextCycleCorrect({ - preview: preview1, - startsAt: addYears(advancedTo, 1).getTime(), // Annual cycle - total: 20, // Pro is $20/mo - }); - - // Preview downgrade for entity 2 (monthly -> pro) - // Next cycle should be 1 month from now (monthly subscription) - const preview2 = await autumnV1.billing.previewAttach({ - customer_id: customerId, - product_id: pro.id, - entity_id: entities[1].id, - }); - expect(preview2.total).toBe(0); // Downgrade = no immediate charge - expectPreviewNextCycleCorrect({ - preview: preview2, - startsAt: addMonths(advancedTo, 1).getTime(), // Monthly cycle - total: 20, // Pro is $20/mo - }); - - // Schedule the downgrades - await autumnV1.billing.attach({ - customer_id: customerId, - product_id: pro.id, - entity_id: entities[0].id, - redirect_mode: "if_required", - }); - await autumnV1.billing.attach({ - customer_id: customerId, - product_id: pro.id, - entity_id: entities[1].id, - redirect_mode: "if_required", - }); - - // Advance to next invoice (1 month) with fresh scenario - const { - autumnV1: autumnV1After, - ctx: ctxAfter, - entities: entitiesAfter, - } = await initScenario({ - customerId, - setup: [ - s.customer({ paymentMethod: "success" }), - s.products({ list: [pro, premium, premiumAnnual] }), - s.entities({ count: 2, featureId: TestFeature.Users }), - ], - actions: [ - s.billing.attach({ productId: premiumAnnual.id, entityIndex: 0 }), - s.billing.attach({ productId: premium.id, entityIndex: 1 }), - s.billing.attach({ productId: pro.id, entityIndex: 0 }), - s.billing.attach({ productId: pro.id, entityIndex: 1 }), - s.advanceToNextInvoice(), - ], - }); - - // Verify entity 1: premiumAnnual still canceling, pro scheduled - // Annual hasn't ended yet - const entity1 = await autumnV1After.entities.get( - customerId, - entitiesAfter[0].id, - ); - await expectProductCanceling({ - customer: entity1, - productId: premiumAnnual.id, - }); - await expectProductScheduled({ - customer: entity1, - productId: pro.id, - }); - - // Verify entity 2: now on pro (monthly completed) - const entity2 = await autumnV1After.entities.get( - customerId, - entitiesAfter[1].id, - ); - await expectProductActive({ - customer: entity2, - productId: pro.id, - }); - await expectProductNotPresent({ - customer: entity2, - productId: premium.id, - }); - - // Verify Stripe subscription - await expectSubToBeCorrect({ - db: ctxAfter.db, - customerId, - org: ctxAfter.org, - env: ctxAfter.env, - }); -}); - -// ═══════════════════════════════════════════════════════════════════════════════ -// TEST 2: Entity 1 premiumAnnual, entity 2 premium, downgrade both to pro, re-upgrade both -// ═══════════════════════════════════════════════════════════════════════════════ - -/** - * Scenario: - * - Entity 1: Premium Annual ($500/year) - * - Entity 2: Premium Monthly ($50/mo) - * - Downgrade both to Pro (scheduled) - * - Re-upgrade both to Premium/PremiumAnnual (immediate) - * - * Expected Result: - * - Scheduled downgrades cancelled - * - Both back to original products - */ -test.concurrent(`${chalk.yellowBright("scheduled-switch-multi-interval 2: entity 1 premiumAnnual, entity 2 premium, downgrade both to pro, re-upgrade both")}`, async () => { - const customerId = "sched-switch-multi-interval-reupgrade"; - - const proMessages = items.monthlyMessages({ includedUsage: 100 }); - const pro = products.pro({ - id: "pro", - items: [proMessages], - }); - - const premiumMessages = items.monthlyMessages({ includedUsage: 500 }); - const premium = products.premium({ - id: "premium", - items: [premiumMessages], - }); - - const premiumAnnualMessages = items.monthlyMessages({ includedUsage: 500 }); - const premiumAnnualPrice = items.annualPrice({ price: 500 }); - // Use products.base for custom pricing - const premiumAnnual = products.base({ - id: "premium-annual", - items: [premiumAnnualMessages, premiumAnnualPrice], - }); - - const { autumnV1, entities, ctx, advancedTo } = await initScenario({ - customerId, - setup: [ - s.customer({ paymentMethod: "success" }), - s.products({ list: [pro, premium, premiumAnnual] }), - s.entities({ count: 2, featureId: TestFeature.Users }), - ], - actions: [ - s.billing.attach({ productId: premiumAnnual.id, entityIndex: 0 }), // Annual - s.billing.attach({ productId: premium.id, entityIndex: 1 }), // Monthly - ], - }); - - // Preview downgrade for entity 1 (annual -> pro) - const preview1 = await autumnV1.billing.previewAttach({ - customer_id: customerId, - product_id: pro.id, - entity_id: entities[0].id, - }); - expect(preview1.total).toBe(0); - expectPreviewNextCycleCorrect({ - preview: preview1, - startsAt: addYears(advancedTo, 1).getTime(), // Annual cycle - total: 20, - }); - - // Preview downgrade for entity 2 (monthly -> pro) - const preview2 = await autumnV1.billing.previewAttach({ - customer_id: customerId, - product_id: pro.id, - entity_id: entities[1].id, - }); - expect(preview2.total).toBe(0); - expectPreviewNextCycleCorrect({ - preview: preview2, - startsAt: addMonths(advancedTo, 1).getTime(), // Monthly cycle - total: 20, - }); - - // Schedule the downgrades - await autumnV1.billing.attach({ - customer_id: customerId, - product_id: pro.id, - entity_id: entities[0].id, - redirect_mode: "if_required", - }); - await autumnV1.billing.attach({ - customer_id: customerId, - product_id: pro.id, - entity_id: entities[1].id, - redirect_mode: "if_required", - }); - - // Verify scheduled states before re-upgrade - const entity1Before = await autumnV1.entities.get( - customerId, - entities[0].id, - ); - const entity2Before = await autumnV1.entities.get( - customerId, - entities[1].id, - ); - - await expectProductCanceling({ - customer: entity1Before, - productId: premiumAnnual.id, - }); - await expectProductScheduled({ - customer: entity1Before, - productId: pro.id, - }); - await expectProductCanceling({ - customer: entity2Before, - productId: premium.id, - }); - await expectProductScheduled({ - customer: entity2Before, - productId: pro.id, - }); - - // Verify Stripe subscription - await expectSubToBeCorrect({ - db: ctx.db, - customerId, - org: ctx.org, - env: ctx.env, - }); - - // Re-upgrade entity 1 back to premiumAnnual - await autumnV1.billing.attach({ - customer_id: customerId, - product_id: premiumAnnual.id, - entity_id: entities[0].id, - redirect_mode: "if_required", - }); - - // Re-upgrade entity 2 back to premium - await autumnV1.billing.attach({ - customer_id: customerId, - product_id: premium.id, - entity_id: entities[1].id, - redirect_mode: "if_required", - }); - - // Verify entity 1: premiumAnnual active, pro no longer scheduled - const entity1After = await autumnV1.entities.get( - customerId, - entities[0].id, - ); - await expectProductActive({ - customer: entity1After, - productId: premiumAnnual.id, - }); - await expectProductNotPresent({ - customer: entity1After, - productId: pro.id, - }); - - // Verify entity 2: premium active, pro no longer scheduled - const entity2After = await autumnV1.entities.get( - customerId, - entities[1].id, - ); - await expectProductActive({ - customer: entity2After, - productId: premium.id, - }); - await expectProductNotPresent({ - customer: entity2After, - productId: pro.id, - }); - - // Verify Stripe subscription after re-upgrade - await expectSubToBeCorrect({ - db: ctx.db, - customerId, - org: ctx.org, - env: ctx.env, - }); -}); - -// ═══════════════════════════════════════════════════════════════════════════════ -// TEST 3: Entity 1 premiumAnnual, entity 2 premium, downgrade both to pro, advance full year -// ═══════════════════════════════════════════════════════════════════════════════ - -/** - * Scenario: - * - Entity 1: Premium Annual ($500/year) - * - Entity 2: Premium Monthly ($50/mo) - * - Downgrade both to Pro (scheduled) - * - Advance a full year - * - * Expected Result: - * - Both entities now on pro (both annual and monthly cycles completed) - */ -test.concurrent(`${chalk.yellowBright("scheduled-switch-multi-interval 3: entity 1 premiumAnnual, entity 2 premium, downgrade both to pro, advance full year")}`, async () => { - const customerId = "sched-switch-multi-interval-fullyear"; - - const proMessages = items.monthlyMessages({ includedUsage: 100 }); - const pro = products.pro({ - id: "pro", - items: [proMessages], - }); - - const premiumMessages = items.monthlyMessages({ includedUsage: 500 }); - const premium = products.premium({ - id: "premium", - items: [premiumMessages], - }); - - const premiumAnnualMessages = items.monthlyMessages({ includedUsage: 500 }); - const premiumAnnualPrice = items.annualPrice({ price: 500 }); - // Use products.base for custom pricing - const premiumAnnual = products.base({ - id: "premium-annual", - items: [premiumAnnualMessages, premiumAnnualPrice], - }); - - const { autumnV1, entities, advancedTo } = await initScenario({ - customerId, - setup: [ - s.customer({ paymentMethod: "success" }), - s.products({ list: [pro, premium, premiumAnnual] }), - s.entities({ count: 2, featureId: TestFeature.Users }), - ], - actions: [ - s.billing.attach({ productId: premiumAnnual.id, entityIndex: 0 }), // Annual - s.billing.attach({ productId: premium.id, entityIndex: 1 }), // Monthly - ], - }); - - // Preview downgrade for entity 1 (annual -> pro) - const preview1 = await autumnV1.billing.previewAttach({ - customer_id: customerId, - product_id: pro.id, - entity_id: entities[0].id, - }); - expect(preview1.total).toBe(0); - expectPreviewNextCycleCorrect({ - preview: preview1, - startsAt: addYears(advancedTo, 1).getTime(), // Annual cycle - total: 20, - }); - - // Preview downgrade for entity 2 (monthly -> pro) - const preview2 = await autumnV1.billing.previewAttach({ - customer_id: customerId, - product_id: pro.id, - entity_id: entities[1].id, - }); - expect(preview2.total).toBe(0); - expectPreviewNextCycleCorrect({ - preview: preview2, - startsAt: addMonths(advancedTo, 1).getTime(), // Monthly cycle - total: 20, - }); - - // Schedule the downgrades and advance 12 months with fresh scenario - const { - autumnV1: autumnV1After, - ctx: ctxAfter, - entities: entitiesAfter, - } = await initScenario({ - customerId, - setup: [ - s.customer({ paymentMethod: "success" }), - s.products({ list: [pro, premium, premiumAnnual] }), - s.entities({ count: 2, featureId: TestFeature.Users }), - ], - actions: [ - s.billing.attach({ productId: premiumAnnual.id, entityIndex: 0 }), - s.billing.attach({ productId: premium.id, entityIndex: 1 }), - s.billing.attach({ productId: pro.id, entityIndex: 0 }), - s.billing.attach({ productId: pro.id, entityIndex: 1 }), - // Advance 12 months to complete annual cycle - s.advanceTestClock({ months: 12, waitForSeconds: 30 }), - ], - }); - - // Verify both entities now on pro - const entity1 = await autumnV1After.entities.get( - customerId, - entitiesAfter[0].id, - ); - const entity2 = await autumnV1After.entities.get( - customerId, - entitiesAfter[1].id, - ); - - // Entity 1: pro active, premiumAnnual removed (annual cycle completed) - await expectProductActive({ - customer: entity1, - productId: pro.id, - }); - await expectProductNotPresent({ - customer: entity1, - productId: premiumAnnual.id, - }); - - // Entity 2: pro active, premium removed (multiple monthly cycles completed) - await expectProductActive({ - customer: entity2, - productId: pro.id, - }); - await expectProductNotPresent({ - customer: entity2, - productId: premium.id, - }); - - // Features at pro tier for both - expectCustomerFeatureCorrect({ - customer: entity1, - featureId: TestFeature.Messages, - balance: 100, - usage: 0, - }); - expectCustomerFeatureCorrect({ - customer: entity2, - featureId: TestFeature.Messages, - balance: 100, - usage: 0, - }); - - // Verify Stripe subscription - await expectSubToBeCorrect({ - db: ctxAfter.db, - customerId, - org: ctxAfter.org, - env: ctxAfter.env, - }); -}); diff --git a/server/tests/integration/billing/attach/scheduled-switch/scheduled-switch-prepaid-no-options.test.ts b/server/tests/integration/billing/attach/scheduled-switch/scheduled-switch-prepaid-no-options.test.ts index 724b825bd..3c28d62e3 100644 --- a/server/tests/integration/billing/attach/scheduled-switch/scheduled-switch-prepaid-no-options.test.ts +++ b/server/tests/integration/billing/attach/scheduled-switch/scheduled-switch-prepaid-no-options.test.ts @@ -357,8 +357,9 @@ test.concurrent(`${chalk.yellowBright("scheduled-switch-prepaid-no-options 4: in s.billing.attach({ productId: premium.id, options: [{ feature_id: TestFeature.Messages, quantity: 200 }], + timeout: 2000, }), - s.billing.attach({ productId: pro.id }), // NO options + s.billing.attach({ productId: pro.id, timeout: 2000 }), // NO options s.advanceToNextInvoice(), ], }); @@ -439,8 +440,9 @@ test.concurrent(`${chalk.yellowBright("scheduled-switch-prepaid-no-options 5: al s.billing.attach({ productId: premium.id, options: [{ feature_id: TestFeature.Messages, quantity: 400 }], + timeout: 2000, }), - s.billing.attach({ productId: pro.id }), // NO options + s.billing.attach({ productId: pro.id, timeout: 2000 }), // NO options s.advanceToNextInvoice(), ], }); diff --git a/server/tests/integration/billing/attach/scheduled-switch/scheduled-switch-prepaid.test.ts b/server/tests/integration/billing/attach/scheduled-switch/scheduled-switch-prepaid.test.ts index 56a8feb2c..f48627273 100644 --- a/server/tests/integration/billing/attach/scheduled-switch/scheduled-switch-prepaid.test.ts +++ b/server/tests/integration/billing/attach/scheduled-switch/scheduled-switch-prepaid.test.ts @@ -19,14 +19,13 @@ import { expectProductCanceling, expectProductScheduled, } from "@tests/integration/billing/utils/expectCustomerProductCorrect"; -import { expectNoStripeSubscription } from "@tests/integration/billing/utils/expectNoStripeSubscription"; -import { expectPreviewNextCycleCorrect } from "@tests/integration/billing/utils/expectPreviewNextCycleCorrect"; import { expectSubToBeCorrect } from "@tests/merged/mergeUtils/expectSubCorrect"; import { TestFeature } from "@tests/setup/v2Features"; import { items } from "@tests/utils/fixtures/items"; import { products } from "@tests/utils/fixtures/products"; import { initScenario, s } from "@tests/utils/testInitUtils/initScenario"; import chalk from "chalk"; +import { timeout } from "@/utils/genUtils"; // ═══════════════════════════════════════════════════════════════════════════════ // TEST 1: Prepaid 5 packs to 2 packs (explicit options) @@ -73,6 +72,7 @@ test.concurrent(`${chalk.yellowBright("scheduled-switch-prepaid 1: 5 packs to 2 s.billing.attach({ productId: premium.id, options: [{ feature_id: TestFeature.Messages, quantity: 500 }], + timeout: 2000, }), ], }); @@ -185,6 +185,7 @@ test.concurrent(`${chalk.yellowBright("scheduled-switch-prepaid 2: no options pa s.billing.attach({ productId: premium.id, options: [{ feature_id: TestFeature.Messages, quantity: 500 }], + timeout: 2000, }), ], }); @@ -271,6 +272,7 @@ test.concurrent(`${chalk.yellowBright("scheduled-switch-prepaid 3: no options, d s.billing.attach({ productId: premium.id, options: [{ feature_id: TestFeature.Messages, quantity: 500 }], + timeout: 2000, }), ], }); @@ -341,6 +343,7 @@ test.concurrent(`${chalk.yellowBright("scheduled-switch-prepaid 4: to quantity 0 billingUnits: 100, price: 10, }); + const pro = products.pro({ id: "pro", items: [proPrepaid], @@ -356,6 +359,7 @@ test.concurrent(`${chalk.yellowBright("scheduled-switch-prepaid 4: to quantity 0 s.billing.attach({ productId: premium.id, options: [{ feature_id: TestFeature.Messages, quantity: 500 }], + timeout: 2000, }), ], }); @@ -628,10 +632,12 @@ test.concurrent(`${chalk.yellowBright("scheduled-switch-prepaid 7: included usag s.billing.attach({ productId: premium.id, options: [{ feature_id: TestFeature.Messages, quantity: 200 }], + timeout: 2000, }), s.billing.attach({ productId: pro.id, options: [{ feature_id: TestFeature.Messages, quantity: 200 }], + timeout: 2000, }), s.advanceToNextInvoice(), ], @@ -706,6 +712,7 @@ test.concurrent(`${chalk.yellowBright("scheduled-switch-prepaid 8: included usag s.billing.attach({ productId: premium.id, options: [{ feature_id: TestFeature.Messages, quantity: 200 }], + timeout: 2000, }), ], }); @@ -736,6 +743,8 @@ test.concurrent(`${chalk.yellowBright("scheduled-switch-prepaid 8: included usag redirect_mode: "if_required", }); + await timeout(2000); + const customer = await autumnV1.customers.get(customerId); // Verify states diff --git a/server/tests/integration/billing/attach/scheduled-switch/scheduled-switch-rollover.test.ts b/server/tests/integration/billing/attach/scheduled-switch/scheduled-switch-rollover.test.ts index e52510a32..d33f93807 100644 --- a/server/tests/integration/billing/attach/scheduled-switch/scheduled-switch-rollover.test.ts +++ b/server/tests/integration/billing/attach/scheduled-switch/scheduled-switch-rollover.test.ts @@ -82,10 +82,10 @@ test.concurrent(`${chalk.yellowBright("scheduled-switch-rollover 1: downgrade wi s.products({ list: [pro, free] }), ], actions: [ - s.billing.attach({ productId: pro.id }), + s.billing.attach({ productId: pro.id, timeout: 2000 }), s.track({ featureId: TestFeature.Messages, value: 300, timeout: 2000 }), s.resetFeature({ featureId: TestFeature.Messages }), // Creates rollover of 200 (500 - 300) - s.billing.attach({ productId: free.id }), // Schedule downgrade + s.billing.attach({ productId: free.id, timeout: 2000 }), // Schedule downgrade s.advanceToNextInvoice(), // Triggers downgrade at cycle end ], }); diff --git a/server/tests/utils/testAttachUtils/testAttachUtils.ts b/server/tests/utils/testAttachUtils/testAttachUtils.ts index ea9d0ff9d..e5406e5f6 100644 --- a/server/tests/utils/testAttachUtils/testAttachUtils.ts +++ b/server/tests/utils/testAttachUtils/testAttachUtils.ts @@ -168,7 +168,7 @@ export const advanceToNextInvoice = async ({ stripeCli, testClockId, advanceTo: addMonths(baseTime, 1).getTime(), - waitForSeconds: 30, + waitForSeconds: 45, }); await advanceTestClock({ diff --git a/shared/utils/cusProductUtils/convertCusProduct/cusProductToConvertedFeatureOptions.ts b/shared/utils/cusProductUtils/convertCusProduct/cusProductToConvertedFeatureOptions.ts index 5e57bdd5e..707f1f1f5 100644 --- a/shared/utils/cusProductUtils/convertCusProduct/cusProductToConvertedFeatureOptions.ts +++ b/shared/utils/cusProductUtils/convertCusProduct/cusProductToConvertedFeatureOptions.ts @@ -5,14 +5,14 @@ import type { import type { EntitlementWithFeature } from "@models/productModels/entModels/entModels"; import type { Price } from "@models/productModels/priceModels/priceModels"; import { roundUsageToNearestBillingUnit } from "@utils/billingUtils/usageUtils/roundUsageToNearestBillingUnit"; -import { cusPriceToCusEnt } from "@utils/cusPriceUtils"; import { findPrepaidCusPriceByFeature } from "@utils/cusPriceUtils/findCusPriceUtils/findPrepaidCusPriceByFeature"; import { nullish } from "@utils/utils"; import { Decimal } from "decimal.js"; import { cusProductToFeatureOptions } from "./cusProductToFeatureOptions"; /** - * Get the feature options from a customer product, converted to new price billing units + * Converts purchased packs from an old customer product to packs in new billing units. + * Allowance (included usage) is NOT factored in here — it's handled by getStartingBalance. */ export const cusProductToConvertedFeatureOptions = ({ cusProduct, @@ -36,15 +36,10 @@ export const cusProductToConvertedFeatureOptions = ({ // If no old price found, we can't interpret the stored quantity if (!oldCusPrice) return undefined; - const oldCustomerEntitlement = cusPriceToCusEnt({ - cusPrice: oldCusPrice, - cusEnts: cusProduct.customer_entitlements, - }); - const oldBillingUnits = oldCusPrice.price.config.billing_units ?? 1; const newBillingUnits = newPrice.config.billing_units ?? 1; - // 1. Multiply by old billing units to get actual quantity + // 1. Multiply by old billing units to get actual purchased quantity const actualQuantity = new Decimal(currentOption.quantity) .mul(oldBillingUnits) .toNumber(); @@ -55,31 +50,11 @@ export const cusProductToConvertedFeatureOptions = ({ billingUnits: newBillingUnits, }); - // 3. Add current allowance - const oldAllowance = oldCustomerEntitlement?.entitlement?.allowance ?? 0; - const quantityWithOldAllowance = new Decimal(roundedQuantity) - .add(oldAllowance) - .toNumber(); - - // 4. Subtract new allowance - const newAllowance = entitlement.allowance ?? 0; - const quantityWithoutNewAllowance = Math.max( - 0, - new Decimal(quantityWithOldAllowance).sub(newAllowance).toNumber(), - ); - - // 5. Round to nearest new billing unit - const roundedQuantityWithoutNewAllowance = roundUsageToNearestBillingUnit({ - usage: quantityWithoutNewAllowance, - billingUnits: newBillingUnits, - }); - - // 3. Divide by new billing units - const convertedQuantity = new Decimal(roundedQuantityWithoutNewAllowance) + // 3. Divide by new billing units to get packs + const convertedQuantity = new Decimal(roundedQuantity) .div(newBillingUnits) .toNumber(); - // Clamp to 0 minimum - if new allowance exceeds old total, no additional packs needed const finalQuantity = Math.max(0, convertedQuantity); return {