From 98423f5a88e216bfb4c519008ad5cb6de878d901 Mon Sep 17 00:00:00 2001 From: johnyeo Date: Thu, 28 May 2026 14:52:12 +0100 Subject: [PATCH] updated add + delete tests --- .../computePatchCustomerProductPlan.ts | 7 +- .../setup/patch/handleCustomizeUpdateItems.ts | 7 +- .../carryExisting/carryIdentity.ts | 38 +-- .../customerProductCarryGroups.ts | 5 - .../initPatchCustomerProduct.ts | 11 +- ...nitPatchedCustomerEntitlementsAndPrices.ts | 17 +- .../delete-add-carry.test.ts | 314 ++++++++++++++++++ .../update-items-interval-paid.test.ts | 74 ++++- .../common/customizePlan/customizePlanV1.ts | 48 +-- .../customer/updatePlan/updatePlanOp.ts | 22 +- 10 files changed, 461 insertions(+), 82 deletions(-) create mode 100644 server/tests/integration/billing/migrations-v2/update-plan-operation/complex-scenarios/delete-add-carry.test.ts diff --git a/server/src/internal/billing/v2/compute/computePatchPlan/computePatchCustomerProductPlan.ts b/server/src/internal/billing/v2/compute/computePatchPlan/computePatchCustomerProductPlan.ts index 906ed69e9..b9234fdec 100644 --- a/server/src/internal/billing/v2/compute/computePatchPlan/computePatchCustomerProductPlan.ts +++ b/server/src/internal/billing/v2/compute/computePatchPlan/computePatchCustomerProductPlan.ts @@ -21,7 +21,11 @@ export const computePatchCustomerProductPlan = ({ throw new Error("Patch context is required to compute patch customer plan"); } - const { finalCustomerProduct, customerProductUpdates } = + const { + finalCustomerProduct, + customerProductUpdates, + oneOffPrepaidCarryOverCustomerEntitlements, + } = initPatchCustomerProduct({ ctx, billingContext: updateSubscriptionContext, @@ -43,6 +47,7 @@ export const computePatchCustomerProductPlan = ({ customEntitlements: patchContext.customEntitlements, customFreeTrial: trialContext?.customFreeTrial, lineItems: allLineItems, + insertCustomerEntitlements: oneOffPrepaidCarryOverCustomerEntitlements, } satisfies Partial; if (patchContext.mode === "new") { diff --git a/server/src/internal/billing/v2/setup/patch/handleCustomizeUpdateItems.ts b/server/src/internal/billing/v2/setup/patch/handleCustomizeUpdateItems.ts index ecf4319a0..a929778d8 100644 --- a/server/src/internal/billing/v2/setup/patch/handleCustomizeUpdateItems.ts +++ b/server/src/internal/billing/v2/setup/patch/handleCustomizeUpdateItems.ts @@ -18,7 +18,6 @@ import type { import { planItemFilterMatchesCustomerPair } from "@shared/api/products/items/utils/match"; import { cusEntToCusPrice } from "@shared/utils/cusEntUtils/convertCusEntUtils/cusEntToCusPrice"; import { customerPriceToCustomerEntitlement } from "@shared/utils/cusPriceUtils/convertCustomerPrice/customerPriceToCustomerEntitlement"; -import { isOneOffPrice } from "@shared/utils/productUtils/priceUtils/classifyPriceUtils"; import { StatusCodes } from "http-status-codes"; import { generateId } from "@/utils/genUtils"; @@ -64,13 +63,11 @@ const assertAllowedIntervalUpdate = ({ customerPrice?: FullCustomerPrice; overrides: UpdatePlanItemParamsV1; }) => { - if (overrides.interval === undefined || overrides.interval === ResetInterval.OneOff) - return; - if (!customerPrice || !isOneOffPrice(customerPrice.price)) return; + if (overrides.interval === undefined || !customerPrice) return; throw new RecaseError({ message: - "update_items cannot change intervals for one-off paid items. Use remove_items and add_items instead.", + "update_items cannot change intervals for paid items. Use remove_items and add_items instead.", code: ErrCode.InvalidProductItem, statusCode: StatusCodes.BAD_REQUEST, }); diff --git a/server/src/internal/billing/v2/utils/initFullCustomerProduct/carryExisting/carryIdentity.ts b/server/src/internal/billing/v2/utils/initFullCustomerProduct/carryExisting/carryIdentity.ts index 4bfd33462..2e887f31b 100644 --- a/server/src/internal/billing/v2/utils/initFullCustomerProduct/carryExisting/carryIdentity.ts +++ b/server/src/internal/billing/v2/utils/initFullCustomerProduct/carryExisting/carryIdentity.ts @@ -1,55 +1,21 @@ -import { - EntInterval, - type FullCusEntWithFullCusProduct, - type FullCusProduct, - type FullCustomerEntitlement, -} from "@autumn/shared"; -import { cusEntToCusPrice } from "@shared/utils/cusEntUtils/convertCusEntUtils/cusEntToCusPrice"; -import { priceToBillingMethod } from "@shared/utils/productUtils/priceUtils/convertPriceUtils"; +import type { FullCustomerEntitlement } from "@autumn/shared"; export type CustomerEntitlementCarryIdentity = { internalFeatureId: string; - interval: string; - intervalCount: number; - entityFeatureId: string | null; - billingMethod: string | null; }; export const carryIdentityToKey = ( identity: CustomerEntitlementCarryIdentity, -) => - [ - identity.internalFeatureId, - identity.interval, - identity.intervalCount, - identity.entityFeatureId ?? "", - identity.billingMethod ?? "", - ].join(":"); +) => identity.internalFeatureId; export const customerEntitlementToCarryIdentity = ({ customerEntitlement, - customerProduct, }: { customerEntitlement: FullCustomerEntitlement; - customerProduct: FullCusProduct; }): CustomerEntitlementCarryIdentity => { - const customerEntitlementWithProduct = { - ...customerEntitlement, - customer_product: customerProduct, - } satisfies FullCusEntWithFullCusProduct; - const customerPrice = cusEntToCusPrice({ - cusEnt: customerEntitlementWithProduct, - }); const entitlement = customerEntitlement.entitlement; return { internalFeatureId: entitlement.internal_feature_id, - interval: - customerPrice?.price.config.interval ?? - entitlement.interval ?? - EntInterval.Lifetime, - intervalCount: entitlement.interval_count ?? 1, - entityFeatureId: entitlement.entity_feature_id ?? null, - billingMethod: priceToBillingMethod({ price: customerPrice?.price }) ?? null, }; }; diff --git a/server/src/internal/billing/v2/utils/initFullCustomerProduct/carryExisting/customerProductCarryGroups.ts b/server/src/internal/billing/v2/utils/initFullCustomerProduct/carryExisting/customerProductCarryGroups.ts index 81e335ed1..f682ebd01 100644 --- a/server/src/internal/billing/v2/utils/initFullCustomerProduct/carryExisting/customerProductCarryGroups.ts +++ b/server/src/internal/billing/v2/utils/initFullCustomerProduct/carryExisting/customerProductCarryGroups.ts @@ -30,10 +30,8 @@ const addToGroup = (groups: Map, key: string, value: T) => { }; const groupCustomerEntitlementsByCarryIdentity = ({ - customerProduct, customerEntitlements, }: { - customerProduct: FullCusProduct; customerEntitlements: FullCustomerEntitlement[]; }) => { const customerEntitlementsByKey = new Map< @@ -45,7 +43,6 @@ const groupCustomerEntitlementsByCarryIdentity = ({ const key = carryIdentityToKey( customerEntitlementToCarryIdentity({ customerEntitlement, - customerProduct, }), ); addToGroup(customerEntitlementsByKey, key, customerEntitlement); @@ -84,11 +81,9 @@ const getIdentityCustomerProductCarryGroups = ({ fromCustomerEntitlements: FullCustomerEntitlement[]; }): CustomerProductCarryGroup[] => { const toEntitlementsByKey = groupCustomerEntitlementsByCarryIdentity({ - customerProduct: toCustomerProduct, customerEntitlements: toCustomerProduct.customer_entitlements, }); const fromEntitlementsByKey = groupCustomerEntitlementsByCarryIdentity({ - customerProduct: fromCustomerProduct, customerEntitlements: fromCustomerEntitlements, }); diff --git a/server/src/internal/billing/v2/utils/initFullCustomerProduct/initPatchedCustomerProduct/initPatchCustomerProduct.ts b/server/src/internal/billing/v2/utils/initFullCustomerProduct/initPatchedCustomerProduct/initPatchCustomerProduct.ts index ba166529b..35a03219b 100644 --- a/server/src/internal/billing/v2/utils/initFullCustomerProduct/initPatchedCustomerProduct/initPatchCustomerProduct.ts +++ b/server/src/internal/billing/v2/utils/initFullCustomerProduct/initPatchedCustomerProduct/initPatchCustomerProduct.ts @@ -1,6 +1,7 @@ import { type AutumnBillingPlan, cusProductToProduct, + type InsertCustomerEntitlement, type PatchContext, type TrialContext, type UpdateSubscriptionBillingContext, @@ -68,8 +69,14 @@ export const initPatchCustomerProduct = ({ }): { finalCustomerProduct: PatchContext["finalCustomerProduct"]; customerProductUpdates: CustomerProductUpdates; + oneOffPrepaidCarryOverCustomerEntitlements: InsertCustomerEntitlement[]; } => { - const { customerPrices, customerEntitlements } = + const { + customerPrices, + customerEntitlements, + oneOffPrepaidCarryOverEntitlements, + oneOffPrepaidCarryOverCustomerEntitlements, + } = initPatchedCustomerEntitlementsAndPrices({ ctx, billingContext, @@ -95,6 +102,7 @@ export const initPatchCustomerProduct = ({ }); patchContext.insertCustomerPrices = customerPrices; patchContext.insertCustomerEntitlements = customerEntitlements; + patchContext.customEntitlements.push(...oneOffPrepaidCarryOverEntitlements); patchContext.fullProduct = cusProductToProduct({ cusProduct: patchContext.finalCustomerProduct, }); @@ -116,5 +124,6 @@ export const initPatchCustomerProduct = ({ ...trialUpdates, ...customUpdates, }, + oneOffPrepaidCarryOverCustomerEntitlements, }; }; diff --git a/server/src/internal/billing/v2/utils/initFullCustomerProduct/initPatchedCustomerProduct/initPatchedCustomerEntitlementsAndPrices.ts b/server/src/internal/billing/v2/utils/initFullCustomerProduct/initPatchedCustomerProduct/initPatchedCustomerEntitlementsAndPrices.ts index c43150019..0d42c4167 100644 --- a/server/src/internal/billing/v2/utils/initFullCustomerProduct/initPatchedCustomerProduct/initPatchedCustomerEntitlementsAndPrices.ts +++ b/server/src/internal/billing/v2/utils/initFullCustomerProduct/initPatchedCustomerProduct/initPatchedCustomerEntitlementsAndPrices.ts @@ -1,6 +1,8 @@ import type { + Entitlement, FullCustomerEntitlement, FullCustomerPrice, + InsertCustomerEntitlement, PatchContext, UpdateSubscriptionBillingContext, } from "@autumn/shared"; @@ -33,6 +35,8 @@ export const initPatchedCustomerEntitlementsAndPrices = ({ }): { customerPrices: FullCustomerPrice[]; customerEntitlements: FullCustomerEntitlement[]; + oneOffPrepaidCarryOverEntitlements: Entitlement[]; + oneOffPrepaidCarryOverCustomerEntitlements: InsertCustomerEntitlement[]; } => { const { fullCustomer, @@ -115,6 +119,9 @@ export const initPatchedCustomerEntitlementsAndPrices = ({ return { fromCustomerEntitlement, toCustomerEntitlement }; }), }); + const oneOffPrepaidCarryOverEntitlements: Entitlement[] = []; + const oneOffPrepaidCarryOverCustomerEntitlements: InsertCustomerEntitlement[] = + []; for (const carryGroup of carryGroups) { applyExistingStatesToCustomerProduct({ @@ -132,15 +139,23 @@ export const initPatchedCustomerEntitlementsAndPrices = ({ }, }); - applyOneOffPrepaidCarryOvers({ + const oneOffPrepaidCarryOvers = applyOneOffPrepaidCarryOvers({ oldCustomerProduct: carryGroup.fromCustomerProduct, newCustomerProduct: carryGroup.toCustomerProduct, fullCustomer, }); + oneOffPrepaidCarryOverEntitlements.push( + ...oneOffPrepaidCarryOvers.entitlements, + ); + oneOffPrepaidCarryOverCustomerEntitlements.push( + ...oneOffPrepaidCarryOvers.customerEntitlements, + ); } return { customerPrices: customerProductWithNewItemsOnly.customer_prices, customerEntitlements: customerProductWithNewItemsOnly.customer_entitlements, + oneOffPrepaidCarryOverEntitlements, + oneOffPrepaidCarryOverCustomerEntitlements, }; }; diff --git a/server/tests/integration/billing/migrations-v2/update-plan-operation/complex-scenarios/delete-add-carry.test.ts b/server/tests/integration/billing/migrations-v2/update-plan-operation/complex-scenarios/delete-add-carry.test.ts new file mode 100644 index 000000000..5268baf88 --- /dev/null +++ b/server/tests/integration/billing/migrations-v2/update-plan-operation/complex-scenarios/delete-add-carry.test.ts @@ -0,0 +1,314 @@ +/** + * Contract: delete/add patch migrations carry same-feature usage, one-off prepaid balance, and reset anchors. + * These scenarios intentionally avoid update_items; item changes are remove_items + add_items. + */ + +import { expect, test } from "bun:test"; +import type { ApiCustomerV3, ApiCustomerV5 } from "@autumn/shared"; +import { + BillingMethod, + ResetInterval, +} from "@autumn/shared"; +import { expectCustomerInvoiceCorrect } from "@tests/integration/billing/utils/expectCustomerInvoiceCorrect"; +import { expectBalanceCorrect } from "@tests/integration/utils/expectBalanceCorrect"; +import { getBalanceBucket } from "@tests/integration/utils/getBalanceBucket"; +import { TestFeature } from "@tests/setup/v2Features"; +import { items } from "@tests/utils/fixtures/items"; +import { itemsV2 } from "@tests/utils/fixtures/itemsV2"; +import { products } from "@tests/utils/fixtures/products"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario"; +import chalk from "chalk"; +import { runUpdatePlanMigration } from "../../utils/runUpdatePlanMigration"; + +const TEN_MINUTES_MS = 10 * 60 * 1000; + +const expectCloseToMs = ({ + actual, + expected, +}: { + actual?: number | null; + expected: number; +}) => { + expect(actual).not.toBeNull(); + expect(Math.abs((actual ?? 0) - expected)).toBeLessThanOrEqual( + TEN_MINUTES_MS, + ); +}; + +test.concurrent(`${chalk.yellowBright("migrations complex delete/add: lifetime item to monthly carries usage onto subscription reset")}`, async () => { + const customerId = "migration-complex-lifetime-to-monthly"; + const pro = products.pro({ + id: "migration-complex-lifetime-to-monthly-plan", + items: [items.lifetimeMessages({ includedUsage: 100 })], + }); + + const { autumnV1, autumnV2_2, ctx } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [pro] }), + ], + actions: [ + s.billing.attach({ productId: pro.id }), + s.advanceTestClock({ days: 10 }), + s.track({ featureId: TestFeature.Messages, value: 40, timeout: 2000 }), + ], + }); + const before = await autumnV2_2.customers.get(customerId); + const currentPeriodEnd = before.subscriptions.find( + (subscription) => subscription.plan_id === pro.id, + )?.current_period_end; + expect(currentPeriodEnd).not.toBeNull(); + const invoiceCountBefore = + (await autumnV1.customers.get(customerId)).invoices?.length ?? + 0; + + await runUpdatePlanMigration({ + ctx, + migrationClient: autumnV2_2, + migrationId: `${customerId}-mig`, + customerId, + filter: { customer: { plan: { plan_id: pro.id } } }, + operations: { + customer: [ + { + type: "update_plan", + plan_filter: { plan_id: pro.id }, + customize: { + remove_items: [{ feature_id: TestFeature.Messages }], + add_items: [itemsV2.monthlyMessages({ included: 150 })], + }, + }, + ], + }, + runOnServer: false, + noBillingChanges: true, + }); + + const customer = await autumnV2_2.customers.get(customerId); + expectBalanceCorrect({ + customer, + featureId: TestFeature.Messages, + remaining: 110, + usage: 40, + nextResetAt: currentPeriodEnd!, + planId: pro.id, + breakdown: { + [ResetInterval.Month]: { + included_grant: 150, + remaining: 110, + usage: 40, + }, + }, + }); + const monthlyBucket = getBalanceBucket({ + subject: customer, + featureId: TestFeature.Messages, + resetInterval: ResetInterval.Month, + }); + expectCloseToMs({ + actual: monthlyBucket.reset?.resets_at, + expected: currentPeriodEnd!, + }); + await expectCustomerInvoiceCorrect({ + customer: await autumnV1.customers.get(customerId), + count: invoiceCountBefore, + }); +}); + +test.concurrent(`${chalk.yellowBright("migrations complex delete/add: monthly plus one-off prepaid to monthly carries usage and lifetime balance")}`, async () => { + const customerId = "migration-complex-monthly-oneoff-to-monthly"; + const pro = products.pro({ + id: "migration-complex-monthly-oneoff-to-monthly-plan", + items: [ + items.monthlyMessages({ includedUsage: 100 }), + items.oneOffMessages({ includedUsage: 0, billingUnits: 100, price: 10 }), + ], + }); + + const { autumnV1, autumnV2_2, ctx } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [pro] }), + ], + actions: [ + s.attach({ + productId: pro.id, + options: [{ feature_id: TestFeature.Messages, quantity: 200 }], + }), + s.track({ featureId: TestFeature.Messages, value: 150, timeout: 2000 }), + ], + }); + const before = await autumnV2_2.customers.get(customerId); + const currentPeriodEnd = before.subscriptions.find( + (subscription) => subscription.plan_id === pro.id, + )?.current_period_end; + expect(currentPeriodEnd).not.toBeNull(); + const invoiceCountBefore = + (await autumnV1.customers.get(customerId)).invoices?.length ?? + 0; + + await runUpdatePlanMigration({ + ctx, + migrationClient: autumnV2_2, + migrationId: `${customerId}-mig`, + customerId, + filter: { customer: { plan: { plan_id: pro.id } } }, + operations: { + customer: [ + { + type: "update_plan", + plan_filter: { plan_id: pro.id }, + customize: { + remove_items: [{ feature_id: TestFeature.Messages }], + add_items: [itemsV2.monthlyMessages({ included: 300 })], + }, + }, + ], + }, + runOnServer: false, + noBillingChanges: true, + }); + + const customer = await autumnV2_2.customers.get(customerId); + expectBalanceCorrect({ + customer, + featureId: TestFeature.Messages, + remaining: 350, + usage: 100, + nextResetAt: currentPeriodEnd!, + planId: pro.id, + breakdown: { + [ResetInterval.Month]: { + included_grant: 300, + remaining: 200, + usage: 100, + }, + [ResetInterval.OneOff]: { + included_grant: 150, + prepaid_grant: 0, + remaining: 150, + usage: 0, + }, + }, + }); + const monthlyBucket = getBalanceBucket({ + subject: customer, + featureId: TestFeature.Messages, + resetInterval: ResetInterval.Month, + }); + expectCloseToMs({ + actual: monthlyBucket.reset?.resets_at, + expected: currentPeriodEnd!, + }); + await expectCustomerInvoiceCorrect({ + customer: await autumnV1.customers.get(customerId), + count: invoiceCountBefore, + }); +}); + +test.concurrent(`${chalk.yellowBright("migrations complex delete/add: monthly included increase plus one-off price change preserves both buckets")}`, async () => { + const customerId = "migration-complex-monthly-oneoff-price-change"; + const pro = products.pro({ + id: "migration-complex-monthly-oneoff-price-change-plan", + items: [ + items.monthlyMessages({ includedUsage: 100 }), + items.oneOffMessages({ includedUsage: 0, billingUnits: 100, price: 10 }), + ], + }); + + const { autumnV1, autumnV2_2, ctx } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [pro] }), + ], + actions: [ + s.attach({ + productId: pro.id, + options: [{ feature_id: TestFeature.Messages, quantity: 200 }], + }), + s.track({ featureId: TestFeature.Messages, value: 150, timeout: 2000 }), + ], + }); + const before = await autumnV2_2.customers.get(customerId); + const currentPeriodEnd = before.subscriptions.find( + (subscription) => subscription.plan_id === pro.id, + )?.current_period_end; + expect(currentPeriodEnd).not.toBeNull(); + const invoiceCountBefore = + (await autumnV1.customers.get(customerId)).invoices?.length ?? + 0; + + await runUpdatePlanMigration({ + ctx, + migrationClient: autumnV2_2, + migrationId: `${customerId}-mig`, + customerId, + filter: { customer: { plan: { plan_id: pro.id } } }, + operations: { + customer: [ + { + type: "update_plan", + plan_filter: { plan_id: pro.id }, + customize: { + remove_items: [{ feature_id: TestFeature.Messages }], + add_items: [ + itemsV2.monthlyMessages({ included: 300 }), + itemsV2.oneOffPrepaidMessages({ + amount: 15, + billingUnits: 100, + }), + ], + }, + }, + ], + }, + runOnServer: false, + noBillingChanges: true, + }); + + const customer = await autumnV2_2.customers.get(customerId); + expectBalanceCorrect({ + customer, + featureId: TestFeature.Messages, + remaining: 350, + usage: 100, + nextResetAt: currentPeriodEnd!, + planId: pro.id, + breakdown: { + [ResetInterval.Month]: { + included_grant: 300, + remaining: 200, + usage: 100, + }, + [BillingMethod.Prepaid]: { + included_grant: 150, + prepaid_grant: 0, + remaining: 150, + usage: 0, + }, + }, + }); + const monthlyBucket = getBalanceBucket({ + subject: customer, + featureId: TestFeature.Messages, + resetInterval: ResetInterval.Month, + }); + const prepaidBucket = getBalanceBucket({ + subject: customer, + featureId: TestFeature.Messages, + billingMethod: BillingMethod.Prepaid, + }); + expectCloseToMs({ + actual: monthlyBucket.reset?.resets_at, + expected: currentPeriodEnd!, + }); + expect(prepaidBucket.reset?.interval).toBe(ResetInterval.OneOff); + expect(prepaidBucket.price?.amount).toBe(15); + await expectCustomerInvoiceCorrect({ + customer: await autumnV1.customers.get(customerId), + count: invoiceCountBefore, + }); +}); diff --git a/server/tests/integration/billing/migrations-v2/update-plan-operation/update-items/update-interval/update-items-interval-paid.test.ts b/server/tests/integration/billing/migrations-v2/update-plan-operation/update-items/update-interval/update-items-interval-paid.test.ts index 771e1c6ea..c548cf842 100644 --- a/server/tests/integration/billing/migrations-v2/update-plan-operation/update-items/update-interval/update-items-interval-paid.test.ts +++ b/server/tests/integration/billing/migrations-v2/update-plan-operation/update-items/update-interval/update-items-interval-paid.test.ts @@ -6,6 +6,7 @@ import { } from "@autumn/shared"; import { expectBalanceCorrect } from "@tests/integration/utils/expectBalanceCorrect"; import { TestFeature } from "@tests/setup/v2Features"; +import { items } from "@tests/utils/fixtures/items"; import { products } from "@tests/utils/fixtures/products"; import { initScenario, s } from "@tests/utils/testInitUtils/initScenario"; import chalk from "chalk"; @@ -83,6 +84,77 @@ test.concurrent(`${chalk.yellowBright("migrations update_items interval: subscri }); }); +test.concurrent(`${chalk.yellowBright("migrations update_items interval: monthly paid item interval changes are rejected")}`, async () => { + const customerId = "migration-update-items-monthly-paid-rejected"; + const base = products.base({ + id: "migration-update-items-monthly-paid-rejected-plan", + items: [ + items.prepaid({ + featureId: TestFeature.Credits, + includedUsage: 100, + billingUnits: 100, + price: 10, + }), + items.consumableMessages({ includedUsage: 50, price: 0.1 }), + ], + }); + + const { autumnV2_2, ctx } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [base] }), + ], + actions: [s.billing.attach({ productId: base.id })], + }); + + const cases = [ + { + name: "prepaid", + filter: { + feature_id: TestFeature.Credits, + billing_method: BillingMethod.Prepaid, + }, + }, + { + name: "usage-based", + filter: { + feature_id: TestFeature.Messages, + billing_method: BillingMethod.UsageBased, + }, + }, + ]; + + for (const testCase of cases) { + await expect( + runUpdatePlanMigration({ + ctx, + migrationClient: autumnV2_2, + migrationId: `${customerId}-${testCase.name}-mig`, + customerId, + filter: { customer: { plan: { plan_id: base.id } } }, + operations: { + customer: [ + { + type: "update_plan", + plan_filter: { plan_id: base.id }, + customize: { + update_items: [ + { + filter: testCase.filter, + interval: ResetInterval.OneOff, + }, + ], + }, + }, + ], + }, + runOnServer: false, + }), + ).rejects.toThrow(/paid items/i); + } +}); + test.concurrent(`${chalk.yellowBright("migrations update_items interval: one-off prepaid interval changes are rejected")}`, async () => { const customerId = "migration-update-items-one-off-prepaid-rejected"; const oneOffPrepaid = constructPrepaidItem({ @@ -134,5 +206,5 @@ test.concurrent(`${chalk.yellowBright("migrations update_items interval: one-off }, runOnServer: false, }), - ).rejects.toThrow(/one-off paid/i); + ).rejects.toThrow(/paid items/i); }); diff --git a/shared/api/billing/common/customizePlan/customizePlanV1.ts b/shared/api/billing/common/customizePlan/customizePlanV1.ts index 270faf07b..9ff2f6b7f 100644 --- a/shared/api/billing/common/customizePlan/customizePlanV1.ts +++ b/shared/api/billing/common/customizePlan/customizePlanV1.ts @@ -19,12 +19,13 @@ export const UpdatePlanItemParamsV1Schema = z description: "Override the matched item's reset interval. Use 'one_off' for non-resetting balances.", }), - }) - .meta({ - title: "UpdatePlanItem", - description: - "Patch an existing plan item in place. Supports included and interval changes.", - }); + }) + .meta({ + title: "UpdatePlanItem", + description: + "Deprecated. Use remove_items and add_items to replace plan items.", + deprecated: true, + }); export type UpdatePlanItemParamsV1 = z.infer; @@ -34,21 +35,22 @@ export const CustomizePlanV1Schema = z description: "Override the base price of the plan. Pass null to remove the base price.", }), - items: z.array(CreatePlanItemParamsV1Schema).optional().meta({ - description: - "Override the items in the plan (PUT-style — replaces all existing items). Mutually exclusive with add_items / remove_items / update_items.", - }), + items: z.array(CreatePlanItemParamsV1Schema).optional().meta({ + description: + "Override the items in the plan (PUT-style — replaces all existing items). Mutually exclusive with add_items / remove_items / deprecated update_items.", + }), add_items: z.array(CreatePlanItemParamsV1Schema).optional().meta({ description: "Items to add to the plan.", }), remove_items: z.array(PlanItemFilterSchema).optional().meta({ description: "Filters selecting items to remove from the plan.", }), - update_items: z.array(UpdatePlanItemParamsV1Schema).optional().meta({ - description: - "Patch existing matched plan items. Runs before add_items, after remove_items.", - internal: true, - }), + update_items: z.array(UpdatePlanItemParamsV1Schema).optional().meta({ + description: + "Deprecated. Use remove_items and add_items to replace matched plan items.", + internal: true, + deprecated: true, + }), free_trial: FreeTrialParamsV1Schema.nullable().optional().meta({ description: "Override the plan's default free trial. Pass an object to set a custom trial, or null to remove the trial entirely.", @@ -62,10 +64,10 @@ export const CustomizePlanV1Schema = z data.add_items !== undefined || data.remove_items !== undefined || data.update_items !== undefined, - { - message: - "When using customize, at least one of price, items, add_items, remove_items, update_items, or free_trial must be provided", - }, + { + message: + "When using customize, at least one of price, items, add_items, remove_items, deprecated update_items, or free_trial must be provided", + }, ) .refine( (data) => @@ -75,10 +77,10 @@ export const CustomizePlanV1Schema = z data.remove_items !== undefined || data.update_items !== undefined) ), - { - message: - "customize.items (PUT-style) cannot be combined with add_items / remove_items / update_items (PATCH-style); pick one approach", - }, + { + message: + "customize.items (PUT-style) cannot be combined with add_items / remove_items / deprecated update_items (PATCH-style); pick one approach", + }, ) .meta({ title: "CustomizePlan", diff --git a/shared/api/migrations/operations/customer/updatePlan/updatePlanOp.ts b/shared/api/migrations/operations/customer/updatePlan/updatePlanOp.ts index 611e2b42e..4f6f76171 100644 --- a/shared/api/migrations/operations/customer/updatePlan/updatePlanOp.ts +++ b/shared/api/migrations/operations/customer/updatePlan/updatePlanOp.ts @@ -7,10 +7,14 @@ import { PlanFilterSchema } from "../../../filters/planFilter.js"; export const MigrationUpdatePlanCustomizeSchema = z .object({ - price: BasePriceParamsSchema.nullable().optional(), - add_items: z.array(CreatePlanItemParamsV1Schema).optional(), - remove_items: z.array(PlanItemFilterSchema).optional(), - update_items: z.array(UpdatePlanItemParamsV1Schema).optional(), + price: BasePriceParamsSchema.nullable().optional(), + add_items: z.array(CreatePlanItemParamsV1Schema).optional(), + remove_items: z.array(PlanItemFilterSchema).optional(), + update_items: z.array(UpdatePlanItemParamsV1Schema).optional().meta({ + description: + "Deprecated. Use remove_items and add_items to replace matched plan items.", + deprecated: true, + }), }) .refine( (data) => @@ -18,11 +22,11 @@ export const MigrationUpdatePlanCustomizeSchema = z data.add_items !== undefined || data.remove_items !== undefined || data.update_items !== undefined, - { - message: - "update_plan.customize requires at least one of price, add_items, remove_items, or update_items", - }, - ); + { + message: + "update_plan.customize requires at least one of price, add_items, remove_items, or deprecated update_items", + }, + ); /** * Ordered customer operation: update every customer product matched by