chore: transfer scheduled plans with main plan
This commit is contained in:
@@ -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",
|
||||
|
||||
4
server/src/external/autumn/autumnCli.ts
vendored
4
server/src/external/autumn/autumnCli.ts
vendored
@@ -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;
|
||||
},
|
||||
) {
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user