chore: transfer scheduled plans with main plan

This commit is contained in:
Charlie Lamb
2026-05-25 17:18:47 +01:00
parent 409afb3e7d
commit adc7359b2e
6 changed files with 300 additions and 43 deletions

View File

@@ -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",

View File

@@ -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;
},
) {

View File

@@ -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<TransferEntityUpdates> => {
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;
};

View File

@@ -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,
},
});
}

View File

@@ -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<ReturnType<typeof initScenario>>["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,
);

View File

@@ -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";