From adc7359b2ea00da674770375bba157b7620cae25 Mon Sep 17 00:00:00 2001 From: Charlie Lamb Date: Mon, 25 May 2026 17:18:47 +0100 Subject: [PATCH] chore: transfer scheduled plans with main plan --- knip.json | 5 +- server/src/external/autumn/autumnCli.ts | 4 +- .../transferRelatedCustomerProducts.ts | 111 +++++++++++++ .../handlers/handleTransferProductV2.ts | 67 ++++---- .../transfer-scheduled-products.test.ts | 154 ++++++++++++++++++ server/tests/utils/setup/setupOrg.ts | 2 +- 6 files changed, 300 insertions(+), 43 deletions(-) create mode 100644 server/src/internal/customers/handlers/handleTransferProduct/transferRelatedCustomerProducts.ts create mode 100644 server/tests/integration/billing/transfer/transfer-scheduled-products.test.ts diff --git a/knip.json b/knip.json index 74649a7a2..daa29e94d 100644 --- a/knip.json +++ b/knip.json @@ -11,7 +11,6 @@ "enumMembers", "duplicates" ], - "ignore": ["ai/**", "others/**", ".trigger/**"], "ignoreWorkspaces": [ "packages/atmn", "packages/autumn-js", @@ -20,12 +19,12 @@ ], "workspaces": { ".": { - "entry": ["apps/scope-picker/**/*", "trigger.config.ts"] + "entry": ["apps/scope-picker/**/*", "trigger.config.ts"], + "project": ["apps/scope-picker/**/*"] }, "server": { "entry": [ "src/internal/customers/cusUtils/createNewCustomer.ts", - "src/internal/rewards/RewardRedemptionService.ts", "src/utils/importUtils/addProductFromSubs.ts", "src/utils/scriptUtils/readOnlyStripe.ts", "src/utils/scriptUtils/scriptUtils.ts", diff --git a/server/src/external/autumn/autumnCli.ts b/server/src/external/autumn/autumnCli.ts index 89cef466e..10553fb46 100644 --- a/server/src/external/autumn/autumnCli.ts +++ b/server/src/external/autumn/autumnCli.ts @@ -345,8 +345,8 @@ export class AutumnInt { async transfer( customerId: string, params: { - from_entity_id?: string; - to_entity_id: string; + from_entity_id?: string | null; + to_entity_id?: string | null; product_id: string; }, ) { diff --git a/server/src/internal/customers/handlers/handleTransferProduct/transferRelatedCustomerProducts.ts b/server/src/internal/customers/handlers/handleTransferProduct/transferRelatedCustomerProducts.ts new file mode 100644 index 000000000..cb20ea15a --- /dev/null +++ b/server/src/internal/customers/handlers/handleTransferProduct/transferRelatedCustomerProducts.ts @@ -0,0 +1,111 @@ +import type { + Entity, + FullCusProduct, + FullCustomer, +} from "@autumn/shared"; +import type { AutumnContext } from "@/honoUtils/HonoEnv.js"; +import { nullish } from "@/utils/genUtils.js"; +import { CusProductService } from "../../cusProducts/CusProductService.js"; + +type TransferEntityUpdates = { + entity_id: string | null; + internal_entity_id: string | null; +}; + +type TransferProduct = { + id: string; + group: string | null; + is_add_on: boolean; +}; + +const matchesTransferSource = ({ + cusProduct, + fromEntity, +}: { + cusProduct: FullCusProduct; + fromEntity: Entity | null; +}) => + fromEntity + ? cusProduct.internal_entity_id === fromEntity.internal_id + : nullish(cusProduct.internal_entity_id); + +const matchesTransferProduct = ({ + cusProduct, + product, +}: { + cusProduct: FullCusProduct; + product: TransferProduct; +}) => + product.is_add_on + ? cusProduct.product_id === product.id + : cusProduct.product.group === product.group && + !cusProduct.product.is_add_on; + +export const findTransferCustomerProduct = ({ + fullCustomer, + fromEntity, + productId, +}: { + fullCustomer: FullCustomer; + fromEntity: Entity | null; + productId: string; +}) => + fullCustomer.customer_products.find( + (cusProduct) => + matchesTransferSource({ cusProduct, fromEntity }) && + cusProduct.product.id === productId, + ); + +export const findExistingTransferTargetProduct = ({ + fullCustomer, + toEntity, + product, +}: { + fullCustomer: FullCustomer; + toEntity: Entity | null; + product: TransferProduct; +}) => + fullCustomer.customer_products.find( + (cusProduct) => + matchesTransferProduct({ cusProduct, product }) && + (toEntity + ? cusProduct.internal_entity_id === toEntity.internal_id + : nullish(cusProduct.internal_entity_id)), + ); + +export const transferRelatedCustomerProducts = async ({ + ctx, + fullCustomer, + fromEntity, + toEntity, + product, +}: { + ctx: AutumnContext; + fullCustomer: FullCustomer; + fromEntity: Entity | null; + toEntity: Entity | null; + product: TransferProduct; +}): Promise => { + const updates = { + entity_id: toEntity?.id ?? null, + internal_entity_id: toEntity?.internal_id ?? null, + }; + + await Promise.all( + fullCustomer.customer_products + .filter( + (cusProduct) => + matchesTransferProduct({ cusProduct, product }) && + matchesTransferSource({ cusProduct, fromEntity }), + ) + .map((cusProduct) => + CusProductService.update({ + ctx, + cusProductId: cusProduct.id, + updates, + }), + ), + ); + + return updates; +}; diff --git a/server/src/internal/customers/handlers/handleTransferProductV2.ts b/server/src/internal/customers/handlers/handleTransferProductV2.ts index f4a4e19a6..b9838fe50 100644 --- a/server/src/internal/customers/handlers/handleTransferProductV2.ts +++ b/server/src/internal/customers/handlers/handleTransferProductV2.ts @@ -10,10 +10,13 @@ import { z } from "zod/v4"; import { createRoute } from "@/honoMiddlewares/routeHandler.js"; import { addProductsUpdatedWebhookTask } from "@/internal/analytics/handlers/handleProductsUpdated.js"; import { ProductService } from "@/internal/products/ProductService.js"; -import { nullish } from "@/utils/genUtils.js"; import { CusService } from "../CusService.js"; -import { CusProductService } from "../cusProducts/CusProductService.js"; import { handleDecreaseAndTransfer } from "./handleTransferProduct/handleDecreaseAndTransfer.js"; +import { + findExistingTransferTargetProduct, + findTransferCustomerProduct, + transferRelatedCustomerProducts, +} from "./handleTransferProduct/transferRelatedCustomerProducts.js"; const TransferProductSchema = z.object({ from_entity_id: z.string().nullish(), @@ -61,12 +64,11 @@ export const handleTransferProductV2 = createRoute({ }); } - const fromEntity = customer.entities.find( - (e: any) => e.id === from_entity_id, - ); + const fromEntity = + customer.entities.find((entity) => entity.id === from_entity_id) ?? null; const toEntity = to_entity_id - ? customer.entities.find((e: any) => e.id === to_entity_id) + ? (customer.entities.find((entity) => entity.id === to_entity_id) ?? null) : null; if (to_entity_id && !toEntity) { @@ -75,23 +77,24 @@ export const handleTransferProductV2 = createRoute({ }); } - const cusProduct = customer.customer_products.find( - (cp: any) => - (fromEntity - ? cp.internal_entity_id === fromEntity.internal_id - : nullish(cp.internal_entity_id)) && cp.product.id === product_id, - ); + const cusProduct = findTransferCustomerProduct({ + fullCustomer: customer, + fromEntity, + productId: product_id, + }); - const toCusProduct = customer.customer_products.find((cp: any) => { - const productMatch = cusProduct?.product.is_add_on - ? cp.product.product_id === product.id - : cp.product.group === product.group && !cp.product.is_add_on; + if (!cusProduct) { + throw new CusProductNotFoundError({ + customerId: customer_id, + productId: product_id, + entityId: from_entity_id || undefined, + }); + } - const entityMatch = toEntity?.internal_id - ? cp.internal_entity_id === toEntity.internal_id - : nullish(cp.internal_entity_id); - - return entityMatch && productMatch; + const toCusProduct = findExistingTransferTargetProduct({ + fullCustomer: customer, + toEntity, + product, }); if (toCusProduct) { @@ -102,14 +105,6 @@ export const handleTransferProductV2 = createRoute({ }); } - if (!cusProduct) { - throw new CusProductNotFoundError({ - customerId: customer_id, - productId: product_id, - entityId: from_entity_id || undefined, - }); - } - // 1. If cus product has quantity > 1, only transfer 1... if (cusProduct.quantity > 1) { await handleDecreaseAndTransfer({ @@ -119,13 +114,12 @@ export const handleTransferProductV2 = createRoute({ toEntity: toEntity, }); } else { - await CusProductService.update({ + const updates = await transferRelatedCustomerProducts({ ctx, - cusProductId: cusProduct.id, - updates: { - entity_id: toEntity?.id || null, - internal_entity_id: toEntity?.internal_id || null, - }, + fullCustomer: customer, + fromEntity, + toEntity, + product, }); await addProductsUpdatedWebhookTask({ @@ -137,8 +131,7 @@ export const handleTransferProductV2 = createRoute({ scenario: AttachScenario.New, cusProduct: { ...cusProduct, - entity_id: toEntity?.id || null, - internal_entity_id: toEntity?.internal_id || null, + ...updates, }, }); } diff --git a/server/tests/integration/billing/transfer/transfer-scheduled-products.test.ts b/server/tests/integration/billing/transfer/transfer-scheduled-products.test.ts new file mode 100644 index 000000000..6ea4b61b8 --- /dev/null +++ b/server/tests/integration/billing/transfer/transfer-scheduled-products.test.ts @@ -0,0 +1,154 @@ +import { expect, test } from "bun:test"; +import { CusProductStatus } from "@autumn/shared"; +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 { CusService } from "@/internal/customers/CusService"; + +const expectScopedProducts = async ({ + ctx, + customerId, + productIds, + internalEntityId, +}: { + ctx: Awaited>["ctx"]; + customerId: string; + productIds: string[]; + internalEntityId: string | null; +}) => { + const fullCustomer = await CusService.getFull({ + ctx, + idOrInternalId: customerId, + withEntities: true, + }); + + const scopedProducts = fullCustomer.customer_products + .filter((cusProduct) => productIds.includes(cusProduct.product.id)) + .map((cusProduct) => ({ + productId: cusProduct.product.id, + status: cusProduct.status, + internalEntityId: cusProduct.internal_entity_id, + })) + .sort((left, right) => left.productId.localeCompare(right.productId)); + + expect(scopedProducts).toEqual([ + { + productId: productIds[1], + status: CusProductStatus.Scheduled, + internalEntityId, + }, + { + productId: productIds[0], + status: CusProductStatus.Active, + internalEntityId, + }, + ]); +}; + +test.concurrent( + `${chalk.yellowBright("transfer: scheduled plan follows customer plan to entity")}`, + async () => { + const customerId = "transfer-scheduled-to-entity"; + + const pro = products.pro({ + id: "pro-to-entity", + items: [items.monthlyMessages({ includedUsage: 500 })], + }); + + const premium = products.premium({ + id: "premium-to-entity", + items: [items.monthlyMessages({ includedUsage: 1000 })], + }); + + const { autumnV1, ctx, entities } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [pro, premium] }), + s.entities({ count: 1, featureId: TestFeature.Users }), + ], + actions: [ + s.billing.attach({ productId: pro.id, timeout: 0 }), + s.billing.attach({ + productId: premium.id, + planSchedule: "end_of_cycle", + timeout: 0, + }), + ], + }); + + const entityId = entities[0].id; + await autumnV1.transfer(customerId, { + to_entity_id: entityId, + product_id: pro.id, + }); + + const fullCustomer = await CusService.getFull({ + ctx, + idOrInternalId: customerId, + withEntities: true, + }); + const targetEntity = fullCustomer.entities.find( + (entity) => entity.id === entityId, + ); + expect(targetEntity).toBeDefined(); + + await expectScopedProducts({ + ctx, + customerId, + productIds: [pro.id, premium.id], + internalEntityId: targetEntity!.internal_id, + }); + }, + 30000, +); + +test.concurrent( + `${chalk.yellowBright("transfer: scheduled plan follows entity plan to customer")}`, + async () => { + const customerId = "transfer-scheduled-to-customer"; + + const pro = products.pro({ + id: "pro-to-customer", + items: [items.monthlyMessages({ includedUsage: 500 })], + }); + + const premium = products.premium({ + id: "premium-to-customer", + items: [items.monthlyMessages({ includedUsage: 1000 })], + }); + + const { autumnV1, ctx, entities } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [pro, premium] }), + s.entities({ count: 1, featureId: TestFeature.Users }), + ], + actions: [ + s.billing.attach({ productId: pro.id, entityIndex: 0, timeout: 0 }), + s.billing.attach({ + productId: premium.id, + entityIndex: 0, + planSchedule: "end_of_cycle", + timeout: 0, + }), + ], + }); + + await autumnV1.transfer(customerId, { + from_entity_id: entities[0].id, + product_id: pro.id, + }); + + await expectScopedProducts({ + ctx, + customerId, + productIds: [pro.id, premium.id], + internalEntityId: null, + }); + }, + 30000, +); diff --git a/server/tests/utils/setup/setupOrg.ts b/server/tests/utils/setup/setupOrg.ts index 9a0e1f701..c9e5bde17 100644 --- a/server/tests/utils/setup/setupOrg.ts +++ b/server/tests/utils/setup/setupOrg.ts @@ -1,4 +1,4 @@ -import type { AppEnv } from "@autumn/shared"; +import { OrgConfigSchema, type AppEnv } from "@autumn/shared"; import { getFeatures } from "@tests/setup/v2Features.js"; import axios from "axios"; import { initDrizzle } from "@/db/initDrizzle";