updated add + delete tests

This commit is contained in:
johnyeo
2026-05-28 14:52:12 +01:00
parent 7f05f78940
commit 98423f5a88
10 changed files with 461 additions and 82 deletions

View File

@@ -21,7 +21,11 @@ export const computePatchCustomerProductPlan = ({
throw new Error("Patch context is required to compute patch customer plan");
}
const { finalCustomerProduct, customerProductUpdates } =
const {
finalCustomerProduct,
customerProductUpdates,
oneOffPrepaidCarryOverCustomerEntitlements,
} =
initPatchCustomerProduct({
ctx,
billingContext: updateSubscriptionContext,
@@ -43,6 +47,7 @@ export const computePatchCustomerProductPlan = ({
customEntitlements: patchContext.customEntitlements,
customFreeTrial: trialContext?.customFreeTrial,
lineItems: allLineItems,
insertCustomerEntitlements: oneOffPrepaidCarryOverCustomerEntitlements,
} satisfies Partial<AutumnBillingPlan>;
if (patchContext.mode === "new") {

View File

@@ -18,7 +18,6 @@ import type {
import { planItemFilterMatchesCustomerPair } from "@shared/api/products/items/utils/match";
import { cusEntToCusPrice } from "@shared/utils/cusEntUtils/convertCusEntUtils/cusEntToCusPrice";
import { customerPriceToCustomerEntitlement } from "@shared/utils/cusPriceUtils/convertCustomerPrice/customerPriceToCustomerEntitlement";
import { isOneOffPrice } from "@shared/utils/productUtils/priceUtils/classifyPriceUtils";
import { StatusCodes } from "http-status-codes";
import { generateId } from "@/utils/genUtils";
@@ -64,13 +63,11 @@ const assertAllowedIntervalUpdate = ({
customerPrice?: FullCustomerPrice;
overrides: UpdatePlanItemParamsV1;
}) => {
if (overrides.interval === undefined || overrides.interval === ResetInterval.OneOff)
return;
if (!customerPrice || !isOneOffPrice(customerPrice.price)) return;
if (overrides.interval === undefined || !customerPrice) return;
throw new RecaseError({
message:
"update_items cannot change intervals for one-off paid items. Use remove_items and add_items instead.",
"update_items cannot change intervals for paid items. Use remove_items and add_items instead.",
code: ErrCode.InvalidProductItem,
statusCode: StatusCodes.BAD_REQUEST,
});

View File

@@ -1,55 +1,21 @@
import {
EntInterval,
type FullCusEntWithFullCusProduct,
type FullCusProduct,
type FullCustomerEntitlement,
} from "@autumn/shared";
import { cusEntToCusPrice } from "@shared/utils/cusEntUtils/convertCusEntUtils/cusEntToCusPrice";
import { priceToBillingMethod } from "@shared/utils/productUtils/priceUtils/convertPriceUtils";
import type { FullCustomerEntitlement } from "@autumn/shared";
export type CustomerEntitlementCarryIdentity = {
internalFeatureId: string;
interval: string;
intervalCount: number;
entityFeatureId: string | null;
billingMethod: string | null;
};
export const carryIdentityToKey = (
identity: CustomerEntitlementCarryIdentity,
) =>
[
identity.internalFeatureId,
identity.interval,
identity.intervalCount,
identity.entityFeatureId ?? "",
identity.billingMethod ?? "",
].join(":");
) => identity.internalFeatureId;
export const customerEntitlementToCarryIdentity = ({
customerEntitlement,
customerProduct,
}: {
customerEntitlement: FullCustomerEntitlement;
customerProduct: FullCusProduct;
}): CustomerEntitlementCarryIdentity => {
const customerEntitlementWithProduct = {
...customerEntitlement,
customer_product: customerProduct,
} satisfies FullCusEntWithFullCusProduct;
const customerPrice = cusEntToCusPrice({
cusEnt: customerEntitlementWithProduct,
});
const entitlement = customerEntitlement.entitlement;
return {
internalFeatureId: entitlement.internal_feature_id,
interval:
customerPrice?.price.config.interval ??
entitlement.interval ??
EntInterval.Lifetime,
intervalCount: entitlement.interval_count ?? 1,
entityFeatureId: entitlement.entity_feature_id ?? null,
billingMethod: priceToBillingMethod({ price: customerPrice?.price }) ?? null,
};
};

View File

@@ -30,10 +30,8 @@ const addToGroup = <T>(groups: Map<string, T[]>, key: string, value: T) => {
};
const groupCustomerEntitlementsByCarryIdentity = ({
customerProduct,
customerEntitlements,
}: {
customerProduct: FullCusProduct;
customerEntitlements: FullCustomerEntitlement[];
}) => {
const customerEntitlementsByKey = new Map<
@@ -45,7 +43,6 @@ const groupCustomerEntitlementsByCarryIdentity = ({
const key = carryIdentityToKey(
customerEntitlementToCarryIdentity({
customerEntitlement,
customerProduct,
}),
);
addToGroup(customerEntitlementsByKey, key, customerEntitlement);
@@ -84,11 +81,9 @@ const getIdentityCustomerProductCarryGroups = ({
fromCustomerEntitlements: FullCustomerEntitlement[];
}): CustomerProductCarryGroup[] => {
const toEntitlementsByKey = groupCustomerEntitlementsByCarryIdentity({
customerProduct: toCustomerProduct,
customerEntitlements: toCustomerProduct.customer_entitlements,
});
const fromEntitlementsByKey = groupCustomerEntitlementsByCarryIdentity({
customerProduct: fromCustomerProduct,
customerEntitlements: fromCustomerEntitlements,
});

View File

@@ -1,6 +1,7 @@
import {
type AutumnBillingPlan,
cusProductToProduct,
type InsertCustomerEntitlement,
type PatchContext,
type TrialContext,
type UpdateSubscriptionBillingContext,
@@ -68,8 +69,14 @@ export const initPatchCustomerProduct = ({
}): {
finalCustomerProduct: PatchContext["finalCustomerProduct"];
customerProductUpdates: CustomerProductUpdates;
oneOffPrepaidCarryOverCustomerEntitlements: InsertCustomerEntitlement[];
} => {
const { customerPrices, customerEntitlements } =
const {
customerPrices,
customerEntitlements,
oneOffPrepaidCarryOverEntitlements,
oneOffPrepaidCarryOverCustomerEntitlements,
} =
initPatchedCustomerEntitlementsAndPrices({
ctx,
billingContext,
@@ -95,6 +102,7 @@ export const initPatchCustomerProduct = ({
});
patchContext.insertCustomerPrices = customerPrices;
patchContext.insertCustomerEntitlements = customerEntitlements;
patchContext.customEntitlements.push(...oneOffPrepaidCarryOverEntitlements);
patchContext.fullProduct = cusProductToProduct({
cusProduct: patchContext.finalCustomerProduct,
});
@@ -116,5 +124,6 @@ export const initPatchCustomerProduct = ({
...trialUpdates,
...customUpdates,
},
oneOffPrepaidCarryOverCustomerEntitlements,
};
};

View File

@@ -1,6 +1,8 @@
import type {
Entitlement,
FullCustomerEntitlement,
FullCustomerPrice,
InsertCustomerEntitlement,
PatchContext,
UpdateSubscriptionBillingContext,
} from "@autumn/shared";
@@ -33,6 +35,8 @@ export const initPatchedCustomerEntitlementsAndPrices = ({
}): {
customerPrices: FullCustomerPrice[];
customerEntitlements: FullCustomerEntitlement[];
oneOffPrepaidCarryOverEntitlements: Entitlement[];
oneOffPrepaidCarryOverCustomerEntitlements: InsertCustomerEntitlement[];
} => {
const {
fullCustomer,
@@ -115,6 +119,9 @@ export const initPatchedCustomerEntitlementsAndPrices = ({
return { fromCustomerEntitlement, toCustomerEntitlement };
}),
});
const oneOffPrepaidCarryOverEntitlements: Entitlement[] = [];
const oneOffPrepaidCarryOverCustomerEntitlements: InsertCustomerEntitlement[] =
[];
for (const carryGroup of carryGroups) {
applyExistingStatesToCustomerProduct({
@@ -132,15 +139,23 @@ export const initPatchedCustomerEntitlementsAndPrices = ({
},
});
applyOneOffPrepaidCarryOvers({
const oneOffPrepaidCarryOvers = applyOneOffPrepaidCarryOvers({
oldCustomerProduct: carryGroup.fromCustomerProduct,
newCustomerProduct: carryGroup.toCustomerProduct,
fullCustomer,
});
oneOffPrepaidCarryOverEntitlements.push(
...oneOffPrepaidCarryOvers.entitlements,
);
oneOffPrepaidCarryOverCustomerEntitlements.push(
...oneOffPrepaidCarryOvers.customerEntitlements,
);
}
return {
customerPrices: customerProductWithNewItemsOnly.customer_prices,
customerEntitlements: customerProductWithNewItemsOnly.customer_entitlements,
oneOffPrepaidCarryOverEntitlements,
oneOffPrepaidCarryOverCustomerEntitlements,
};
};

View File

@@ -0,0 +1,314 @@
/**
* Contract: delete/add patch migrations carry same-feature usage, one-off prepaid balance, and reset anchors.
* These scenarios intentionally avoid update_items; item changes are remove_items + add_items.
*/
import { expect, test } from "bun:test";
import type { ApiCustomerV3, ApiCustomerV5 } from "@autumn/shared";
import {
BillingMethod,
ResetInterval,
} from "@autumn/shared";
import { expectCustomerInvoiceCorrect } from "@tests/integration/billing/utils/expectCustomerInvoiceCorrect";
import { expectBalanceCorrect } from "@tests/integration/utils/expectBalanceCorrect";
import { getBalanceBucket } from "@tests/integration/utils/getBalanceBucket";
import { TestFeature } from "@tests/setup/v2Features";
import { items } from "@tests/utils/fixtures/items";
import { itemsV2 } from "@tests/utils/fixtures/itemsV2";
import { products } from "@tests/utils/fixtures/products";
import { initScenario, s } from "@tests/utils/testInitUtils/initScenario";
import chalk from "chalk";
import { runUpdatePlanMigration } from "../../utils/runUpdatePlanMigration";
const TEN_MINUTES_MS = 10 * 60 * 1000;
const expectCloseToMs = ({
actual,
expected,
}: {
actual?: number | null;
expected: number;
}) => {
expect(actual).not.toBeNull();
expect(Math.abs((actual ?? 0) - expected)).toBeLessThanOrEqual(
TEN_MINUTES_MS,
);
};
test.concurrent(`${chalk.yellowBright("migrations complex delete/add: lifetime item to monthly carries usage onto subscription reset")}`, async () => {
const customerId = "migration-complex-lifetime-to-monthly";
const pro = products.pro({
id: "migration-complex-lifetime-to-monthly-plan",
items: [items.lifetimeMessages({ includedUsage: 100 })],
});
const { autumnV1, autumnV2_2, ctx } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [pro] }),
],
actions: [
s.billing.attach({ productId: pro.id }),
s.advanceTestClock({ days: 10 }),
s.track({ featureId: TestFeature.Messages, value: 40, timeout: 2000 }),
],
});
const before = await autumnV2_2.customers.get<ApiCustomerV5>(customerId);
const currentPeriodEnd = before.subscriptions.find(
(subscription) => subscription.plan_id === pro.id,
)?.current_period_end;
expect(currentPeriodEnd).not.toBeNull();
const invoiceCountBefore =
(await autumnV1.customers.get<ApiCustomerV3>(customerId)).invoices?.length ??
0;
await runUpdatePlanMigration({
ctx,
migrationClient: autumnV2_2,
migrationId: `${customerId}-mig`,
customerId,
filter: { customer: { plan: { plan_id: pro.id } } },
operations: {
customer: [
{
type: "update_plan",
plan_filter: { plan_id: pro.id },
customize: {
remove_items: [{ feature_id: TestFeature.Messages }],
add_items: [itemsV2.monthlyMessages({ included: 150 })],
},
},
],
},
runOnServer: false,
noBillingChanges: true,
});
const customer = await autumnV2_2.customers.get<ApiCustomerV5>(customerId);
expectBalanceCorrect({
customer,
featureId: TestFeature.Messages,
remaining: 110,
usage: 40,
nextResetAt: currentPeriodEnd!,
planId: pro.id,
breakdown: {
[ResetInterval.Month]: {
included_grant: 150,
remaining: 110,
usage: 40,
},
},
});
const monthlyBucket = getBalanceBucket({
subject: customer,
featureId: TestFeature.Messages,
resetInterval: ResetInterval.Month,
});
expectCloseToMs({
actual: monthlyBucket.reset?.resets_at,
expected: currentPeriodEnd!,
});
await expectCustomerInvoiceCorrect({
customer: await autumnV1.customers.get<ApiCustomerV3>(customerId),
count: invoiceCountBefore,
});
});
test.concurrent(`${chalk.yellowBright("migrations complex delete/add: monthly plus one-off prepaid to monthly carries usage and lifetime balance")}`, async () => {
const customerId = "migration-complex-monthly-oneoff-to-monthly";
const pro = products.pro({
id: "migration-complex-monthly-oneoff-to-monthly-plan",
items: [
items.monthlyMessages({ includedUsage: 100 }),
items.oneOffMessages({ includedUsage: 0, billingUnits: 100, price: 10 }),
],
});
const { autumnV1, autumnV2_2, ctx } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [pro] }),
],
actions: [
s.attach({
productId: pro.id,
options: [{ feature_id: TestFeature.Messages, quantity: 200 }],
}),
s.track({ featureId: TestFeature.Messages, value: 150, timeout: 2000 }),
],
});
const before = await autumnV2_2.customers.get<ApiCustomerV5>(customerId);
const currentPeriodEnd = before.subscriptions.find(
(subscription) => subscription.plan_id === pro.id,
)?.current_period_end;
expect(currentPeriodEnd).not.toBeNull();
const invoiceCountBefore =
(await autumnV1.customers.get<ApiCustomerV3>(customerId)).invoices?.length ??
0;
await runUpdatePlanMigration({
ctx,
migrationClient: autumnV2_2,
migrationId: `${customerId}-mig`,
customerId,
filter: { customer: { plan: { plan_id: pro.id } } },
operations: {
customer: [
{
type: "update_plan",
plan_filter: { plan_id: pro.id },
customize: {
remove_items: [{ feature_id: TestFeature.Messages }],
add_items: [itemsV2.monthlyMessages({ included: 300 })],
},
},
],
},
runOnServer: false,
noBillingChanges: true,
});
const customer = await autumnV2_2.customers.get<ApiCustomerV5>(customerId);
expectBalanceCorrect({
customer,
featureId: TestFeature.Messages,
remaining: 350,
usage: 100,
nextResetAt: currentPeriodEnd!,
planId: pro.id,
breakdown: {
[ResetInterval.Month]: {
included_grant: 300,
remaining: 200,
usage: 100,
},
[ResetInterval.OneOff]: {
included_grant: 150,
prepaid_grant: 0,
remaining: 150,
usage: 0,
},
},
});
const monthlyBucket = getBalanceBucket({
subject: customer,
featureId: TestFeature.Messages,
resetInterval: ResetInterval.Month,
});
expectCloseToMs({
actual: monthlyBucket.reset?.resets_at,
expected: currentPeriodEnd!,
});
await expectCustomerInvoiceCorrect({
customer: await autumnV1.customers.get<ApiCustomerV3>(customerId),
count: invoiceCountBefore,
});
});
test.concurrent(`${chalk.yellowBright("migrations complex delete/add: monthly included increase plus one-off price change preserves both buckets")}`, async () => {
const customerId = "migration-complex-monthly-oneoff-price-change";
const pro = products.pro({
id: "migration-complex-monthly-oneoff-price-change-plan",
items: [
items.monthlyMessages({ includedUsage: 100 }),
items.oneOffMessages({ includedUsage: 0, billingUnits: 100, price: 10 }),
],
});
const { autumnV1, autumnV2_2, ctx } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [pro] }),
],
actions: [
s.attach({
productId: pro.id,
options: [{ feature_id: TestFeature.Messages, quantity: 200 }],
}),
s.track({ featureId: TestFeature.Messages, value: 150, timeout: 2000 }),
],
});
const before = await autumnV2_2.customers.get<ApiCustomerV5>(customerId);
const currentPeriodEnd = before.subscriptions.find(
(subscription) => subscription.plan_id === pro.id,
)?.current_period_end;
expect(currentPeriodEnd).not.toBeNull();
const invoiceCountBefore =
(await autumnV1.customers.get<ApiCustomerV3>(customerId)).invoices?.length ??
0;
await runUpdatePlanMigration({
ctx,
migrationClient: autumnV2_2,
migrationId: `${customerId}-mig`,
customerId,
filter: { customer: { plan: { plan_id: pro.id } } },
operations: {
customer: [
{
type: "update_plan",
plan_filter: { plan_id: pro.id },
customize: {
remove_items: [{ feature_id: TestFeature.Messages }],
add_items: [
itemsV2.monthlyMessages({ included: 300 }),
itemsV2.oneOffPrepaidMessages({
amount: 15,
billingUnits: 100,
}),
],
},
},
],
},
runOnServer: false,
noBillingChanges: true,
});
const customer = await autumnV2_2.customers.get<ApiCustomerV5>(customerId);
expectBalanceCorrect({
customer,
featureId: TestFeature.Messages,
remaining: 350,
usage: 100,
nextResetAt: currentPeriodEnd!,
planId: pro.id,
breakdown: {
[ResetInterval.Month]: {
included_grant: 300,
remaining: 200,
usage: 100,
},
[BillingMethod.Prepaid]: {
included_grant: 150,
prepaid_grant: 0,
remaining: 150,
usage: 0,
},
},
});
const monthlyBucket = getBalanceBucket({
subject: customer,
featureId: TestFeature.Messages,
resetInterval: ResetInterval.Month,
});
const prepaidBucket = getBalanceBucket({
subject: customer,
featureId: TestFeature.Messages,
billingMethod: BillingMethod.Prepaid,
});
expectCloseToMs({
actual: monthlyBucket.reset?.resets_at,
expected: currentPeriodEnd!,
});
expect(prepaidBucket.reset?.interval).toBe(ResetInterval.OneOff);
expect(prepaidBucket.price?.amount).toBe(15);
await expectCustomerInvoiceCorrect({
customer: await autumnV1.customers.get<ApiCustomerV3>(customerId),
count: invoiceCountBefore,
});
});

View File

@@ -6,6 +6,7 @@ import {
} from "@autumn/shared";
import { expectBalanceCorrect } from "@tests/integration/utils/expectBalanceCorrect";
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";
@@ -83,6 +84,77 @@ test.concurrent(`${chalk.yellowBright("migrations update_items interval: subscri
});
});
test.concurrent(`${chalk.yellowBright("migrations update_items interval: monthly paid item interval changes are rejected")}`, async () => {
const customerId = "migration-update-items-monthly-paid-rejected";
const base = products.base({
id: "migration-update-items-monthly-paid-rejected-plan",
items: [
items.prepaid({
featureId: TestFeature.Credits,
includedUsage: 100,
billingUnits: 100,
price: 10,
}),
items.consumableMessages({ includedUsage: 50, price: 0.1 }),
],
});
const { autumnV2_2, ctx } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [base] }),
],
actions: [s.billing.attach({ productId: base.id })],
});
const cases = [
{
name: "prepaid",
filter: {
feature_id: TestFeature.Credits,
billing_method: BillingMethod.Prepaid,
},
},
{
name: "usage-based",
filter: {
feature_id: TestFeature.Messages,
billing_method: BillingMethod.UsageBased,
},
},
];
for (const testCase of cases) {
await expect(
runUpdatePlanMigration({
ctx,
migrationClient: autumnV2_2,
migrationId: `${customerId}-${testCase.name}-mig`,
customerId,
filter: { customer: { plan: { plan_id: base.id } } },
operations: {
customer: [
{
type: "update_plan",
plan_filter: { plan_id: base.id },
customize: {
update_items: [
{
filter: testCase.filter,
interval: ResetInterval.OneOff,
},
],
},
},
],
},
runOnServer: false,
}),
).rejects.toThrow(/paid items/i);
}
});
test.concurrent(`${chalk.yellowBright("migrations update_items interval: one-off prepaid interval changes are rejected")}`, async () => {
const customerId = "migration-update-items-one-off-prepaid-rejected";
const oneOffPrepaid = constructPrepaidItem({
@@ -134,5 +206,5 @@ test.concurrent(`${chalk.yellowBright("migrations update_items interval: one-off
},
runOnServer: false,
}),
).rejects.toThrow(/one-off paid/i);
).rejects.toThrow(/paid items/i);
});

View File

@@ -19,12 +19,13 @@ export const UpdatePlanItemParamsV1Schema = z
description:
"Override the matched item's reset interval. Use 'one_off' for non-resetting balances.",
}),
})
.meta({
title: "UpdatePlanItem",
description:
"Patch an existing plan item in place. Supports included and interval changes.",
});
})
.meta({
title: "UpdatePlanItem",
description:
"Deprecated. Use remove_items and add_items to replace plan items.",
deprecated: true,
});
export type UpdatePlanItemParamsV1 = z.infer<typeof UpdatePlanItemParamsV1Schema>;
@@ -34,21 +35,22 @@ export const CustomizePlanV1Schema = z
description:
"Override the base price of the plan. Pass null to remove the base price.",
}),
items: z.array(CreatePlanItemParamsV1Schema).optional().meta({
description:
"Override the items in the plan (PUT-style — replaces all existing items). Mutually exclusive with add_items / remove_items / update_items.",
}),
items: z.array(CreatePlanItemParamsV1Schema).optional().meta({
description:
"Override the items in the plan (PUT-style — replaces all existing items). Mutually exclusive with add_items / remove_items / deprecated update_items.",
}),
add_items: z.array(CreatePlanItemParamsV1Schema).optional().meta({
description: "Items to add to the plan.",
}),
remove_items: z.array(PlanItemFilterSchema).optional().meta({
description: "Filters selecting items to remove from the plan.",
}),
update_items: z.array(UpdatePlanItemParamsV1Schema).optional().meta({
description:
"Patch existing matched plan items. Runs before add_items, after remove_items.",
internal: true,
}),
update_items: z.array(UpdatePlanItemParamsV1Schema).optional().meta({
description:
"Deprecated. Use remove_items and add_items to replace matched plan items.",
internal: true,
deprecated: true,
}),
free_trial: FreeTrialParamsV1Schema.nullable().optional().meta({
description:
"Override the plan's default free trial. Pass an object to set a custom trial, or null to remove the trial entirely.",
@@ -62,10 +64,10 @@ export const CustomizePlanV1Schema = z
data.add_items !== undefined ||
data.remove_items !== undefined ||
data.update_items !== undefined,
{
message:
"When using customize, at least one of price, items, add_items, remove_items, update_items, or free_trial must be provided",
},
{
message:
"When using customize, at least one of price, items, add_items, remove_items, deprecated update_items, or free_trial must be provided",
},
)
.refine(
(data) =>
@@ -75,10 +77,10 @@ export const CustomizePlanV1Schema = z
data.remove_items !== undefined ||
data.update_items !== undefined)
),
{
message:
"customize.items (PUT-style) cannot be combined with add_items / remove_items / update_items (PATCH-style); pick one approach",
},
{
message:
"customize.items (PUT-style) cannot be combined with add_items / remove_items / deprecated update_items (PATCH-style); pick one approach",
},
)
.meta({
title: "CustomizePlan",

View File

@@ -7,10 +7,14 @@ import { PlanFilterSchema } from "../../../filters/planFilter.js";
export const MigrationUpdatePlanCustomizeSchema = z
.object({
price: BasePriceParamsSchema.nullable().optional(),
add_items: z.array(CreatePlanItemParamsV1Schema).optional(),
remove_items: z.array(PlanItemFilterSchema).optional(),
update_items: z.array(UpdatePlanItemParamsV1Schema).optional(),
price: BasePriceParamsSchema.nullable().optional(),
add_items: z.array(CreatePlanItemParamsV1Schema).optional(),
remove_items: z.array(PlanItemFilterSchema).optional(),
update_items: z.array(UpdatePlanItemParamsV1Schema).optional().meta({
description:
"Deprecated. Use remove_items and add_items to replace matched plan items.",
deprecated: true,
}),
})
.refine(
(data) =>
@@ -18,11 +22,11 @@ export const MigrationUpdatePlanCustomizeSchema = z
data.add_items !== undefined ||
data.remove_items !== undefined ||
data.update_items !== undefined,
{
message:
"update_plan.customize requires at least one of price, add_items, remove_items, or update_items",
},
);
{
message:
"update_plan.customize requires at least one of price, add_items, remove_items, or deprecated update_items",
},
);
/**
* Ordered customer operation: update every customer product matched by