added update items to update plan op, starting with included

This commit is contained in:
johnyeo
2026-05-26 18:36:01 +01:00
parent 7e668616a3
commit 2010ba9ffb
11 changed files with 1445 additions and 13 deletions

View File

@@ -0,0 +1,186 @@
import type {
CustomizePlanV1,
Entitlement,
Feature,
FullCusEntWithFullCusProduct,
FullCusProduct,
FullCustomerEntitlement,
FullCustomerPrice,
Price,
UpdatePlanItemParamsV1,
} from "@autumn/shared";
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 { generateId } from "@/utils/genUtils";
type CustomerProductItemPair = {
customerPrice?: FullCustomerPrice;
customerEntitlement?: FullCustomerEntitlement;
};
const getCustomerProductItemPairs = ({
targetCustomerProduct,
}: {
targetCustomerProduct: FullCusProduct;
}): CustomerProductItemPair[] => {
const pairs: CustomerProductItemPair[] =
targetCustomerProduct.customer_prices.map((customerPrice) => ({
customerPrice,
customerEntitlement: customerPriceToCustomerEntitlement({
customerPrice,
customerEntitlements: targetCustomerProduct.customer_entitlements,
}),
}));
for (const customerEntitlement of targetCustomerProduct.customer_entitlements) {
const customerPrice = cusEntToCusPrice({
cusEnt: {
...customerEntitlement,
customer_product: targetCustomerProduct,
} satisfies FullCusEntWithFullCusProduct,
});
if (!customerPrice) {
pairs.push({ customerEntitlement });
}
}
return pairs;
};
const applyOverridesToEntitlement = ({
source,
overrides,
}: {
source: Entitlement;
overrides: UpdatePlanItemParamsV1;
}): Entitlement => ({
...source,
id: generateId("ent"),
is_custom: true,
created_at: Date.now(),
allowance:
overrides.included !== undefined ? overrides.included : source.allowance,
});
const applyOverridesToPrice = ({
source,
newEntitlementId,
}: {
source: Price;
newEntitlementId: string;
}): Price => ({
...source,
id: generateId("pr"),
is_custom: true,
created_at: Date.now(),
entitlement_id: newEntitlementId,
});
/**
* Patch existing items in place. For each `update_items[i]`, find matching
* customer-entitlement / customer-price pairs on the target customer product,
* clone the underlying entitlement (and price, if any) with the overrides
* applied, and emit them as delete + add buckets. Existing usage and rollovers
* carry forward via the shared patch carry plumbing.
*/
export const handleCustomizeUpdateItems = ({
customize,
targetCustomerProduct,
features: _features,
}: {
customize: CustomizePlanV1;
targetCustomerProduct: FullCusProduct;
features: Feature[];
}): {
customerPrices: FullCustomerPrice[];
customerEntitlements: FullCustomerEntitlement[];
prices: Price[];
entitlements: Entitlement[];
} => {
const updateItems = customize.update_items ?? [];
if (updateItems.length === 0) {
return {
customerPrices: [],
customerEntitlements: [],
prices: [],
entitlements: [],
};
}
const deleteCustomerPriceIds = new Set<string>();
const deleteCustomerEntitlementIds = new Set<string>();
const deleteCustomerPrices: FullCustomerPrice[] = [];
const deleteCustomerEntitlements: FullCustomerEntitlement[] = [];
const newPrices: Price[] = [];
const newEntitlements: Entitlement[] = [];
const pairs = getCustomerProductItemPairs({ targetCustomerProduct });
for (const update of updateItems) {
const matchedPairs = pairs.filter((pair) =>
planItemFilterMatchesCustomerPair({
filter: update.filter,
customerPrice: pair.customerPrice,
customerEntitlement: pair.customerEntitlement,
}),
);
for (const pair of matchedPairs) {
if (!pair.customerEntitlement) continue;
if (deleteCustomerEntitlementIds.has(pair.customerEntitlement.id))
continue;
const newEntitlement = applyOverridesToEntitlement({
source: pair.customerEntitlement.entitlement,
overrides: update,
});
newEntitlements.push(newEntitlement);
deleteCustomerEntitlements.push(pair.customerEntitlement);
deleteCustomerEntitlementIds.add(pair.customerEntitlement.id);
if (pair.customerPrice && !deleteCustomerPriceIds.has(pair.customerPrice.id)) {
const newPrice = applyOverridesToPrice({
source: pair.customerPrice.price,
newEntitlementId: newEntitlement.id,
});
newPrices.push(newPrice);
deleteCustomerPrices.push(pair.customerPrice);
deleteCustomerPriceIds.add(pair.customerPrice.id);
}
}
}
// Also remove any sibling customer_prices whose price is linked to an
// entitlement being deleted — keeps `targetCustomerProduct` consistent.
for (const customerPrice of targetCustomerProduct.customer_prices) {
if (deleteCustomerPriceIds.has(customerPrice.id)) continue;
const entitlementId = customerPrice.price.entitlement_id;
if (!entitlementId) continue;
const matchesDeletedEnt = deleteCustomerEntitlements.some(
(deleted) => deleted.entitlement.id === entitlementId,
);
if (matchesDeletedEnt) {
deleteCustomerPrices.push(customerPrice);
deleteCustomerPriceIds.add(customerPrice.id);
}
}
targetCustomerProduct.customer_entitlements =
targetCustomerProduct.customer_entitlements.filter(
(customerEntitlement) =>
!deleteCustomerEntitlementIds.has(customerEntitlement.id),
);
targetCustomerProduct.customer_prices =
targetCustomerProduct.customer_prices.filter(
(customerPrice) => !deleteCustomerPriceIds.has(customerPrice.id),
);
return {
customerPrices: deleteCustomerPrices,
customerEntitlements: deleteCustomerEntitlements,
prices: newPrices,
entitlements: newEntitlements,
};
};

View File

@@ -14,6 +14,7 @@ import { handleCustomizeAddItems } from "./handleCustomizeAddItems";
import { handleCustomizeDeleteItems } from "./handleCustomizeDeleteItems";
import { handleCustomizeNoopItems } from "./handleCustomizeNoopItems";
import { handleCustomizePrice } from "./handleCustomizePrice";
import { handleCustomizeUpdateItems } from "./handleCustomizeUpdateItems";
import type { ReusePricesAndEntitlements } from "./types";
const applyProductDefinitionToCustomerProduct = ({
@@ -123,10 +124,33 @@ export const setupPatchContext = ({
targetCustomerProduct: finalCustomerProduct,
});
const {
customerPrices: updateDeleteCustomerPrices,
customerEntitlements: updateDeleteCustomerEntitlements,
prices: updateNewPrices,
entitlements: updateNewEntitlements,
} = handleCustomizeUpdateItems({
customize: params.customize ?? {},
targetCustomerProduct: finalCustomerProduct,
features: ctx.features,
});
const patchFullProduct = cusProductToProduct({
cusProduct: finalCustomerProduct,
});
// Surface update_items' new entitlements / prices on the patched product
// snapshot so downstream consumers (initPatchedCustomerEntitlementsAndPrices,
// add_items noop check) see the updated shape.
for (const newEnt of updateNewEntitlements) {
const feature = ctx.features.find(
(candidate) => candidate.internal_id === newEnt.internal_feature_id,
);
if (!feature) continue;
patchFullProduct.entitlements.push({ ...newEnt, feature });
}
patchFullProduct.prices.push(...updateNewPrices);
const {
customerPrices: deletePriceCustomerPrices,
prices: customPricePrices,
@@ -161,11 +185,19 @@ export const setupPatchContext = ({
insertCustomerEntitlements: [],
deleteCustomerPrices: uniqueCustomerPrices([
...deleteCustomerPrices,
...updateDeleteCustomerPrices,
...deletePriceCustomerPrices,
]),
deleteCustomerEntitlements,
customPrices: [...customPricePrices, ...customItemPrices],
customEntitlements,
deleteCustomerEntitlements: [
...deleteCustomerEntitlements,
...updateDeleteCustomerEntitlements,
],
customPrices: [
...customPricePrices,
...updateNewPrices,
...customItemPrices,
],
customEntitlements: [...updateNewEntitlements, ...customEntitlements],
};
return patchContext;

View File

@@ -64,7 +64,9 @@ export const setupUpdatePlanProductContext = async ({
preparedOp.version === undefined &&
customize.price === undefined &&
customize.add_items?.length === 0 &&
customize.remove_items === undefined
customize.remove_items === undefined &&
(customize.update_items === undefined ||
customize.update_items.length === 0)
) {
return undefined;
}

View File

@@ -0,0 +1,221 @@
/**
* TDD coverage for update_plan.customize.update_items — basic flows.
*
* Contract under test:
* New types/fields:
* - customize.update_items: Array<{ filter: PlanItemFilter; included?: number }>
* New behaviors:
* - Bumping `included` for a matched item updates the entitlement's
* allowance and balance, while carrying existing usage forward.
* - Lowering `included` below current usage drops the balance accordingly
* (no overage created beyond the carried usage).
* - `next_reset_at` is preserved across update_items (no cycle shift).
* Side effects:
* - The customer product is marked is_custom = true after the migration.
* - No Stripe invoice is generated.
*/
import { expect, test } from "bun:test";
import type { ApiCustomerV3, ApiCustomerV5 } from "@autumn/shared";
import { expectCustomerInvoiceCorrect } from "@tests/integration/billing/utils/expectCustomerInvoiceCorrect";
import { expectCustomerProducts } from "@tests/integration/billing/utils/expectCustomerProductCorrect";
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";
import { CusService } from "@/internal/customers/CusService";
import { CusProductService } from "@/internal/customers/cusProducts/CusProductService";
import { runUpdatePlanMigration } from "../../utils/runUpdatePlanMigration";
const expectActiveProductIsCustom = async ({
ctx,
customerId,
productId,
isCustom,
}: {
ctx: Awaited<ReturnType<typeof initScenario>>["ctx"];
customerId: string;
productId: string;
isCustom: boolean;
}) => {
const customer = await CusService.get({
db: ctx.db,
idOrInternalId: customerId,
orgId: ctx.org.id,
env: ctx.env,
});
if (!customer) throw new Error(`Customer ${customerId} not found`);
const cusProducts = await CusProductService.list({
db: ctx.db,
internalCustomerId: customer.internal_id,
});
const cusProduct = cusProducts.find(
(candidate) => candidate.product.id === productId,
);
expect(
cusProduct,
`active cusProduct ${productId} not found for customer ${customerId}`,
).toBeDefined();
expect(cusProduct?.is_custom).toBe(isCustom);
};
test.concurrent(`${chalk.yellowBright("migrations update_items: bump included carries usage forward")}`, async () => {
const customerId = "migration-update-items-basic-bump";
const base = products.base({
id: "migration-update-items-basic-bump-plan",
items: [items.monthlyMessages({ includedUsage: 100 })],
});
const { autumnV1, autumnV2_2, ctx } = await initScenario({
customerId,
setup: [s.customer(), s.products({ list: [base] })],
actions: [
s.billing.attach({ productId: base.id }),
s.track({ featureId: TestFeature.Messages, value: 30, timeout: 2000 }),
],
});
await runUpdatePlanMigration({
ctx,
migrationClient: autumnV2_2,
migrationId: `${customerId}-mig`,
customerId,
filter: { customer: { plan: { plan_id: base.id } } },
operations: {
customer: [
{
type: "update_plan",
plan_filter: { plan_id: base.id },
customize: {
update_items: [
{ filter: { feature_id: TestFeature.Messages }, included: 200 },
],
},
},
],
},
});
const customer = await autumnV2_2.customers.get<ApiCustomerV5>(customerId);
await expectCustomerProducts({ customer, active: [base.id] });
expectBalanceCorrect({
customer,
featureId: TestFeature.Messages,
remaining: 170,
usage: 30,
planId: base.id,
});
await expectActiveProductIsCustom({
ctx,
customerId,
productId: base.id,
isCustom: true,
});
await expectCustomerInvoiceCorrect({
customer: await autumnV1.customers.get<ApiCustomerV3>(customerId),
count: 0,
});
});
test.concurrent(`${chalk.yellowBright("migrations update_items: lower included below current usage clamps balance to zero")}`, async () => {
const customerId = "migration-update-items-basic-lower";
const base = products.base({
id: "migration-update-items-basic-lower-plan",
items: [items.monthlyMessages({ includedUsage: 100 })],
});
const { autumnV2_2, ctx } = await initScenario({
customerId,
setup: [s.customer(), s.products({ list: [base] })],
actions: [
s.billing.attach({ productId: base.id }),
s.track({ featureId: TestFeature.Messages, value: 30, timeout: 2000 }),
],
});
await runUpdatePlanMigration({
ctx,
migrationClient: autumnV2_2,
migrationId: `${customerId}-mig`,
customerId,
filter: { customer: { plan: { plan_id: base.id } } },
operations: {
customer: [
{
type: "update_plan",
plan_filter: { plan_id: base.id },
customize: {
update_items: [
{ filter: { feature_id: TestFeature.Messages }, included: 50 },
],
},
},
],
},
});
const customer = await autumnV2_2.customers.get<ApiCustomerV5>(customerId);
expectBalanceCorrect({
customer,
featureId: TestFeature.Messages,
remaining: 20,
usage: 30,
planId: base.id,
});
});
test.concurrent(`${chalk.yellowBright("migrations update_items: preserves next_reset_at on the updated entitlement")}`, async () => {
const customerId = "migration-update-items-reset-preserved";
const base = products.base({
id: "migration-update-items-reset-preserved-plan",
items: [items.monthlyMessages({ includedUsage: 100 })],
});
const { autumnV2_2, ctx } = await initScenario({
customerId,
setup: [s.customer(), s.products({ list: [base] })],
actions: [s.billing.attach({ productId: base.id })],
});
const before = await autumnV2_2.customers.get<ApiCustomerV5>(customerId);
const beforeResetAt = before.balances[TestFeature.Messages]?.next_reset_at;
expect(
beforeResetAt,
"pre-update next_reset_at should be set on monthly entitlement",
).not.toBeNull();
await runUpdatePlanMigration({
ctx,
migrationClient: autumnV2_2,
migrationId: `${customerId}-mig`,
customerId,
filter: { customer: { plan: { plan_id: base.id } } },
operations: {
customer: [
{
type: "update_plan",
plan_filter: { plan_id: base.id },
customize: {
update_items: [
{ filter: { feature_id: TestFeature.Messages }, included: 300 },
],
},
},
],
},
});
const customer = await autumnV2_2.customers.get<ApiCustomerV5>(customerId);
expectBalanceCorrect({
customer,
featureId: TestFeature.Messages,
remaining: 300,
usage: 0,
nextResetAt: beforeResetAt as number,
planId: base.id,
});
});

View File

@@ -0,0 +1,219 @@
/**
* TDD coverage for update_items lifecycle behaviors:
* - Cycle anchor preservation across a mid-cycle update (test-clock advance).
* - Newly-tracked usage applies to the updated entitlement.
* - Idempotency: running the same update_items twice is a no-op on the
* second pass.
*
* Contract under test:
* New behaviors:
* - Mid-cycle update_items does NOT shift the billing/reset anchor; at
* next reset the balance resets to the NEW included value (not the
* old one).
* - The new entitlement participates in normal track/deduct flows.
* - Running an identical update_items migration twice yields the same
* end state as running it once.
*/
import { expect, test } from "bun:test";
import { type ApiCustomerV5 } 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 { advanceTestClock } from "@tests/utils/stripeUtils";
import { initScenario, s } from "@tests/utils/testInitUtils/initScenario";
import chalk from "chalk";
import { runUpdatePlanMigration } from "../../utils/runUpdatePlanMigration";
test(`${chalk.yellowBright("migrations update_items: cycle anchor survives mid-cycle update; next reset uses new included")}`, async () => {
const customerId = "migration-update-items-cycle-anchor";
const pro = products.pro({
id: "migration-update-items-cycle-anchor-plan",
items: [items.monthlyMessages({ includedUsage: 100 })],
});
const { autumnV2_2, ctx, testClockId } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success", testClock: true }),
s.products({ list: [pro] }),
],
actions: [
s.billing.attach({ productId: pro.id }),
s.advanceTestClock({ days: 10 }),
],
});
const before = await autumnV2_2.customers.get<ApiCustomerV5>(customerId);
const anchorBefore = before.balances[TestFeature.Messages]
?.next_reset_at as number;
expect(anchorBefore, "monthly entitlement must have a next_reset_at").not.toBeNull();
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: {
update_items: [
{ filter: { feature_id: TestFeature.Messages }, included: 300 },
],
},
},
],
},
});
const afterUpdate = await autumnV2_2.customers.get<ApiCustomerV5>(customerId);
expectBalanceCorrect({
customer: afterUpdate,
featureId: TestFeature.Messages,
remaining: 300,
usage: 0,
nextResetAt: anchorBefore,
planId: pro.id,
});
// Advance past the original anchor — the entitlement should reset to the
// NEW included value (300), not the old (100).
await advanceTestClock({
stripeCli: ctx.stripeCli,
testClockId: testClockId!,
numberOfDays: 30,
waitForSeconds: 30,
});
const afterReset = await autumnV2_2.customers.get<ApiCustomerV5>(customerId);
expectBalanceCorrect({
customer: afterReset,
featureId: TestFeature.Messages,
remaining: 300,
usage: 0,
planId: pro.id,
});
});
test.concurrent(`${chalk.yellowBright("migrations update_items: tracking new usage on the updated entitlement deducts correctly")}`, async () => {
const customerId = "migration-update-items-track-after-update";
const base = products.base({
id: "migration-update-items-track-after-update-plan",
items: [items.monthlyMessages({ includedUsage: 100 })],
});
const { autumnV1, autumnV2_2, ctx } = await initScenario({
customerId,
setup: [s.customer(), s.products({ list: [base] })],
actions: [s.billing.attach({ productId: base.id })],
});
await runUpdatePlanMigration({
ctx,
migrationClient: autumnV2_2,
migrationId: `${customerId}-mig`,
customerId,
filter: { customer: { plan: { plan_id: base.id } } },
operations: {
customer: [
{
type: "update_plan",
plan_filter: { plan_id: base.id },
customize: {
update_items: [
{ filter: { feature_id: TestFeature.Messages }, included: 250 },
],
},
},
],
},
});
// Track 75 against the (now patched) entitlement.
await autumnV1.track(
{
customer_id: customerId,
feature_id: TestFeature.Messages,
value: 75,
},
{ timeout: 2000 },
);
const customer = await autumnV2_2.customers.get<ApiCustomerV5>(customerId);
expectBalanceCorrect({
customer,
featureId: TestFeature.Messages,
remaining: 175,
usage: 75,
planId: base.id,
});
});
test.concurrent(`${chalk.yellowBright("migrations update_items: running the same migration twice is idempotent")}`, async () => {
const customerId = "migration-update-items-idempotent";
const base = products.base({
id: "migration-update-items-idempotent-plan",
items: [items.monthlyMessages({ includedUsage: 100 })],
});
const { autumnV2_2, ctx } = await initScenario({
customerId,
setup: [s.customer(), s.products({ list: [base] })],
actions: [
s.billing.attach({ productId: base.id }),
s.track({ featureId: TestFeature.Messages, value: 40, timeout: 2000 }),
],
});
const runMigration = (migrationSuffix: string) =>
runUpdatePlanMigration({
ctx,
migrationClient: autumnV2_2,
migrationId: `${customerId}-mig-${migrationSuffix}`,
customerId,
filter: { customer: { plan: { plan_id: base.id } } },
operations: {
customer: [
{
type: "update_plan",
plan_filter: { plan_id: base.id },
customize: {
update_items: [
{
filter: { feature_id: TestFeature.Messages },
included: 250,
},
],
},
},
],
},
});
await runMigration("first");
const afterFirst = await autumnV2_2.customers.get<ApiCustomerV5>(customerId);
expectBalanceCorrect({
customer: afterFirst,
featureId: TestFeature.Messages,
remaining: 210,
usage: 40,
planId: base.id,
});
await runMigration("second");
const afterSecond = await autumnV2_2.customers.get<ApiCustomerV5>(customerId);
expectBalanceCorrect({
customer: afterSecond,
featureId: TestFeature.Messages,
remaining: 210,
usage: 40,
planId: base.id,
});
});

View File

@@ -0,0 +1,147 @@
/**
* TDD coverage for update_items mixed with add_items / remove_items in the
* same customize.
*
* Contract under test:
* New behaviors:
* - update_items, remove_items, and add_items in one customize compose
* coherently: removes drop items, updates patch existing items, adds
* insert new items.
* - update_items runs BEFORE add_items, so a feature that already has a
* cusEnt is updated in place rather than skipped by add_items' noop
* logic, and the freshly-added items in the same migration are not
* eligible matches for update_items.
* - Items that the user targets exclusively with remove_items are not
* also picked up by update_items running against the same product.
*/
import { test } from "bun:test";
import type { ApiCustomerV3, ApiCustomerV5 } from "@autumn/shared";
import { expectCustomerInvoiceCorrect } from "@tests/integration/billing/utils/expectCustomerInvoiceCorrect";
import { expectBalanceCorrect } from "@tests/integration/utils/expectBalanceCorrect";
import { expectFlagCorrect } from "@tests/integration/utils/expectFlagCorrect";
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 { expect } from "bun:test";
import { runUpdatePlanMigration } from "../../utils/runUpdatePlanMigration";
test.concurrent(`${chalk.yellowBright("migrations update_items: update + add + remove compose correctly in one customize")}`, async () => {
const customerId = "migration-update-items-mixed-compose";
const base = products.base({
id: "migration-update-items-mixed-compose-plan",
items: [
items.monthlyMessages({ includedUsage: 100 }),
items.monthlyWords({ includedUsage: 80 }),
],
});
const { autumnV1, autumnV2_2, ctx } = await initScenario({
customerId,
setup: [s.customer(), s.products({ list: [base] })],
actions: [
s.billing.attach({ productId: base.id }),
s.track({ featureId: TestFeature.Messages, value: 20, timeout: 2000 }),
],
});
await runUpdatePlanMigration({
ctx,
migrationClient: autumnV2_2,
migrationId: `${customerId}-mig`,
customerId,
filter: { customer: { plan: { plan_id: base.id } } },
operations: {
customer: [
{
type: "update_plan",
plan_filter: { plan_id: base.id },
customize: {
update_items: [
{ filter: { feature_id: TestFeature.Messages }, included: 250 },
],
remove_items: [{ feature_id: TestFeature.Words }],
add_items: [itemsV2.dashboard()],
},
},
],
},
});
const customer = await autumnV2_2.customers.get<ApiCustomerV5>(customerId);
// update_items: messages updated 100 -> 250 with 20 usage carried.
expectBalanceCorrect({
customer,
featureId: TestFeature.Messages,
remaining: 230,
usage: 20,
planId: base.id,
});
// remove_items: words dropped.
expect(
customer.balances[TestFeature.Words],
"remove_items must drop the words entitlement",
).toBeUndefined();
// add_items: dashboard boolean now present.
expectFlagCorrect({
customer,
featureId: TestFeature.Dashboard,
planId: base.id,
});
await expectCustomerInvoiceCorrect({
customer: await autumnV1.customers.get<ApiCustomerV3>(customerId),
count: 0,
});
});
test.concurrent(`${chalk.yellowBright("migrations update_items: filter that overlaps remove_items only deletes once")}`, async () => {
const customerId = "migration-update-items-mixed-no-double-delete";
const base = products.base({
id: "migration-update-items-mixed-no-double-delete-plan",
items: [items.monthlyMessages({ includedUsage: 100 })],
});
const { autumnV2_2, ctx } = await initScenario({
customerId,
setup: [s.customer(), s.products({ list: [base] })],
actions: [s.billing.attach({ productId: base.id })],
});
// User asks to BOTH remove and update the same item — the operation should
// still succeed; resulting state mirrors "remove wins" since the item is
// gone before update_items can target a fresh row.
await runUpdatePlanMigration({
ctx,
migrationClient: autumnV2_2,
migrationId: `${customerId}-mig`,
customerId,
filter: { customer: { plan: { plan_id: base.id } } },
operations: {
customer: [
{
type: "update_plan",
plan_filter: { plan_id: base.id },
customize: {
remove_items: [{ feature_id: TestFeature.Messages }],
update_items: [
{ filter: { feature_id: TestFeature.Messages }, included: 999 },
],
},
},
],
},
});
const customer = await autumnV2_2.customers.get<ApiCustomerV5>(customerId);
expect(
customer.balances[TestFeature.Messages],
"remove + update for the same feature should leave the feature gone, not 999",
).toBeUndefined();
});

View File

@@ -0,0 +1,152 @@
/**
* TDD coverage for update_items targeting one of several customer entitlements
* for the same feature (monthly + lifetime case).
*
* Contract under test:
* New behaviors:
* - A `PlanItemFilter` that includes `interval` only matches entitlements
* with that interval. Untouched entitlements (different interval) keep
* their balance and reset state exactly as-is.
* - Usage carried via update_items only applies to the entitlement(s) it
* replaced — sibling entitlements for the same feature with usage of
* their own do not get double-deducted.
* - When a single update_items[i].filter matches multiple customer
* entitlements (e.g. feature_id only), all matches are updated.
*/
import { expect, test } from "bun:test";
import {
type ApiCustomerV3,
type ApiCustomerV5,
BillingInterval,
ResetInterval,
} from "@autumn/shared";
import { expectCustomerInvoiceCorrect } from "@tests/integration/billing/utils/expectCustomerInvoiceCorrect";
import { expectCustomerProducts } from "@tests/integration/billing/utils/expectCustomerProductCorrect";
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";
import { runUpdatePlanMigration } from "../../utils/runUpdatePlanMigration";
test.concurrent(`${chalk.yellowBright("migrations update_items: interval filter only touches monthly cusEnt; lifetime untouched")}`, async () => {
const customerId = "migration-update-items-multi-monthly-only";
const base = products.base({
id: "migration-update-items-multi-monthly-only-plan",
items: [
items.monthlyMessages({ includedUsage: 100 }),
items.lifetimeMessages({ includedUsage: 50 }),
],
});
const { autumnV1, autumnV2_2, ctx } = await initScenario({
customerId,
setup: [s.customer(), s.products({ list: [base] })],
actions: [
s.billing.attach({ productId: base.id }),
s.track({ featureId: TestFeature.Messages, value: 30, timeout: 2000 }),
],
});
await runUpdatePlanMigration({
ctx,
migrationClient: autumnV2_2,
migrationId: `${customerId}-mig`,
customerId,
filter: { customer: { plan: { plan_id: base.id } } },
operations: {
customer: [
{
type: "update_plan",
plan_filter: { plan_id: base.id },
customize: {
update_items: [
{
filter: {
feature_id: TestFeature.Messages,
interval: BillingInterval.Month,
},
included: 300,
},
],
},
},
],
},
});
const customer = await autumnV2_2.customers.get<ApiCustomerV5>(customerId);
await expectCustomerProducts({ customer, active: [base.id] });
// Aggregated balance: monthly (300 - 30 usage) + lifetime untouched (50)
expectBalanceCorrect({
customer,
featureId: TestFeature.Messages,
remaining: 320,
usage: 30,
planId: base.id,
breakdown: {
[ResetInterval.Month]: { included_grant: 300, remaining: 270, usage: 30 },
[ResetInterval.OneOff]: { included_grant: 50, remaining: 50, usage: 0 },
},
});
await expectCustomerInvoiceCorrect({
customer: await autumnV1.customers.get<ApiCustomerV3>(customerId),
count: 0,
});
});
test.concurrent(`${chalk.yellowBright("migrations update_items: feature-id-only filter updates every cusEnt for that feature")}`, async () => {
const customerId = "migration-update-items-multi-feature-wide";
const base = products.base({
id: "migration-update-items-multi-feature-wide-plan",
items: [
items.monthlyMessages({ includedUsage: 100 }),
items.lifetimeMessages({ includedUsage: 50 }),
],
});
const { autumnV2_2, ctx } = await initScenario({
customerId,
setup: [s.customer(), s.products({ list: [base] })],
actions: [s.billing.attach({ productId: base.id })],
});
await runUpdatePlanMigration({
ctx,
migrationClient: autumnV2_2,
migrationId: `${customerId}-mig`,
customerId,
filter: { customer: { plan: { plan_id: base.id } } },
operations: {
customer: [
{
type: "update_plan",
plan_filter: { plan_id: base.id },
customize: {
update_items: [
{ filter: { feature_id: TestFeature.Messages }, included: 999 },
],
},
},
],
},
});
const customer = await autumnV2_2.customers.get<ApiCustomerV5>(customerId);
// Both monthly and lifetime entitlements should now be granted 999.
expectBalanceCorrect({
customer,
featureId: TestFeature.Messages,
remaining: 1998,
usage: 0,
planId: base.id,
breakdown: {
[ResetInterval.Month]: { included_grant: 999, remaining: 999 },
[ResetInterval.OneOff]: { included_grant: 999, remaining: 999 },
},
});
});

View File

@@ -0,0 +1,352 @@
/**
* TDD coverage for update_items against paid / billed features and edge
* cases.
*
* Contract under test:
* New behaviors:
* - PREPAID feature update_items: bumping `included` carries the
* customer-selected quantity forward and leaves the Stripe
* subscription unchanged. No new invoice.
* - CONSUMABLE (usage-in-arrear) update_items: bumping `included`
* preserves arrears usage. Stripe subscription untouched.
* - update_items targeting a free feature on a customer with a PAID
* Stripe subscription leaves the subscription's line items + anchor
* intact (no proration invoice).
* - Filter that matches nothing is a graceful no-op (the migration
* succeeds, customer state unchanged, no charge artifacts).
* - Unlimited entitlement + update_items.included: the override is
* silently ignored (unlimited stays unlimited; we do not regress to
* a numeric allowance).
*/
import { expect, test } from "bun:test";
import {
type ApiCustomerV3,
type ApiCustomerV5,
BillingMethod,
} from "@autumn/shared";
import { expectCustomerInvoiceCorrect } from "@tests/integration/billing/utils/expectCustomerInvoiceCorrect";
import { expectStripeSubscriptionUnchanged } from "@tests/integration/billing/utils/stripe/expectStripeSubscriptionUnchanged";
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";
import { runUpdatePlanMigration } from "../../utils/runUpdatePlanMigration";
test.concurrent(`${chalk.yellowBright("migrations update_items: prepaid feature — preserves selected quantity, Stripe sub unchanged, no invoice")}`, async () => {
const customerId = "migration-update-items-prepaid";
const pro = products.pro({
id: "migration-update-items-prepaid-plan",
items: [
items.prepaidMessages({
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.billing.attach({
productId: pro.id,
options: [{ feature_id: TestFeature.Messages, quantity: 200 }],
}),
],
});
const customerBefore = await autumnV1.customers.get<ApiCustomerV3>(customerId);
const invoiceCountBefore = customerBefore.invoices?.length ?? 0;
const stripeCustomerId = customerBefore.stripe_id as string;
const subsBefore = await ctx.stripeCli.subscriptions.list({
customer: stripeCustomerId,
status: "all",
});
const subBefore = subsBefore.data.find(
(sub) => sub.status === "active" || sub.status === "trialing",
);
expect(subBefore, "expected a paid Stripe sub for the prepaid plan").toBeDefined();
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: {
update_items: [
{ filter: { feature_id: TestFeature.Messages }, included: 100 },
],
},
},
],
},
});
const customer = await autumnV2_2.customers.get<ApiCustomerV5>(customerId);
// Bumping `included` on a prepaid item absorbs that allowance into the
// already-purchased prepaid quantity (see cusProductToConvertedFeatureOptions).
// Total grant stays at the original 200; the new entitlement carries
// `included_grant: 100` and the prepaid_grant drops to 100.
expectBalanceCorrect({
customer,
featureId: TestFeature.Messages,
remaining: 200,
usage: 0,
breakdown: {
[BillingMethod.Prepaid]: {
included_grant: 100,
prepaid_grant: 100,
remaining: 200,
usage: 0,
},
},
});
const subAfter = await ctx.stripeCli.subscriptions.retrieve(subBefore!.id);
expectStripeSubscriptionUnchanged({
before: subBefore!,
after: subAfter,
});
await expectCustomerInvoiceCorrect({
customer: await autumnV1.customers.get<ApiCustomerV3>(customerId),
count: invoiceCountBefore,
});
});
test.concurrent(`${chalk.yellowBright("migrations update_items: consumable (usage-in-arrear) feature — preserves arrears usage, Stripe sub unchanged")}`, async () => {
const customerId = "migration-update-items-consumable";
const pro = products.pro({
id: "migration-update-items-consumable-plan",
items: [items.consumableMessages({ includedUsage: 100, price: 0.1 })],
});
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.track({ featureId: TestFeature.Messages, value: 150, timeout: 2000 }),
],
});
const customerBefore = await autumnV1.customers.get<ApiCustomerV3>(customerId);
const invoiceCountBefore = customerBefore.invoices?.length ?? 0;
const stripeCustomerId = customerBefore.stripe_id as string;
const subsBefore = await ctx.stripeCli.subscriptions.list({
customer: stripeCustomerId,
status: "all",
});
const subBefore = subsBefore.data.find(
(sub) => sub.status === "active" || sub.status === "trialing",
);
expect(subBefore).toBeDefined();
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: {
update_items: [
{ filter: { feature_id: TestFeature.Messages }, included: 300 },
],
},
},
],
},
});
const customer = await autumnV2_2.customers.get<ApiCustomerV5>(customerId);
// new included (300) - existing usage (150) → remaining 150
expectBalanceCorrect({
customer,
featureId: TestFeature.Messages,
remaining: 150,
usage: 150,
breakdown: {
[BillingMethod.UsageBased]: {
included_grant: 300,
remaining: 150,
usage: 150,
},
},
});
const subAfter = await ctx.stripeCli.subscriptions.retrieve(subBefore!.id);
expectStripeSubscriptionUnchanged({ before: subBefore!, after: subAfter });
await expectCustomerInvoiceCorrect({
customer: await autumnV1.customers.get<ApiCustomerV3>(customerId),
count: invoiceCountBefore,
});
});
test.concurrent(`${chalk.yellowBright("migrations update_items: allocated (entity-scoped) feature — per-entity balances preserved")}`, async () => {
const customerId = "migration-update-items-allocated";
const base = products.base({
id: "migration-update-items-allocated-plan",
items: [items.freeAllocatedUsers({ includedUsage: 5 })],
});
const { autumnV1, autumnV2_2, ctx, entities } = await initScenario({
customerId,
setup: [
s.customer(),
s.products({ list: [base] }),
s.entities({ count: 2, featureId: TestFeature.Users }),
],
actions: [s.billing.attach({ productId: base.id })],
});
await runUpdatePlanMigration({
ctx,
migrationClient: autumnV2_2,
migrationId: `${customerId}-mig`,
customerId,
filter: { customer: { plan: { plan_id: base.id } } },
operations: {
customer: [
{
type: "update_plan",
plan_filter: { plan_id: base.id },
customize: {
update_items: [
{ filter: { feature_id: TestFeature.Users }, included: 10 },
],
},
},
],
},
});
// Allocated/seat features count existing entities as usage (see
// mergeEntitiesWithExistingUsages). With 2 entities + new included=10, the
// expected post-update balance per entity is 10 2 = 8.
for (const entity of entities) {
const customer = await autumnV1.entities.get(customerId, entity.id);
expect(
customer.features?.[TestFeature.Users]?.balance,
`entity ${entity.id} should have balance 8 (10 included minus 2 entity-as-usage)`,
).toBe(8);
}
});
test.concurrent(`${chalk.yellowBright("migrations update_items: filter that matches nothing is a graceful no-op")}`, async () => {
const customerId = "migration-update-items-no-match";
const base = products.base({
id: "migration-update-items-no-match-plan",
items: [items.monthlyMessages({ includedUsage: 100 })],
});
const { autumnV1, autumnV2_2, ctx } = await initScenario({
customerId,
setup: [s.customer(), s.products({ list: [base] })],
actions: [
s.billing.attach({ productId: base.id }),
s.track({ featureId: TestFeature.Messages, value: 20, timeout: 2000 }),
],
});
await runUpdatePlanMigration({
ctx,
migrationClient: autumnV2_2,
migrationId: `${customerId}-mig`,
customerId,
filter: { customer: { plan: { plan_id: base.id } } },
operations: {
customer: [
{
type: "update_plan",
plan_filter: { plan_id: base.id },
customize: {
update_items: [
{
filter: { feature_id: "does-not-exist-on-this-plan" },
included: 999,
},
],
},
},
],
},
});
const customer = await autumnV2_2.customers.get<ApiCustomerV5>(customerId);
// Untouched state: original 100 included, 20 used → 80 remaining.
expectBalanceCorrect({
customer,
featureId: TestFeature.Messages,
remaining: 80,
usage: 20,
planId: base.id,
});
await expectCustomerInvoiceCorrect({
customer: await autumnV1.customers.get<ApiCustomerV3>(customerId),
count: 0,
});
});
test.concurrent(`${chalk.yellowBright("migrations update_items: included on an unlimited entitlement is ignored (still unlimited)")}`, async () => {
const customerId = "migration-update-items-unlimited";
const base = products.base({
id: "migration-update-items-unlimited-plan",
items: [items.unlimitedMessages()],
});
const { autumnV2_2, ctx } = await initScenario({
customerId,
setup: [s.customer(), s.products({ list: [base] })],
actions: [s.billing.attach({ productId: base.id })],
});
await runUpdatePlanMigration({
ctx,
migrationClient: autumnV2_2,
migrationId: `${customerId}-mig`,
customerId,
filter: { customer: { plan: { plan_id: base.id } } },
operations: {
customer: [
{
type: "update_plan",
plan_filter: { plan_id: base.id },
customize: {
update_items: [
{ filter: { feature_id: TestFeature.Messages }, included: 500 },
],
},
},
],
},
});
const customer = await autumnV2_2.customers.get<ApiCustomerV5>(customerId);
const balance = customer.balances[TestFeature.Messages];
expect(
balance?.unlimited,
"unlimited entitlement must stay unlimited after update_items",
).toBe(true);
});

View File

@@ -0,0 +1,89 @@
/**
* TDD coverage for update_items + rollover.
*
* Contract under test:
* New behaviors:
* - When an entitlement with an active rollover is updated via update_items,
* the rollover is carried over onto the new entitlement.
* - The updated entitlement keeps the same rollover configuration that
* was already in place (no need to redeclare it when only bumping
* `included`).
*/
import { test } from "bun:test";
import {
type ApiCustomerV3,
type ApiCustomerV5,
RolloverExpiryDurationType,
} from "@autumn/shared";
import { expectCustomerInvoiceCorrect } from "@tests/integration/billing/utils/expectCustomerInvoiceCorrect";
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";
import { runUpdatePlanMigration } from "../../utils/runUpdatePlanMigration";
test.concurrent(`${chalk.yellowBright("migrations update_items: rollover from old cusEnt carries onto new cusEnt")}`, async () => {
const customerId = "migration-update-items-rollover-carry";
const base = products.base({
id: "migration-update-items-rollover-plan",
items: [
items.monthlyMessagesWithRollover({
includedUsage: 400,
rolloverConfig: {
max: 500,
length: 1,
duration: RolloverExpiryDurationType.Month,
},
}),
],
});
const { autumnV1, autumnV2_2, ctx } = await initScenario({
customerId,
setup: [s.customer(), s.products({ list: [base] })],
actions: [
s.billing.attach({ productId: base.id }),
s.track({ featureId: TestFeature.Messages, value: 250, timeout: 2000 }),
s.resetFeature({ featureId: TestFeature.Messages }),
],
});
await runUpdatePlanMigration({
ctx,
migrationClient: autumnV2_2,
migrationId: `${customerId}-mig`,
customerId,
filter: { customer: { plan: { plan_id: base.id } } },
operations: {
customer: [
{
type: "update_plan",
plan_filter: { plan_id: base.id },
customize: {
update_items: [
{ filter: { feature_id: TestFeature.Messages }, included: 500 },
],
},
},
],
},
});
const customer = await autumnV2_2.customers.get<ApiCustomerV5>(customerId);
// 500 new included + 150 unused that rolled over = 650 available.
expectBalanceCorrect({
customer,
featureId: TestFeature.Messages,
remaining: 650,
usage: 0,
rollovers: [{ balance: 150 }],
});
await expectCustomerInvoiceCorrect({
customer: await autumnV1.customers.get<ApiCustomerV3>(customerId),
count: 0,
});
});

View File

@@ -4,6 +4,25 @@ import { CreatePlanItemParamsV1Schema } from "@api/products/items/crud/createPla
import { PlanItemFilterSchema } from "@api/products/items/filter/planItemFilter";
import { z } from "zod/v4";
export const UpdatePlanItemParamsV1Schema = z
.object({
filter: PlanItemFilterSchema.meta({
description:
"Filter selecting which existing plan item(s) to update. Same shape as remove_items filters.",
}),
included: z.number().nonnegative().optional().meta({
description:
"Override the matched item's included usage / allowance. Existing usage carries forward.",
}),
})
.meta({
title: "UpdatePlanItem",
description:
"Patch an existing plan item in place. Phase 1 supports only `included`.",
});
export type UpdatePlanItemParamsV1 = z.infer<typeof UpdatePlanItemParamsV1Schema>;
export const CustomizePlanV1Schema = z
.object({
price: BasePriceParamsSchema.nullable().optional().meta({
@@ -12,7 +31,7 @@ export const CustomizePlanV1Schema = z
}),
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.",
"Override the items in the plan (PUT-style — replaces all existing items). Mutually exclusive with add_items / remove_items / update_items.",
}),
add_items: z.array(CreatePlanItemParamsV1Schema).optional().meta({
description: "Items to add to the plan.",
@@ -20,6 +39,11 @@ export const CustomizePlanV1Schema = z
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,
}),
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.",
@@ -31,21 +55,24 @@ export const CustomizePlanV1Schema = z
data.price !== undefined ||
data.free_trial !== undefined ||
data.add_items !== undefined ||
data.remove_items !== undefined,
data.remove_items !== undefined ||
data.update_items !== undefined,
{
message:
"When using customize, at least one of price, items, add_items, remove_items, or free_trial must be provided",
"When using customize, at least one of price, items, add_items, remove_items, update_items, or free_trial must be provided",
},
)
.refine(
(data) =>
!(
data.items !== undefined &&
(data.add_items !== undefined || data.remove_items !== undefined)
(data.add_items !== undefined ||
data.remove_items !== undefined ||
data.update_items !== undefined)
),
{
message:
"customize.items (PUT-style) cannot be combined with add_items/remove_items (PATCH-style); pick one approach",
"customize.items (PUT-style) cannot be combined with add_items / remove_items / update_items (PATCH-style); pick one approach",
},
)
.meta({
@@ -65,7 +92,8 @@ export const hasCustomItems = (
customize.price !== undefined ||
customize.items !== undefined ||
customize.add_items !== undefined ||
customize.remove_items !== undefined
customize.remove_items !== undefined ||
customize.update_items !== undefined
);
};
@@ -75,4 +103,5 @@ export const isCustomizePlanPatchStyle = (
customize?.items === undefined &&
(customize?.price !== undefined ||
customize?.add_items !== undefined ||
customize?.remove_items !== undefined);
customize?.remove_items !== undefined ||
customize?.update_items !== undefined);

View File

@@ -1,6 +1,7 @@
import { BasePriceParamsSchema } from "@api/products/components/basePrice/basePrice";
import { PlanItemFilterSchema } from "@api/products/items/filter/planItemFilter";
import { z } from "zod/v4";
import { UpdatePlanItemParamsV1Schema } from "../../../../billing/common/customizePlan/customizePlanV1.js";
import { CreatePlanItemParamsV1Schema } from "../../../../products/items/crud/createPlanItemParamsV1.js";
import { PlanFilterSchema } from "../../../filters/planFilter.js";
@@ -9,15 +10,17 @@ export const MigrationUpdatePlanCustomizeSchema = z
price: BasePriceParamsSchema.nullable().optional(),
add_items: z.array(CreatePlanItemParamsV1Schema).optional(),
remove_items: z.array(PlanItemFilterSchema).optional(),
update_items: z.array(UpdatePlanItemParamsV1Schema).optional(),
})
.refine(
(data) =>
data.price !== undefined ||
data.add_items !== undefined ||
data.remove_items !== undefined,
data.remove_items !== undefined ||
data.update_items !== undefined,
{
message:
"update_plan.customize requires at least one of price, add_items, or remove_items",
"update_plan.customize requires at least one of price, add_items, remove_items, or update_items",
},
);