From 2010ba9ffba4215efcd199fa9edcf1705351e4cd Mon Sep 17 00:00:00 2001 From: johnyeo Date: Tue, 26 May 2026 18:36:01 +0100 Subject: [PATCH] added update items to update plan op, starting with included --- .../setup/patch/handleCustomizeUpdateItems.ts | 186 +++++++++ .../v2/setup/patch/setupPatchContext.ts | 38 +- .../setup/setupUpdatePlanProductContext.ts | 4 +- .../update-items/update-items-basic.test.ts | 221 +++++++++++ .../update-items/update-items-cycle.test.ts | 219 +++++++++++ .../update-items/update-items-mixed.test.ts | 147 ++++++++ .../update-items-multi-cusent.test.ts | 152 ++++++++ .../update-items-paid-features.test.ts | 352 ++++++++++++++++++ .../update-items-rollover.test.ts | 89 +++++ .../common/customizePlan/customizePlanV1.ts | 43 ++- .../customer/updatePlan/updatePlanOp.ts | 7 +- 11 files changed, 1445 insertions(+), 13 deletions(-) create mode 100644 server/src/internal/billing/v2/setup/patch/handleCustomizeUpdateItems.ts create mode 100644 server/tests/integration/billing/migrations-v2/update-plan-operation/update-items/update-items-basic.test.ts create mode 100644 server/tests/integration/billing/migrations-v2/update-plan-operation/update-items/update-items-cycle.test.ts create mode 100644 server/tests/integration/billing/migrations-v2/update-plan-operation/update-items/update-items-mixed.test.ts create mode 100644 server/tests/integration/billing/migrations-v2/update-plan-operation/update-items/update-items-multi-cusent.test.ts create mode 100644 server/tests/integration/billing/migrations-v2/update-plan-operation/update-items/update-items-paid-features.test.ts create mode 100644 server/tests/integration/billing/migrations-v2/update-plan-operation/update-items/update-items-rollover.test.ts diff --git a/server/src/internal/billing/v2/setup/patch/handleCustomizeUpdateItems.ts b/server/src/internal/billing/v2/setup/patch/handleCustomizeUpdateItems.ts new file mode 100644 index 000000000..fd08629ba --- /dev/null +++ b/server/src/internal/billing/v2/setup/patch/handleCustomizeUpdateItems.ts @@ -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(); + const deleteCustomerEntitlementIds = new Set(); + 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, + }; +}; diff --git a/server/src/internal/billing/v2/setup/patch/setupPatchContext.ts b/server/src/internal/billing/v2/setup/patch/setupPatchContext.ts index 7913ab2cf..5222fff55 100644 --- a/server/src/internal/billing/v2/setup/patch/setupPatchContext.ts +++ b/server/src/internal/billing/v2/setup/patch/setupPatchContext.ts @@ -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; diff --git a/server/src/internal/migrations/v2/operations/updatePlan/setup/setupUpdatePlanProductContext.ts b/server/src/internal/migrations/v2/operations/updatePlan/setup/setupUpdatePlanProductContext.ts index a690ab2f3..b33215c7c 100644 --- a/server/src/internal/migrations/v2/operations/updatePlan/setup/setupUpdatePlanProductContext.ts +++ b/server/src/internal/migrations/v2/operations/updatePlan/setup/setupUpdatePlanProductContext.ts @@ -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; } diff --git a/server/tests/integration/billing/migrations-v2/update-plan-operation/update-items/update-items-basic.test.ts b/server/tests/integration/billing/migrations-v2/update-plan-operation/update-items/update-items-basic.test.ts new file mode 100644 index 000000000..801f895ff --- /dev/null +++ b/server/tests/integration/billing/migrations-v2/update-plan-operation/update-items/update-items-basic.test.ts @@ -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>["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(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(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(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(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(customerId); + expectBalanceCorrect({ + customer, + featureId: TestFeature.Messages, + remaining: 300, + usage: 0, + nextResetAt: beforeResetAt as number, + planId: base.id, + }); +}); diff --git a/server/tests/integration/billing/migrations-v2/update-plan-operation/update-items/update-items-cycle.test.ts b/server/tests/integration/billing/migrations-v2/update-plan-operation/update-items/update-items-cycle.test.ts new file mode 100644 index 000000000..1b499316c --- /dev/null +++ b/server/tests/integration/billing/migrations-v2/update-plan-operation/update-items/update-items-cycle.test.ts @@ -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(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(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(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(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(customerId); + expectBalanceCorrect({ + customer: afterFirst, + featureId: TestFeature.Messages, + remaining: 210, + usage: 40, + planId: base.id, + }); + + await runMigration("second"); + + const afterSecond = await autumnV2_2.customers.get(customerId); + expectBalanceCorrect({ + customer: afterSecond, + featureId: TestFeature.Messages, + remaining: 210, + usage: 40, + planId: base.id, + }); +}); diff --git a/server/tests/integration/billing/migrations-v2/update-plan-operation/update-items/update-items-mixed.test.ts b/server/tests/integration/billing/migrations-v2/update-plan-operation/update-items/update-items-mixed.test.ts new file mode 100644 index 000000000..cd8a1ee49 --- /dev/null +++ b/server/tests/integration/billing/migrations-v2/update-plan-operation/update-items/update-items-mixed.test.ts @@ -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(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(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(customerId); + expect( + customer.balances[TestFeature.Messages], + "remove + update for the same feature should leave the feature gone, not 999", + ).toBeUndefined(); +}); diff --git a/server/tests/integration/billing/migrations-v2/update-plan-operation/update-items/update-items-multi-cusent.test.ts b/server/tests/integration/billing/migrations-v2/update-plan-operation/update-items/update-items-multi-cusent.test.ts new file mode 100644 index 000000000..94cc9b197 --- /dev/null +++ b/server/tests/integration/billing/migrations-v2/update-plan-operation/update-items/update-items-multi-cusent.test.ts @@ -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(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(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(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 }, + }, + }); +}); diff --git a/server/tests/integration/billing/migrations-v2/update-plan-operation/update-items/update-items-paid-features.test.ts b/server/tests/integration/billing/migrations-v2/update-plan-operation/update-items/update-items-paid-features.test.ts new file mode 100644 index 000000000..7b5b7095c --- /dev/null +++ b/server/tests/integration/billing/migrations-v2/update-plan-operation/update-items/update-items-paid-features.test.ts @@ -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(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(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(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(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(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(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(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(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(customerId); + const balance = customer.balances[TestFeature.Messages]; + expect( + balance?.unlimited, + "unlimited entitlement must stay unlimited after update_items", + ).toBe(true); +}); diff --git a/server/tests/integration/billing/migrations-v2/update-plan-operation/update-items/update-items-rollover.test.ts b/server/tests/integration/billing/migrations-v2/update-plan-operation/update-items/update-items-rollover.test.ts new file mode 100644 index 000000000..e6412e8cb --- /dev/null +++ b/server/tests/integration/billing/migrations-v2/update-plan-operation/update-items/update-items-rollover.test.ts @@ -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(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(customerId), + count: 0, + }); +}); diff --git a/shared/api/billing/common/customizePlan/customizePlanV1.ts b/shared/api/billing/common/customizePlan/customizePlanV1.ts index 99bc77b11..fd4c34184 100644 --- a/shared/api/billing/common/customizePlan/customizePlanV1.ts +++ b/shared/api/billing/common/customizePlan/customizePlanV1.ts @@ -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; + 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); diff --git a/shared/api/migrations/operations/customer/updatePlan/updatePlanOp.ts b/shared/api/migrations/operations/customer/updatePlan/updatePlanOp.ts index 96a39deb5..611e2b42e 100644 --- a/shared/api/migrations/operations/customer/updatePlan/updatePlanOp.ts +++ b/shared/api/migrations/operations/customer/updatePlan/updatePlanOp.ts @@ -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", }, );