diff --git a/server/src/internal/billing/v2/setup/setupFeatureQuantitiesContext.ts b/server/src/internal/billing/v2/setup/setupFeatureQuantitiesContext.ts index 4d8eee01f..295767e43 100644 --- a/server/src/internal/billing/v2/setup/setupFeatureQuantitiesContext.ts +++ b/server/src/internal/billing/v2/setup/setupFeatureQuantitiesContext.ts @@ -3,6 +3,7 @@ import { type FeatureOptions, type FullCusProduct, type FullProduct, + formatPrice, isPrepaidPrice, priceToFeature, type UpdateSubscriptionV0Params, @@ -27,6 +28,16 @@ export const setupFeatureQuantitiesContext = ({ }): FeatureOptions[] => { const options: FeatureOptions[] = []; + console.log( + "Full product prices:", + fullProduct.prices.map((price) => formatPrice({ price })), + ); + + console.log( + "Current customer product options:", + currentCustomerProduct?.options, + ); + for (const price of fullProduct.prices) { if (!isPrepaidPrice(price)) continue; diff --git a/server/src/internal/billing/v2/updateSubscription/compute/computeUpdateSubscriptionIntent.ts b/server/src/internal/billing/v2/updateSubscription/compute/computeUpdateSubscriptionIntent.ts index cec50a970..a29528138 100644 --- a/server/src/internal/billing/v2/updateSubscription/compute/computeUpdateSubscriptionIntent.ts +++ b/server/src/internal/billing/v2/updateSubscription/compute/computeUpdateSubscriptionIntent.ts @@ -1,4 +1,5 @@ import type { UpdateSubscriptionV0Params } from "@shared/index"; +import { notNullish } from "@/utils/genUtils"; export enum UpdateSubscriptionIntent { UpdateQuantity = "update_quantity", @@ -11,11 +12,13 @@ export enum UpdateSubscriptionIntent { export const computeUpdateSubscriptionIntent = ( params: UpdateSubscriptionV0Params, ): UpdateSubscriptionIntent => { + const itemsChanged = notNullish(params.items); + const versionChanged = notNullish(params.version); + const freeTrialChanged = params.free_trial !== undefined; + + if (itemsChanged || versionChanged || freeTrialChanged) + return UpdateSubscriptionIntent.UpdatePlan; + // Version change = plan update (takes priority) - if (params.version !== undefined) return UpdateSubscriptionIntent.UpdatePlan; - - if (params.options?.length && !params.items?.length) - return UpdateSubscriptionIntent.UpdateQuantity; - - return UpdateSubscriptionIntent.UpdatePlan; + return UpdateSubscriptionIntent.UpdateQuantity; }; diff --git a/server/src/internal/billing/v2/updateSubscription/handlePreviewUpdateSubscription.ts b/server/src/internal/billing/v2/updateSubscription/handlePreviewUpdateSubscription.ts index ccca7bdcd..84b4cc3df 100644 --- a/server/src/internal/billing/v2/updateSubscription/handlePreviewUpdateSubscription.ts +++ b/server/src/internal/billing/v2/updateSubscription/handlePreviewUpdateSubscription.ts @@ -1,8 +1,11 @@ import { UpdateSubscriptionV0ParamsSchema } from "@autumn/shared"; import { evaluateStripeBillingPlan } from "@/internal/billing/v2/providers/stripe/actionBuilders/evaluateStripeBillingPlan"; +import { logStripeBillingPlan } from "@/internal/billing/v2/providers/stripe/logs/logStripeBillingPlan"; import { billingPlanToPreviewResponse } from "@/internal/billing/v2/utils/billingPlanToPreviewResponse"; import { createRoute } from "../../../../honoMiddlewares/routeHandler"; import { computeUpdateSubscriptionPlan } from "./compute/computeUpdateSubscriptionPlan"; +import { logUpdateSubscriptionContext } from "./logs/logUpdateSubscriptionContext"; +import { logUpdateSubscriptionPlan } from "./logs/logUpdateSubscriptionPlan"; import { setupUpdateSubscriptionBillingContext } from "./setup/setupUpdateSubscriptionBillingContext"; export const handlePreviewUpdateSubscription = createRoute({ @@ -11,23 +14,41 @@ export const handlePreviewUpdateSubscription = createRoute({ const ctx = c.get("ctx"); const body = c.req.valid("json"); + ctx.logger.info( + `=============== RUNNING PREVIEW UPDATE SUBSCRIPTION FOR ${body.customer_id} ===============`, + ); + const updateSubscriptionBillingContext = await setupUpdateSubscriptionBillingContext({ ctx, params: body, }); + logUpdateSubscriptionContext({ + ctx, + billingContext: updateSubscriptionBillingContext, + }); const autumnBillingPlan = await computeUpdateSubscriptionPlan({ ctx, billingContext: updateSubscriptionBillingContext, params: body, }); + logUpdateSubscriptionPlan({ + ctx, + plan: autumnBillingPlan, + billingContext: updateSubscriptionBillingContext, + }); const stripeBillingPlan = await evaluateStripeBillingPlan({ ctx, billingContext: updateSubscriptionBillingContext, autumnBillingPlan, }); + logStripeBillingPlan({ + ctx, + stripeBillingPlan, + billingContext: updateSubscriptionBillingContext, + }); const previewResponse = billingPlanToPreviewResponse({ ctx, diff --git a/server/tests/integration/billing/update-subscription/free-trial/update-trial-paid-features.test.ts b/server/tests/integration/billing/update-subscription/free-trial/update-trial-paid-features.test.ts index 250e249ff..0c111144d 100644 --- a/server/tests/integration/billing/update-subscription/free-trial/update-trial-paid-features.test.ts +++ b/server/tests/integration/billing/update-subscription/free-trial/update-trial-paid-features.test.ts @@ -20,11 +20,11 @@ import chalk from "chalk"; * Tests for scenarios involving products with prepaid/paid features and trial transitions. */ -// 1. Pro product with prepaid users -> add trial -> remove trial -test.concurrent(`${chalk.yellowBright("trial-paid-features: prepaid users add trial then remove")}`, async () => { +// 1. Pro product with prepaid users (0 seats) -> add trial -> update quantity to 5 seats +test.concurrent(`${chalk.yellowBright("trial-paid-features: add trial then update quantity")}`, async () => { const messagesItem = items.monthlyMessages({ includedUsage: 100 }); const priceItem = items.monthlyPrice({ price: 20 }); - const prepaidUsersItem = items.prepaidUsers({ includedUsage: 5 }); + const prepaidUsersItem = items.prepaidUsers({ includedUsage: 0 }); // $10/seat const pro = products.base({ id: "pro", @@ -32,7 +32,7 @@ test.concurrent(`${chalk.yellowBright("trial-paid-features: prepaid users add tr }); const { customerId, autumnV1, ctx, advancedTo } = await initScenario({ - customerId: "trial-prepaid-add-remove", + customerId: "trial-prepaid-add-update-qty", setup: [ s.customer({ testClock: true, paymentMethod: "success" }), s.products({ list: [pro] }), @@ -40,14 +40,12 @@ test.concurrent(`${chalk.yellowBright("trial-paid-features: prepaid users add tr actions: [ s.attach({ productId: pro.id, - options: [{ feature_id: TestFeature.Users, quantity: 3 }], + options: [{ feature_id: TestFeature.Users, quantity: 0 }], }), ], }); - return; - - // Verify initial state - NOT trialing, has users + // Verify initial state - NOT trialing, 0 seats const customerBefore = await autumnV1.customers.get(customerId); await expectProductNotTrialing({ @@ -55,12 +53,12 @@ test.concurrent(`${chalk.yellowBright("trial-paid-features: prepaid users add tr productId: pro.id, }); - // Prepaid users: 5 included + 3 purchased = 8 total + // Prepaid users: 0 included + 0 purchased = 0 total expectCustomerFeatureCorrect({ customer: customerBefore, featureId: TestFeature.Users, - includedUsage: 5, - balance: 8, + includedUsage: 0, + balance: 0, usage: 0, }); @@ -79,10 +77,10 @@ test.concurrent(`${chalk.yellowBright("trial-paid-features: prepaid users add tr const addTrialPreview = await autumnV1.subscriptions.previewUpdate(addTrialParams); - // Should refund previous payment since entering trial - expect(addTrialPreview.total).toBeLessThanOrEqual(0); + // Should refund previous payment since entering trial (base price only, 0 seats) + expect(addTrialPreview.total).toEqual(-priceItem.price!); - // next_cycle should show when trial ends + // next_cycle should show when trial ends (base price only, 0 seats) expectPreviewNextCycleCorrect({ preview: addTrialPreview, startsAt: advancedTo + ms.days(14), @@ -91,6 +89,8 @@ test.concurrent(`${chalk.yellowBright("trial-paid-features: prepaid users add tr await autumnV1.subscriptions.update(addTrialParams); + return; + const customerWithTrial = await autumnV1.customers.get(customerId); @@ -101,12 +101,12 @@ test.concurrent(`${chalk.yellowBright("trial-paid-features: prepaid users add tr trialEndsAt: advancedTo + ms.days(14), }); - // Users should still be accessible + // Users should still be 0 expectCustomerFeatureCorrect({ customer: customerWithTrial, featureId: TestFeature.Users, - includedUsage: 5, - balance: 8, + includedUsage: 0, + balance: 0, usage: 0, }); @@ -119,54 +119,60 @@ test.concurrent(`${chalk.yellowBright("trial-paid-features: prepaid users add tr usage: 0, }); - // Step 3: Update plan to remove the trial - const removeTrialParams = { + // Step 3: Update quantity to 5 seats while trialing + const seatsQuantity = 5; + const seatsPrice = seatsQuantity * 10; // $10/seat = $50 + + const updateQuantityParams = { customer_id: customerId, product_id: pro.id, - free_trial: null, + options: [{ feature_id: TestFeature.Users, quantity: seatsQuantity }], }; - const removeTrialPreview = - await autumnV1.subscriptions.previewUpdate(removeTrialParams); + const updateQuantityPreview = + await autumnV1.subscriptions.previewUpdate(updateQuantityParams); - // Should charge full price since trial is being removed - expect(removeTrialPreview.total).toEqual(priceItem.price); + // Should be $0 during trial (seats are free during trial) + expect(updateQuantityPreview.total).toEqual(0); - // When trial is removed, next_cycle should not be defined (billing starts now) + // next_cycle should show when trial ends (base price + 5 seats) expectPreviewNextCycleCorrect({ - preview: removeTrialPreview, - expectDefined: false, + preview: updateQuantityPreview, + startsAt: advancedTo + ms.days(14), + total: priceItem.price! + seatsPrice, }); - await autumnV1.subscriptions.update(removeTrialParams, { timeout: 5000 }); + await autumnV1.subscriptions.update(updateQuantityParams); + return; - const customerAfterRemove = + const customerAfterQuantityUpdate = await autumnV1.customers.get(customerId); - // Product should no longer be trialing - await expectProductNotTrialing({ - customer: customerAfterRemove, + // Product should still be trialing + await expectProductTrialing({ + customer: customerAfterQuantityUpdate, productId: pro.id, + trialEndsAt: advancedTo + ms.days(14), }); - // Should now be active (not trialing) + // Should now be active (trialing) await expectProductActive({ - customer: customerAfterRemove, + customer: customerAfterQuantityUpdate, productId: pro.id, }); - // Users should still be accessible with same balance + // Users should now have 5 seats expectCustomerFeatureCorrect({ - customer: customerAfterRemove, + customer: customerAfterQuantityUpdate, featureId: TestFeature.Users, - includedUsage: 5, - balance: 8, + includedUsage: 0, + balance: seatsQuantity, usage: 0, }); // Messages should still be accessible expectCustomerFeatureCorrect({ - customer: customerAfterRemove, + customer: customerAfterQuantityUpdate, featureId: TestFeature.Messages, includedUsage: messagesItem.included_usage, balance: messagesItem.included_usage, @@ -178,8 +184,5 @@ test.concurrent(`${chalk.yellowBright("trial-paid-features: prepaid users add tr customerId, org: ctx.org, env: ctx.env, - flags: { - checkNotTrialing: true, - }, }); }); diff --git a/server/tests/unit/billing/compute-feature-quantities/compute-subscription-update-feature-quantities.test.ts b/server/tests/unit/billing/setup-feature-quantities/setup-feature-quantities-context.spec.ts similarity index 94% rename from server/tests/unit/billing/compute-feature-quantities/compute-subscription-update-feature-quantities.test.ts rename to server/tests/unit/billing/setup-feature-quantities/setup-feature-quantities-context.spec.ts index 028d9ca49..d1526f73f 100644 --- a/server/tests/unit/billing/compute-feature-quantities/compute-subscription-update-feature-quantities.test.ts +++ b/server/tests/unit/billing/setup-feature-quantities/setup-feature-quantities-context.spec.ts @@ -735,5 +735,48 @@ describe(chalk.yellowBright("setupFeatureQuantitiesContext"), () => { expect(result).toHaveLength(1); expect(result[0].quantity).toBe(75); }); + + test("current has options with quantity 0, new params has no options → carries over 0", () => { + const feature = features.create({ + id: "seats", + name: "Seats", + }); + + const price = prices.createPrepaid({ + id: "price_seats", + featureId: "seats", + }); + + const fullProduct = products.createFull({ prices: [price] }); + const cusProduct = customerProducts.create({ + options: [ + { + feature_id: "seats", + internal_feature_id: "internal_seats", + quantity: 0, + }, + ], + customerPrices: [prices.createCustomer({ price })], + }); + + const params: UpdateSubscriptionV0Params = { + customer_id: "cus_test", + product_id: "prod_test", + // No options provided - should carry over from current + }; + + const ctx = contexts.create({ features: [feature] }); + + const result = setupFeatureQuantitiesContext({ + ctx, + featureQuantitiesParams: params, + fullProduct, + currentCustomerProduct: cusProduct, + }); + + expect(result).toHaveLength(1); + expect(result[0].feature_id).toBe("seats"); + expect(result[0].quantity).toBe(0); + }); }); }); diff --git a/shared/utils/cusProductUtils/convertCusProduct/cusProductToConvertedFeatureOptions.ts b/shared/utils/cusProductUtils/convertCusProduct/cusProductToConvertedFeatureOptions.ts index 39d8e405e..51a7dbc2c 100644 --- a/shared/utils/cusProductUtils/convertCusProduct/cusProductToConvertedFeatureOptions.ts +++ b/shared/utils/cusProductUtils/convertCusProduct/cusProductToConvertedFeatureOptions.ts @@ -6,6 +6,7 @@ import type { Feature } from "@models/featureModels/featureModels"; import type { Price } from "@models/productModels/priceModels/priceModels"; import { roundUsageToNearestBillingUnit } from "@utils/billingUtils/usageUtils/roundUsageToNearestBillingUnit"; import { findPrepaidCusPriceByFeature } from "@utils/cusPriceUtils/findCusPriceUtils/findPrepaidCusPriceByFeature"; +import { nullish } from "@utils/utils"; import { Decimal } from "decimal.js"; import { cusProductToFeatureOptions } from "./cusProductToFeatureOptions"; @@ -23,7 +24,7 @@ export const cusProductToConvertedFeatureOptions = ({ }): FeatureOptions | undefined => { const currentOption = cusProductToFeatureOptions({ cusProduct, feature }); - if (!currentOption?.quantity) return undefined; + if (nullish(currentOption?.quantity)) return undefined; const oldCusPrice = findPrepaidCusPriceByFeature({ customerPrices: cusProduct.customer_prices,