From 27810193eb862150bef07fbc1959b82b53a8a0f8 Mon Sep 17 00:00:00 2001 From: John Yeo Date: Thu, 15 Jan 2026 19:15:32 +0000 Subject: [PATCH] fix: logic in billingPlanToNextCyclePreview not using updated customer product --- scripts/testGroups/update-subscription.sh | 7 +- .../buildSharedSubscriptionTrialLineItems.ts | 7 +- .../autumnBillingPlanToFinalFullCustomer.ts | 11 +- .../billingPlanToNextCyclePreview.ts | 12 +- .../billingPlanToUpdatedCustomerProduct.ts | 17 ++ .../handlers/handlePlanHasCustomersV2.ts | 49 +++++ server/src/internal/products/productRouter.ts | 6 +- .../custom-plan/update-one-off.test.ts | 2 +- .../update-trial-paid-features.test.ts | 185 ++++++++++++++++++ 9 files changed, 279 insertions(+), 17 deletions(-) create mode 100644 server/src/internal/billing/v2/utils/billingPlan/billingPlanToUpdatedCustomerProduct.ts create mode 100644 server/src/internal/products/handlers/handlePlanHasCustomersV2.ts create mode 100644 server/tests/integration/billing/update-subscription/free-trial/update-trial-paid-features.test.ts diff --git a/scripts/testGroups/update-subscription.sh b/scripts/testGroups/update-subscription.sh index 1fe72f391..a42893792 100755 --- a/scripts/testGroups/update-subscription.sh +++ b/scripts/testGroups/update-subscription.sh @@ -3,8 +3,11 @@ # Source shared configuration source "$(dirname "$0")/config.sh" -bun test:integration update-subscription/custom-plan -bun test:integration update-subscription/discounts +# Exit immediately if a command exits with a non-zero status +set -e + +# bun test:integration update-subscription/custom-plan +# bun test:integration update-subscription/discounts bun test:integration update-subscription/errors bun test:integration update-subscription/free-trial bun test:integration update-subscription/invoice diff --git a/server/src/internal/billing/v2/compute/computeAutumnUtils/buildSharedSubscriptionTrialLineItems.ts b/server/src/internal/billing/v2/compute/computeAutumnUtils/buildSharedSubscriptionTrialLineItems.ts index 9463c57c4..c6f2f5cc6 100644 --- a/server/src/internal/billing/v2/compute/computeAutumnUtils/buildSharedSubscriptionTrialLineItems.ts +++ b/server/src/internal/billing/v2/compute/computeAutumnUtils/buildSharedSubscriptionTrialLineItems.ts @@ -5,6 +5,7 @@ import type { AutumnContext } from "@/honoUtils/HonoEnv"; import type { UpdateSubscriptionBillingContext } from "@/internal/billing/v2/billingContext"; import type { AutumnBillingPlan } from "@/internal/billing/v2/types/autumnBillingPlan"; import { getTrialStateTransition } from "@/internal/billing/v2/utils/billingContext/getTrialStateTransition"; +import { billingPlanToUpdatedCustomerProduct } from "@/internal/billing/v2/utils/billingPlan/billingPlanToUpdatedCustomerProduct"; import { customerProductToLineItems } from "@/internal/billing/v2/utils/lineItems/customerProductToLineItems"; const formatLineItem = (item: LineItem) => ({ @@ -69,9 +70,13 @@ const getSiblingCustomerProducts = ({ autumnBillingPlan: AutumnBillingPlan; stripeSubscriptionId: string; }): FullCusProduct[] => { + const updatedCustomerProduct = billingPlanToUpdatedCustomerProduct({ + autumnBillingPlan, + }); + const handledIds = new Set([ ...autumnBillingPlan.insertCustomerProducts.map((cp) => cp.id), - autumnBillingPlan.updateCustomerProduct?.customerProduct.id, + updatedCustomerProduct?.id, autumnBillingPlan.deleteCustomerProduct?.id, ]); diff --git a/server/src/internal/billing/v2/utils/autumnBillingPlanToFinalFullCustomer.ts b/server/src/internal/billing/v2/utils/autumnBillingPlanToFinalFullCustomer.ts index 5350e6bf3..2e2ad3d68 100644 --- a/server/src/internal/billing/v2/utils/autumnBillingPlanToFinalFullCustomer.ts +++ b/server/src/internal/billing/v2/utils/autumnBillingPlanToFinalFullCustomer.ts @@ -1,5 +1,6 @@ import type { BillingContext } from "@/internal/billing/v2/billingContext"; import type { AutumnBillingPlan } from "@/internal/billing/v2/types/billingPlan"; +import { billingPlanToUpdatedCustomerProduct } from "@/internal/billing/v2/utils/billingPlan/billingPlanToUpdatedCustomerProduct"; export const autumnBillingPlanToFinalFullCustomer = ({ billingContext, @@ -9,7 +10,6 @@ export const autumnBillingPlanToFinalFullCustomer = ({ autumnBillingPlan: AutumnBillingPlan; }) => { const { - updateCustomerProduct, deleteCustomerProduct, insertCustomerProducts, updateCustomerEntitlements, @@ -24,12 +24,9 @@ export const autumnBillingPlanToFinalFullCustomer = ({ ]; // 2. Replace updated customer product if applicable - const updatedCustomerProduct = updateCustomerProduct - ? { - ...updateCustomerProduct.customerProduct, - ...updateCustomerProduct.updates, - } - : undefined; + const updatedCustomerProduct = billingPlanToUpdatedCustomerProduct({ + autumnBillingPlan, + }); let customerProducts = combinedCustomerProducts.map((customerProduct) => customerProduct.id === updatedCustomerProduct?.id diff --git a/server/src/internal/billing/v2/utils/billingPlan/billingPlanToNextCyclePreview.ts b/server/src/internal/billing/v2/utils/billingPlan/billingPlanToNextCyclePreview.ts index 26d6f3c89..657df5372 100644 --- a/server/src/internal/billing/v2/utils/billingPlan/billingPlanToNextCyclePreview.ts +++ b/server/src/internal/billing/v2/utils/billingPlan/billingPlanToNextCyclePreview.ts @@ -9,6 +9,7 @@ import { import type { AutumnContext } from "@/honoUtils/HonoEnv"; import type { BillingContext } from "@/internal/billing/v2/billingContext"; import type { BillingPlan } from "@/internal/billing/v2/types/billingPlan"; +import { billingPlanToUpdatedCustomerProduct } from "@/internal/billing/v2/utils/billingPlan/billingPlanToUpdatedCustomerProduct"; import { customerProductToLineItems } from "../lineItems/customerProductToLineItems"; export const billingPlanToNextCyclePreview = ({ @@ -24,15 +25,16 @@ export const billingPlanToNextCyclePreview = ({ const { billingCycleAnchorMs } = billingContext; if (billingCycleAnchorMs === "now") return undefined; - const { - insertCustomerProducts, - updateCustomerProduct: { customerProduct: targetUpdateCustomerProduct }, - } = billingPlan.autumn; + + const updatedCustomerProduct = billingPlanToUpdatedCustomerProduct({ + autumnBillingPlan: billingPlan.autumn, + }); + const { insertCustomerProducts } = billingPlan.autumn; // 2. Get cycle end and if none, return undefined const allCustomerProducts = [ ...insertCustomerProducts, - ...(targetUpdateCustomerProduct ? [targetUpdateCustomerProduct] : []), + ...(updatedCustomerProduct ? [updatedCustomerProduct] : []), ]; const customerProducts = allCustomerProducts.filter( (customerProduct) => diff --git a/server/src/internal/billing/v2/utils/billingPlan/billingPlanToUpdatedCustomerProduct.ts b/server/src/internal/billing/v2/utils/billingPlan/billingPlanToUpdatedCustomerProduct.ts new file mode 100644 index 000000000..7c54c6745 --- /dev/null +++ b/server/src/internal/billing/v2/utils/billingPlan/billingPlanToUpdatedCustomerProduct.ts @@ -0,0 +1,17 @@ +import type { FullCusProduct } from "@autumn/shared"; +import type { AutumnBillingPlan } from "@/internal/billing/v2/types/autumnBillingPlan"; + +export const billingPlanToUpdatedCustomerProduct = ({ + autumnBillingPlan, +}: { + autumnBillingPlan: AutumnBillingPlan; +}): FullCusProduct | undefined => { + const { updateCustomerProduct } = autumnBillingPlan; + + if (!updateCustomerProduct) return undefined; + + return { + ...updateCustomerProduct.customerProduct, + ...updateCustomerProduct.updates, + }; +}; diff --git a/server/src/internal/products/handlers/handlePlanHasCustomersV2.ts b/server/src/internal/products/handlers/handlePlanHasCustomersV2.ts new file mode 100644 index 000000000..a74ede361 --- /dev/null +++ b/server/src/internal/products/handlers/handlePlanHasCustomersV2.ts @@ -0,0 +1,49 @@ +import { + ProductNotFoundError, + type ProductV2, + productsAreSame, +} from "@autumn/shared"; +import { createRoute } from "@/honoMiddlewares/routeHandler"; +import { CusProductService } from "@/internal/customers/cusProducts/CusProductService"; +import { ProductService } from "@/internal/products/ProductService"; + +export const handlePlanHasCustomersV2 = createRoute({ + handler: async (c) => { + const { product_id } = c.req.param(); + const ctx = c.get("ctx"); + const { db, features, org, env } = ctx; + + const body = await c.req.json(); + + const product = await ProductService.getFull({ + db, + idOrInternalId: product_id, + orgId: org.id, + env: env, + }); + + if (!product) { + throw new ProductNotFoundError({ productId: product_id }); + } + + const cusProductsCurVersion = + await CusProductService.getByInternalProductId({ + db, + internalProductId: product.internal_id, + }); + + const { itemsSame, freeTrialsSame } = productsAreSame({ + newProductV2: body as ProductV2, + curProductV1: product, + features, + }); + + const productSame = itemsSame && freeTrialsSame; + + return c.json({ + current_version: product.version, + will_version: !productSame && cusProductsCurVersion.length > 0, + archived: product.archived, + }); + }, +}); diff --git a/server/src/internal/products/productRouter.ts b/server/src/internal/products/productRouter.ts index 7573ee339..46df6f435 100644 --- a/server/src/internal/products/productRouter.ts +++ b/server/src/internal/products/productRouter.ts @@ -1,6 +1,7 @@ import express from "express"; import { Hono } from "hono"; import type { HonoEnv } from "@/honoUtils/HonoEnv.js"; +import { handlePlanHasCustomersV2 } from "@/internal/products/handlers/handlePlanHasCustomersV2.js"; import { handleCopyProductV2 } from "./handlers/handleCopyProduct/handleCopyProductV2.js"; import { handleCreatePlan } from "./handlers/handleCreatePlan.js"; import { handleDeleteProduct as handleDeleteProductHono } from "./handlers/handleDeleteProduct.js"; @@ -36,5 +37,8 @@ honoProductRouter.delete("/:product_id", ...handleDeleteProductHono); honoProductRouter.post("/:product_id/copy", ...handleCopyProductV2); // Info before deleting plan -// honoProductRouter.get("/:product_id/has_customers", ...handlePlanHasCustomers); +honoProductRouter.post( + "/:product_id/has_customers", + ...handlePlanHasCustomersV2, +); honoProductRouter.get("/:product_id/deletion_info", ...handleGetPlanDeleteInfo); diff --git a/server/tests/integration/billing/update-subscription/custom-plan/update-one-off.test.ts b/server/tests/integration/billing/update-subscription/custom-plan/update-one-off.test.ts index b06c24416..0cb524b53 100644 --- a/server/tests/integration/billing/update-subscription/custom-plan/update-one-off.test.ts +++ b/server/tests/integration/billing/update-subscription/custom-plan/update-one-off.test.ts @@ -41,7 +41,7 @@ test.concurrent(`${chalk.yellowBright("one-off: update included usage on free me s.customer({ paymentMethod: "success" }), s.products({ list: [oneOffProduct] }), ], - actions: [s.attach({ productId: oneOffProduct.id })], + actions: [s.attach({ productId: oneOffProduct.id, timeout: 3000 })], }); // Track some usage 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 new file mode 100644 index 000000000..250e249ff --- /dev/null +++ b/server/tests/integration/billing/update-subscription/free-trial/update-trial-paid-features.test.ts @@ -0,0 +1,185 @@ +import { expect, test } from "bun:test"; +import { type ApiCustomerV3, FreeTrialDuration, ms } from "@autumn/shared"; +import { expectCustomerFeatureCorrect } from "@tests/integration/billing/utils/expectCustomerFeatureCorrect"; +import { expectProductActive } from "@tests/integration/billing/utils/expectCustomerProductCorrect"; +import { + expectProductNotTrialing, + expectProductTrialing, +} from "@tests/integration/billing/utils/expectCustomerProductTrialing"; +import { expectPreviewNextCycleCorrect } from "@tests/integration/billing/utils/expectPreviewNextCycleCorrect"; +import { expectSubToBeCorrect } from "@tests/merged/mergeUtils/expectSubCorrect"; +import { TestFeature } from "@tests/setup/v2Features.js"; +import { items } from "@tests/utils/fixtures/items.js"; +import { products } from "@tests/utils/fixtures/products.js"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js"; +import chalk from "chalk"; + +/** + * Update Trial with Paid Features Tests + * + * 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 () => { + const messagesItem = items.monthlyMessages({ includedUsage: 100 }); + const priceItem = items.monthlyPrice({ price: 20 }); + const prepaidUsersItem = items.prepaidUsers({ includedUsage: 5 }); + + const pro = products.base({ + id: "pro", + items: [messagesItem, priceItem, prepaidUsersItem], + }); + + const { customerId, autumnV1, ctx, advancedTo } = await initScenario({ + customerId: "trial-prepaid-add-remove", + setup: [ + s.customer({ testClock: true, paymentMethod: "success" }), + s.products({ list: [pro] }), + ], + actions: [ + s.attach({ + productId: pro.id, + options: [{ feature_id: TestFeature.Users, quantity: 3 }], + }), + ], + }); + + return; + + // Verify initial state - NOT trialing, has users + const customerBefore = + await autumnV1.customers.get(customerId); + await expectProductNotTrialing({ + customer: customerBefore, + productId: pro.id, + }); + + // Prepaid users: 5 included + 3 purchased = 8 total + expectCustomerFeatureCorrect({ + customer: customerBefore, + featureId: TestFeature.Users, + includedUsage: 5, + balance: 8, + usage: 0, + }); + + // Step 2: Update plan to start a free trial + const addTrialParams = { + customer_id: customerId, + product_id: pro.id, + free_trial: { + length: 14, + duration: FreeTrialDuration.Day, + card_required: true, + unique_fingerprint: false, + }, + }; + + const addTrialPreview = + await autumnV1.subscriptions.previewUpdate(addTrialParams); + + // Should refund previous payment since entering trial + expect(addTrialPreview.total).toBeLessThanOrEqual(0); + + // next_cycle should show when trial ends + expectPreviewNextCycleCorrect({ + preview: addTrialPreview, + startsAt: advancedTo + ms.days(14), + total: priceItem.price!, + }); + + await autumnV1.subscriptions.update(addTrialParams); + + const customerWithTrial = + await autumnV1.customers.get(customerId); + + // Product should now be trialing + await expectProductTrialing({ + customer: customerWithTrial, + productId: pro.id, + trialEndsAt: advancedTo + ms.days(14), + }); + + // Users should still be accessible + expectCustomerFeatureCorrect({ + customer: customerWithTrial, + featureId: TestFeature.Users, + includedUsage: 5, + balance: 8, + usage: 0, + }); + + // Messages should still be accessible + expectCustomerFeatureCorrect({ + customer: customerWithTrial, + featureId: TestFeature.Messages, + includedUsage: messagesItem.included_usage, + balance: messagesItem.included_usage, + usage: 0, + }); + + // Step 3: Update plan to remove the trial + const removeTrialParams = { + customer_id: customerId, + product_id: pro.id, + free_trial: null, + }; + + const removeTrialPreview = + await autumnV1.subscriptions.previewUpdate(removeTrialParams); + + // Should charge full price since trial is being removed + expect(removeTrialPreview.total).toEqual(priceItem.price); + + // When trial is removed, next_cycle should not be defined (billing starts now) + expectPreviewNextCycleCorrect({ + preview: removeTrialPreview, + expectDefined: false, + }); + + await autumnV1.subscriptions.update(removeTrialParams, { timeout: 5000 }); + + const customerAfterRemove = + await autumnV1.customers.get(customerId); + + // Product should no longer be trialing + await expectProductNotTrialing({ + customer: customerAfterRemove, + productId: pro.id, + }); + + // Should now be active (not trialing) + await expectProductActive({ + customer: customerAfterRemove, + productId: pro.id, + }); + + // Users should still be accessible with same balance + expectCustomerFeatureCorrect({ + customer: customerAfterRemove, + featureId: TestFeature.Users, + includedUsage: 5, + balance: 8, + usage: 0, + }); + + // Messages should still be accessible + expectCustomerFeatureCorrect({ + customer: customerAfterRemove, + featureId: TestFeature.Messages, + includedUsage: messagesItem.included_usage, + balance: messagesItem.included_usage, + usage: 0, + }); + + await expectSubToBeCorrect({ + db: ctx.db, + customerId, + org: ctx.org, + env: ctx.env, + flags: { + checkNotTrialing: true, + }, + }); +});