diff --git a/server/src/internal/billing/v2/actions/updateSubscription/setup/setupUpdateSubscriptionProductContext.ts b/server/src/internal/billing/v2/actions/updateSubscription/setup/setupUpdateSubscriptionProductContext.ts index 6bf9e850e..1f52f6813 100644 --- a/server/src/internal/billing/v2/actions/updateSubscription/setup/setupUpdateSubscriptionProductContext.ts +++ b/server/src/internal/billing/v2/actions/updateSubscription/setup/setupUpdateSubscriptionProductContext.ts @@ -3,7 +3,6 @@ import { type FullCustomer, isCustomerProductFree, isFreeProduct, - notNullish, type UpdateSubscriptionBillingContextOverride, type UpdateSubscriptionV1Params, } from "@autumn/shared"; @@ -22,12 +21,14 @@ export const setupUpdateSubscriptionProductContext = async ({ params, contextOverride = {}, reusePricesAndEntitlements, + resetToCatalogVersion = false, }: { ctx: AutumnContext; fullCustomer: FullCustomer; params: UpdateSubscriptionV1Params; contextOverride?: UpdateSubscriptionBillingContextOverride; reusePricesAndEntitlements?: ReusePricesAndEntitlements; + resetToCatalogVersion?: boolean; }) => { const { productContext } = contextOverride; @@ -50,17 +51,22 @@ export const setupUpdateSubscriptionProductContext = async ({ }); let fullProduct = cusProductToProduct({ cusProduct: targetCustomerProduct }); + const requestedVersion = params.version; + const targetVersion = targetCustomerProduct.product.version; + const hasRequestedVersion = typeof requestedVersion === "number"; + const changesVersion = + hasRequestedVersion && + (requestedVersion < targetVersion || requestedVersion > targetVersion); + const shouldLoadCatalogVersion = + hasRequestedVersion && (resetToCatalogVersion || changesVersion); - if ( - notNullish(params.version) && - params.version !== targetCustomerProduct.product.version - ) { + if (shouldLoadCatalogVersion) { fullProduct = await ProductService.getFull({ db: ctx.db, idOrInternalId: targetCustomerProduct.product.id, orgId: ctx.org.id, env: ctx.env, - version: params.version, + version: requestedVersion, }); } diff --git a/server/src/internal/billing/v2/utils/billingChangeResponse/buildPlanChanges.ts b/server/src/internal/billing/v2/utils/billingChangeResponse/buildPlanChanges.ts index f424ab80d..054c51ca5 100644 --- a/server/src/internal/billing/v2/utils/billingChangeResponse/buildPlanChanges.ts +++ b/server/src/internal/billing/v2/utils/billingChangeResponse/buildPlanChanges.ts @@ -2,12 +2,19 @@ import { type AutumnBillingPlan, CusProductStatus, type CustomerPlanChange, + customerEntitlementToFeatureId, + type FullCusProduct, } from "@autumn/shared"; import { buildPlanItemChanges } from "./buildPlanItemChanges"; import { buildPreviousAttributes } from "./buildPreviousAttributes"; import { cusProductStatusToPublicStatus } from "./cusProductStatusMapping"; import { toCustomerPlanSnapshot } from "./toCustomerPlanSnapshot"; +type PlanChangeEntry = { + change: CustomerPlanChange; + customerProduct?: FullCusProduct; +}; + const getChangePlanId = (change: CustomerPlanChange): string | undefined => change.subscription?.plan_id ?? change.purchase?.plan_id; @@ -38,6 +45,57 @@ const getUpdatedChangeMergeKey = ( } }; +const entitlementFeatureIds = (customerProduct: FullCusProduct) => + new Set( + customerProduct.customer_entitlements.map((customerEntitlement) => + customerEntitlementToFeatureId(customerEntitlement), + ), + ); + +const buildReplacementItemChanges = ({ + activated, + expired, +}: { + activated: PlanChangeEntry; + expired: PlanChangeEntry; +}): CustomerPlanChange["item_changes"] => { + const activatedProduct = activated.customerProduct; + const expiredProduct = expired.customerProduct; + if (activatedProduct === undefined || expiredProduct === undefined) { + return [ + ...(activated.change.item_changes ?? []), + ...(expired.change.item_changes ?? []), + ]; + } + + const activatedFeatureIds = entitlementFeatureIds(activatedProduct); + const expiredFeatureIds = entitlementFeatureIds(expiredProduct); + + return [ + ...buildPlanItemChanges({ + customerProduct: activatedProduct, + insertCustomerEntitlements: + activatedProduct.customer_entitlements.filter( + (customerEntitlement) => + expiredFeatureIds.has( + customerEntitlementToFeatureId(customerEntitlement), + ) === false, + ), + insertCustomerPrices: activatedProduct.customer_prices, + }), + ...buildPlanItemChanges({ + customerProduct: expiredProduct, + deleteCustomerEntitlements: expiredProduct.customer_entitlements.filter( + (customerEntitlement) => + activatedFeatureIds.has( + customerEntitlementToFeatureId(customerEntitlement), + ) === false, + ), + deleteCustomerPrices: expiredProduct.customer_prices, + }), + ]; +}; + /** * When a billing action updates a plan in-place, Autumn often creates a new * customer product (insertCustomerProducts) and expires the old one @@ -47,17 +105,20 @@ const getUpdatedChangeMergeKey = ( * reflects the logical operation. */ const collapseSamePlanIdPairs = ( - changes: CustomerPlanChange[], -): CustomerPlanChange[] => { + entries: PlanChangeEntry[], +): PlanChangeEntry[] => { const consumed = new Set(); - const result: CustomerPlanChange[] = []; + const result: PlanChangeEntry[] = []; - for (let i = 0; i < changes.length; i++) { + for (let i = 0; i < entries.length; i++) { if (consumed.has(i)) continue; - const change = changes[i]; + const entry = entries[i]; + const { change } = entry; - if (change.action !== "activated" && change.action !== "expired") { - result.push(change); + const canCollapse = + change.action === "activated" || change.action === "expired"; + if (canCollapse === false) { + result.push(entry); continue; } @@ -65,16 +126,17 @@ const collapseSamePlanIdPairs = ( const counterpartAction = change.action === "activated" ? "expired" : "activated"; - const pairIdx = changes.findIndex( - (other, j) => - j !== i && - !consumed.has(j) && - other.action === counterpartAction && - getChangePlanId(other) === planId, - ); + const pairIdx = entries.findIndex((other, j) => { + if (j === i) return false; + if (consumed.has(j)) return false; + return ( + other.change.action === counterpartAction && + getChangePlanId(other.change) === planId + ); + }); if (pairIdx < 0) { - result.push(change); + result.push(entry); continue; } @@ -84,15 +146,22 @@ const collapseSamePlanIdPairs = ( // the iterator, not as a pairing candidate). consumed.add(i); consumed.add(pairIdx); - const activatedChange = change.action === "activated" ? change : changes[pairIdx]; - const expiredChange = change.action === "expired" ? change : changes[pairIdx]; + const pair = entries[pairIdx]; + const activated = change.action === "activated" ? entry : pair; + const expired = change.action === "expired" ? entry : pair; result.push({ - action: "updated", - subscription: activatedChange.subscription, - purchase: activatedChange.purchase, - previous_attributes: expiredChange.previous_attributes, - item_changes: [], + customerProduct: activated.customerProduct, + change: { + action: "updated", + subscription: activated.change.subscription, + purchase: activated.change.purchase, + previous_attributes: expired.change.previous_attributes, + item_changes: buildReplacementItemChanges({ + activated, + expired, + }), + }, }); } @@ -100,35 +169,37 @@ const collapseSamePlanIdPairs = ( }; const mergeUpdatedPlanChanges = ( - changes: CustomerPlanChange[], -): CustomerPlanChange[] => { - const merged = new Map(); - const result: CustomerPlanChange[] = []; + entries: PlanChangeEntry[], +): PlanChangeEntry[] => { + const merged = new Map(); + const result: PlanChangeEntry[] = []; - for (const change of changes) { + for (const entry of entries) { + const { change } = entry; const mergeKey = getUpdatedChangeMergeKey(change); - if (change.action !== "updated" || !mergeKey) { - result.push(change); + if (change.action === "updated" && mergeKey) { + const existing = merged.get(mergeKey); + if (existing) { + existing.change.subscription = + existing.change.subscription ?? change.subscription; + existing.change.purchase = existing.change.purchase ?? change.purchase; + existing.change.previous_attributes = { + ...(existing.change.previous_attributes ?? {}), + ...(change.previous_attributes ?? {}), + }; + existing.change.item_changes = [ + ...(existing.change.item_changes ?? []), + ...(change.item_changes ?? []), + ]; + continue; + } + + merged.set(mergeKey, entry); + result.push(entry); continue; } - const existing = merged.get(mergeKey); - if (!existing) { - merged.set(mergeKey, change); - result.push(change); - continue; - } - - existing.subscription = existing.subscription ?? change.subscription; - existing.purchase = existing.purchase ?? change.purchase; - existing.previous_attributes = { - ...(existing.previous_attributes ?? {}), - ...(change.previous_attributes ?? {}), - }; - existing.item_changes = [ - ...(existing.item_changes ?? []), - ...(change.item_changes ?? []), - ]; + result.push(entry); } return result; @@ -139,18 +210,21 @@ export const buildPlanChanges = ({ }: { autumnBillingPlan: AutumnBillingPlan; }): CustomerPlanChange[] => { - const changes: CustomerPlanChange[] = []; + const entries: PlanChangeEntry[] = []; for (const cusProduct of autumnBillingPlan.insertCustomerProducts ?? []) { const action = cusProduct.status === CusProductStatus.Scheduled ? "scheduled" : "activated"; - changes.push({ - action, - ...toCustomerPlanSnapshot({ cusProduct }), - previous_attributes: null, - item_changes: [], + entries.push({ + customerProduct: cusProduct, + change: { + action, + ...toCustomerPlanSnapshot({ cusProduct }), + previous_attributes: null, + item_changes: [], + }, }); } @@ -188,36 +262,44 @@ export const buildPlanChanges = ({ action = "updated"; } - changes.push({ - action, - ...toCustomerPlanSnapshot({ - cusProduct: originalCusProduct, - overrides: { - status: update.updates.status, - canceled_at: update.updates.canceled_at, - ended_at: update.updates.ended_at, - trial_ends_at: update.updates.trial_ends_at, - }, - }), - previous_attributes: previousAttributes, - item_changes: [], + entries.push({ + customerProduct: originalCusProduct, + change: { + action, + ...toCustomerPlanSnapshot({ + cusProduct: originalCusProduct, + overrides: { + status: update.updates.status, + canceled_at: update.updates.canceled_at, + ended_at: update.updates.ended_at, + trial_ends_at: update.updates.trial_ends_at, + }, + }), + previous_attributes: previousAttributes, + item_changes: [], + }, }); } for (const patch of autumnBillingPlan.patchCustomerProducts ?? []) { - changes.push({ - action: "updated", - ...toCustomerPlanSnapshot({ cusProduct: patch.customerProduct }), - previous_attributes: {}, - item_changes: buildPlanItemChanges({ - customerProduct: patch.customerProduct, - insertCustomerEntitlements: patch.insertCustomerEntitlements, - deleteCustomerEntitlements: patch.deleteCustomerEntitlements, - insertCustomerPrices: patch.insertCustomerPrices, - deleteCustomerPrices: patch.deleteCustomerPrices, - }), + entries.push({ + customerProduct: patch.customerProduct, + change: { + action: "updated", + ...toCustomerPlanSnapshot({ cusProduct: patch.customerProduct }), + previous_attributes: {}, + item_changes: buildPlanItemChanges({ + customerProduct: patch.customerProduct, + insertCustomerEntitlements: patch.insertCustomerEntitlements, + deleteCustomerEntitlements: patch.deleteCustomerEntitlements, + insertCustomerPrices: patch.insertCustomerPrices, + deleteCustomerPrices: patch.deleteCustomerPrices, + }), + }, }); } - return mergeUpdatedPlanChanges(collapseSamePlanIdPairs(changes)); + return mergeUpdatedPlanChanges(collapseSamePlanIdPairs(entries)).map( + (entry) => entry.change, + ); }; 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 6507f18d4..138c38ad6 100644 --- a/server/src/internal/migrations/v2/operations/updatePlan/setup/setupUpdatePlanProductContext.ts +++ b/server/src/internal/migrations/v2/operations/updatePlan/setup/setupUpdatePlanProductContext.ts @@ -105,6 +105,7 @@ export const setupUpdatePlanProductContext = async ({ fullCustomer: productFullCustomer, params, reusePricesAndEntitlements, + resetToCatalogVersion: typeof preparedOp.version === "number", }); const operationBillingContext = await setupMigrationOperationBillingContext({ diff --git a/server/src/internal/migrations/v2/run/preProcess/preProcessMigration.ts b/server/src/internal/migrations/v2/run/preProcess/preProcessMigration.ts index 1776d74ca..bff079644 100644 --- a/server/src/internal/migrations/v2/run/preProcess/preProcessMigration.ts +++ b/server/src/internal/migrations/v2/run/preProcess/preProcessMigration.ts @@ -17,7 +17,10 @@ export const preProcessMigration = ( migration: M, ): M => { const operations = migration.operations - ? preProcessMigrationOperations({ operations: migration.operations }) + ? preProcessMigrationOperations({ + operations: migration.operations, + filter: migration.filter, + }) : migration.operations; const filter = preProcessMigrationFilter({ operations: operations ?? undefined, diff --git a/server/src/internal/migrations/v2/run/preProcess/preProcessMigrationOperations.ts b/server/src/internal/migrations/v2/run/preProcess/preProcessMigrationOperations.ts index 530e867e2..9a12114e6 100644 --- a/server/src/internal/migrations/v2/run/preProcess/preProcessMigrationOperations.ts +++ b/server/src/internal/migrations/v2/run/preProcess/preProcessMigrationOperations.ts @@ -2,8 +2,42 @@ import type { CustomerOperation, CustomerOperations, } from "@autumn/shared/api/migrations/operations/customer/customerOperations.js"; +import type { MigrationFilter } from "@autumn/shared/api/migrations/filters/migrationFilter.js"; +import type { PlanFilter } from "@autumn/shared/api/migrations/filters/planFilter.js"; import type { Operations } from "@autumn/shared/api/migrations/operations/operations.js"; +type PlanQuantifier = { + $some?: PlanFilter; + $every?: PlanFilter; + $none?: PlanFilter; +}; + +const isPlanQuantifier = ( + plan: PlanFilter | PlanQuantifier, +): plan is PlanQuantifier => + "$some" in plan || "$every" in plan || "$none" in plan; + +const planFilterTargetsCustom = (plan: PlanFilter): boolean => + plan.custom === true || (plan.$or ?? []).some(planFilterTargetsCustom); + +const planTargetsCustom = (plan: PlanFilter | PlanQuantifier): boolean => { + if (isPlanQuantifier(plan)) { + return [plan.$some, plan.$every, plan.$none].some((inner) => { + if (inner === undefined) return false; + return planFilterTargetsCustom(inner); + }); + } + + return planFilterTargetsCustom(plan); +}; + +const filterTargetsCustom = (filter: MigrationFilter | null | undefined) => { + const customer = filter?.customer; + if (customer?.customer_id) return true; + if (customer?.plan === undefined) return false; + return planTargetsCustom(customer.plan); +}; + /** * Op-level guard. Any `update_plan` op that bumps `version` automatically * gets `plan_filter.custom: false` so admin-customized customer_products @@ -15,24 +49,37 @@ import type { Operations } from "@autumn/shared/api/migrations/operations/operat */ export const preProcessMigrationOperations = ({ operations, + filter, }: { operations: Operations; + filter?: MigrationFilter | null; }): Operations => { - if (!operations.customer) return operations; + if (operations.customer === undefined) return operations; + + const targetsCustom = filterTargetsCustom(filter); const customerOps: CustomerOperations = operations.customer.map( (op): CustomerOperation => { - if (op.type !== "update_plan") return op; - if (op.version === undefined) return op; - if (op.plan_filter.custom !== undefined) return op; + if (op.type === "update_plan") { + if (op.version === undefined) return op; + if ( + op.plan_filter.custom === true || + op.plan_filter.custom === false + ) { + return op; + } + if (targetsCustom) return op; - return { - ...op, - plan_filter: { - ...op.plan_filter, - custom: false, - }, - }; + return { + ...op, + plan_filter: { + ...op.plan_filter, + custom: false, + }, + }; + } + + return op; }, ); diff --git a/server/src/internal/product/actions/inPlaceUpdateUtils.ts b/server/src/internal/product/actions/inPlaceUpdateUtils.ts index b0c6a5f57..a1f55a3a4 100644 --- a/server/src/internal/product/actions/inPlaceUpdateUtils.ts +++ b/server/src/internal/product/actions/inPlaceUpdateUtils.ts @@ -76,18 +76,15 @@ const retireOrDeleteRows = async ({ db, priceIds, }); - - for (const entitlementId of entitlementIds) { - if (referencedEnts.has(entitlementId)) { - await EntitlementService.update({ - db, - id: entitlementId, - updates: { is_custom: true }, - }); - } else { - await EntitlementService.deleteInIds({ db, ids: [entitlementId] }); - } - } + const priceRows = await PriceService.getInIds({ db, ids: priceIds }); + const entitlementsReferencedByRetainedPrices = new Set( + priceRows + .flatMap((price) => + referencedPrices.has(price.id) && price.entitlement_id + ? [price.entitlement_id] + : [], + ), + ); for (const priceId of priceIds) { if (referencedPrices.has(priceId)) { @@ -100,6 +97,21 @@ const retireOrDeleteRows = async ({ await PriceService.deleteInIds({ db, ids: [priceId] }); } } + + for (const entitlementId of entitlementIds) { + if ( + referencedEnts.has(entitlementId) || + entitlementsReferencedByRetainedPrices.has(entitlementId) + ) { + await EntitlementService.update({ + db, + id: entitlementId, + updates: { is_custom: true }, + }); + } else { + await EntitlementService.deleteInIds({ db, ids: [entitlementId] }); + } + } }; /** diff --git a/server/tests/integration/billing/migrations-v2/update-plan-operation/selection/update-plan-op-custom.test.ts b/server/tests/integration/billing/migrations-v2/update-plan-operation/selection/update-plan-op-custom.test.ts index 2cd5d7577..afd7445e8 100644 --- a/server/tests/integration/billing/migrations-v2/update-plan-operation/selection/update-plan-op-custom.test.ts +++ b/server/tests/integration/billing/migrations-v2/update-plan-operation/selection/update-plan-op-custom.test.ts @@ -14,9 +14,24 @@ */ import { expect, test } from "bun:test"; -import type { ApiCustomerV3, ApiCustomerV5 } from "@autumn/shared"; +import { + type ApiCustomerV3, + type ApiCustomerV5, + CusProductStatus, + customerEntitlements, + customerPrices, + customerProducts, + customers, + entitlements, + features, + prices, + ResetInterval, +} from "@autumn/shared"; +import type { AutumnContext } from "@/honoUtils/HonoEnv"; +import { expectCustomerInvoiceCorrect } from "@tests/integration/billing/utils/expectCustomerInvoiceCorrect"; import { expectCustomerFeatureCorrect } from "@tests/integration/billing/utils/expectCustomerFeatureCorrect"; import { expectCustomerProducts } from "@tests/integration/billing/utils/expectCustomerProductCorrect"; +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"; @@ -24,8 +39,155 @@ 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 { and, eq, isNull } from "drizzle-orm"; import { runUpdatePlanMigration } from "../../utils/runUpdatePlanMigration"; +const getActiveCustomerProductIsCustom = async ({ + ctx, + customerId, + productId, +}: { + ctx: AutumnContext; + customerId: string; + productId: string; +}): Promise => { + const [row] = await ctx.db + .select({ isCustom: customerProducts.is_custom }) + .from(customerProducts) + .innerJoin( + customers, + eq(customerProducts.internal_customer_id, customers.internal_id), + ) + .where( + and( + eq(customers.org_id, ctx.org.id), + eq(customers.env, ctx.env), + eq(customers.id, customerId), + eq(customerProducts.product_id, productId), + eq(customerProducts.status, CusProductStatus.Active), + ), + ); + + return row?.isCustom; +}; + +const getActiveCustomerProductFeatureIds = async ({ + ctx, + customerId, + productId, +}: { + ctx: AutumnContext; + customerId: string; + productId: string; +}): Promise => { + const rows = await ctx.db + .select({ featureId: features.id }) + .from(customerProducts) + .innerJoin( + customers, + eq(customerProducts.internal_customer_id, customers.internal_id), + ) + .innerJoin( + customerEntitlements, + eq(customerEntitlements.customer_product_id, customerProducts.id), + ) + .innerJoin( + entitlements, + eq(customerEntitlements.entitlement_id, entitlements.id), + ) + .innerJoin(features, eq(entitlements.internal_feature_id, features.internal_id)) + .where( + and( + eq(customers.org_id, ctx.org.id), + eq(customers.env, ctx.env), + eq(customers.id, customerId), + eq(customerProducts.product_id, productId), + eq(customerProducts.status, CusProductStatus.Active), + ), + ); + + return rows.map((row) => row.featureId); +}; + +const getActiveBasePriceAmount = async ({ + ctx, + customerId, + productId, +}: { + ctx: AutumnContext; + customerId: string; + productId: string; +}): Promise => { + const [row] = await ctx.db + .select({ config: prices.config }) + .from(customerProducts) + .innerJoin( + customers, + eq(customerProducts.internal_customer_id, customers.internal_id), + ) + .innerJoin( + customerPrices, + eq(customerPrices.customer_product_id, customerProducts.id), + ) + .innerJoin(prices, eq(customerPrices.price_id, prices.id)) + .where( + and( + eq(customers.org_id, ctx.org.id), + eq(customers.env, ctx.env), + eq(customers.id, customerId), + eq(customerProducts.product_id, productId), + eq(customerProducts.status, CusProductStatus.Active), + isNull(prices.entitlement_id), + ), + ); + + const config = row?.config; + return config && "amount" in config && typeof config.amount === "number" + ? config.amount + : undefined; +}; + +const getActiveFeatureResetInterval = async ({ + ctx, + customerId, + productId, + featureId, +}: { + ctx: AutumnContext; + customerId: string; + productId: string; + featureId: string; +}): Promise => { + const [row] = await ctx.db + .select({ interval: entitlements.interval }) + .from(customerProducts) + .innerJoin( + customers, + eq(customerProducts.internal_customer_id, customers.internal_id), + ) + .innerJoin( + customerEntitlements, + eq(customerEntitlements.customer_product_id, customerProducts.id), + ) + .innerJoin( + entitlements, + eq(customerEntitlements.entitlement_id, entitlements.id), + ) + .innerJoin(features, eq(entitlements.internal_feature_id, features.internal_id)) + .where( + and( + eq(customers.org_id, ctx.org.id), + eq(customers.env, ctx.env), + eq(customers.id, customerId), + eq(customerProducts.product_id, productId), + eq(customerProducts.status, CusProductStatus.Active), + eq(features.id, featureId), + ), + ); + + return row?.interval; +}; + test.concurrent(`${chalk.yellowBright("update_plan custom: customer with is_custom plan is skipped")}`, async () => { const customerId = "migration-v2-custom-skip"; @@ -403,3 +565,183 @@ test.concurrent(`${chalk.yellowBright("update_plan custom: explicit `custom: tru usage: 0, }); }); + +test.concurrent(`${chalk.yellowBright("update_plan reset: same-version custom plan resets to catalog")}`, async () => { + const customerId = "migration-v2-same-version-custom-reset"; + const catalogBasePrice = 20; + const customBasePrice = 30; + const customMessages = { + ...itemsV2.monthlyMessages({ included: 850 }), + reset: { interval: ResetInterval.Hour }, + }; + + const pro = products.pro({ + id: "v2-same-version-reset-pro", + items: [ + items.monthlyMessages({ includedUsage: 500 }), + items.adminRights(), + ], + }); + + const { autumnV1, autumnV2_2, ctx } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [pro] }), + ], + actions: [s.billing.attach({ productId: pro.id })], + }); + + await autumnV2_2.subscriptions.update({ + customer_id: customerId, + plan_id: pro.id, + customize: { + price: itemsV2.monthlyPrice({ amount: customBasePrice }), + items: [customMessages, itemsV2.dashboard()], + }, + }); + let customer = await autumnV2_2.customers.get(customerId); + expectFlagCorrect({ + customer, + featureId: TestFeature.Dashboard, + present: true, + }); + expectFlagCorrect({ + customer, + featureId: TestFeature.AdminRights, + present: false, + }); + expect( + await getActiveCustomerProductIsCustom({ ctx, customerId, productId: pro.id }), + ).toBe(true); + expect( + await getActiveBasePriceAmount({ ctx, customerId, productId: pro.id }), + ).toBe(customBasePrice); + expect( + await getActiveFeatureResetInterval({ + ctx, + customerId, + productId: pro.id, + featureId: TestFeature.Messages, + }), + ).toBe(ResetInterval.Hour); + expectBalanceCorrect({ + customer, + featureId: TestFeature.Messages, + remaining: 850, + usage: 0, + planId: pro.id, + }); + const invoiceCountBefore = + (await autumnV1.customers.get(customerId)).invoices + ?.length ?? 0; + + await runUpdatePlanMigration({ + ctx, + migrationClient: autumnV2_2, + migrationId: `${customerId}-mig`, + customerId, + filter: { customer: { customer_id: customerId } }, + operations: { + customer: [ + { + type: "update_plan", + plan_filter: { plan_id: pro.id, version: 1 }, + version: 1, + }, + ], + }, + runOnServer: false, + noBillingChanges: true, + }); + + customer = await autumnV2_2.customers.get(customerId); + const featureIds = await getActiveCustomerProductFeatureIds({ + ctx, + customerId, + productId: pro.id, + }); + expect(featureIds).not.toContain(TestFeature.Dashboard); + expect(featureIds).toContain(TestFeature.AdminRights); + expect( + await getActiveBasePriceAmount({ ctx, customerId, productId: pro.id }), + ).toBe(catalogBasePrice); + expect( + await getActiveFeatureResetInterval({ + ctx, + customerId, + productId: pro.id, + featureId: TestFeature.Messages, + }), + ).toBe(ResetInterval.Month); + expectBalanceCorrect({ + customer, + featureId: TestFeature.Messages, + remaining: 500, + usage: 0, + planId: pro.id, + }); + await expectCustomerInvoiceCorrect({ + customer: await autumnV1.customers.get(customerId), + count: invoiceCountBefore, + }); +}); + +test.concurrent(`${chalk.yellowBright("update_plan reset: same-version regular plan stays non-custom")}`, async () => { + const customerId = "migration-v2-same-version-regular-reset"; + + const pro = products.pro({ + id: "v2-same-version-regular-pro", + items: [items.monthlyMessages({ includedUsage: 500 })], + }); + + 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: 100, timeout: 2000 }), + ], + }); + 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, version: 1 } } }, + operations: { + customer: [ + { + type: "update_plan", + plan_filter: { plan_id: pro.id, version: 1 }, + version: 1, + }, + ], + }, + runOnServer: false, + noBillingChanges: true, + }); + + const customer = await autumnV2_2.customers.get(customerId); + expectBalanceCorrect({ + customer, + featureId: TestFeature.Messages, + remaining: 400, + usage: 100, + planId: pro.id, + }); + expect( + await getActiveCustomerProductIsCustom({ ctx, customerId, productId: pro.id }), + ).toBe(false); + await expectCustomerInvoiceCorrect({ + customer: await autumnV1.customers.get(customerId), + count: invoiceCountBefore, + }); +}); diff --git a/server/tests/integration/crud/plans/update/in-place/in-place-update.test.ts b/server/tests/integration/crud/plans/update/in-place/in-place-update.test.ts index 17bb3b137..9d5961389 100644 --- a/server/tests/integration/crud/plans/update/in-place/in-place-update.test.ts +++ b/server/tests/integration/crud/plans/update/in-place/in-place-update.test.ts @@ -19,6 +19,7 @@ import { type ApiPlanV1, ApiVersion, BillingInterval, + BillingMethod, ResetInterval, type UpdatePlanParamsV2Input, } from "@autumn/shared"; @@ -33,6 +34,8 @@ import { AutumnRpcCli } from "@/external/autumn/autumnRpcCli.js"; import { ProductService } from "@/internal/products/ProductService.js"; import { snapshotCustomerState } from "./utils/snapshotCustomerState"; +type RpcInput = Omit; + const messagesEnt = async ({ ctx, planId, @@ -183,3 +186,44 @@ test(`${chalk.yellowBright("plans.update disable_version: respects requested ver 200, ); }); + +test(`${chalk.yellowBright("plans.update disable_version: UPDATE price-linked item keeps FK order valid")}`, async () => { + const customerId = "plan-in-place-update-priced-item"; + const pro = products.pro({ + id: "pro_in_place_update_priced_item", + items: [items.consumableMessages({ includedUsage: 0, price: 10 })], + }); + + const { ctx } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [pro] }), + ], + actions: [s.billing.attach({ productId: pro.id })], + }); + + const autumnRpc = new AutumnRpcCli({ + secretKey: ctx.orgSecretKey, + version: ApiVersion.V2_1, + }); + const before = await snapshotCustomerState({ ctx, customerId }); + + await autumnRpc.plans.update(pro.id, { + disable_version: true, + price: { amount: 20, interval: BillingInterval.Month }, + items: [ + { + feature_id: TestFeature.Messages, + price: { + amount: 12, + interval: BillingInterval.Month, + billing_method: BillingMethod.UsageBased, + billing_units: 1, + }, + }, + ], + }); + + expect(await snapshotCustomerState({ ctx, customerId })).toBe(before); +}); diff --git a/server/tests/scenarios/migrations/users-usage-scenario.test.ts b/server/tests/scenarios/migrations/users-usage-scenario.test.ts new file mode 100644 index 000000000..1ea5fcba5 --- /dev/null +++ b/server/tests/scenarios/migrations/users-usage-scenario.test.ts @@ -0,0 +1,47 @@ +import { test } from "bun:test"; +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"; + +/** + * Migration setup: users entitlement with existing usage. + * + * v1 $20/mo · 5 included users → cus migusers-v1 (used 4) + * v2 $20/mo · 10 included users (latest, no customer) + */ +test(`${chalk.yellowBright("migration-setup: users included with usage")}`, async () => { + const team = products.base({ + id: "team-users", + items: [ + items.monthlyPrice({ price: 20 }), + items.monthlyUsers({ includedUsage: 5 }), + ], + }); + + const { autumnV1 } = await initScenario({ + customerId: "migusers-v1", + setup: [ + s.customer({ paymentMethod: "success", testClock: false }), + s.products({ list: [team], prefix: "migusers" }), + ], + actions: [ + s.billing.attach({ productId: team.id }), + s.track({ featureId: TestFeature.Users, value: 4, timeout: 2000 }), + ], + }); + + await autumnV1.products.update(team.id, { + items: [ + items.monthlyPrice({ price: 20 }), + items.monthlyUsers({ includedUsage: 10 }), + ], + }); + + console.log( + chalk.green( + `[migration-setup] plan "${team.id}" has v1-v2. migusers-v1 is on v1 with 5 users included and 4 users used; latest is v2.`, + ), + ); +}, 20_000); diff --git a/server/tests/unit/billing/billing-change-response/update-subscription.test.ts b/server/tests/unit/billing/billing-change-response/update-subscription.test.ts index 7238203e5..6498723d7 100644 --- a/server/tests/unit/billing/billing-change-response/update-subscription.test.ts +++ b/server/tests/unit/billing/billing-change-response/update-subscription.test.ts @@ -326,4 +326,47 @@ describe("buildBillingChangeResponse — updateSubscription", () => { expired: ["pro"], }); }); + + test("collapse same-plan_id pairs preserves replacement item changes", () => { + const newPro = makeFullCusProduct({ + planId: "pro", + status: CusProductStatus.Active, + startedAt: NOW, + id: "cp_pro_new", + }); + newPro.customer_entitlements = [ + makeCustomerEntitlement({ featureId: "api_calls" }), + ]; + + const oldPro = makeFullCusProduct({ + planId: "pro", + startedAt: NOW - 30_000, + id: "cp_pro_old", + }); + oldPro.customer_entitlements = [ + makeCustomerEntitlement({ featureId: "legacy_feature" }), + ]; + + const response = buildBillingChangeResponse({ + ctx, + originalFullCustomer: makeFullCustomer({ customerProducts: [oldPro] }), + autumnBillingPlan: makeAutumnBillingPlan({ + inserts: [newPro], + update: makeUpdate({ + customerProduct: oldPro, + updates: { status: CusProductStatus.Expired }, + }), + }), + }); + + expectBillingChangeResponse(response, { updated: ["pro"] }); + expectPlanChange(findPlanChange(response, { action: "updated", planId: "pro" }), { + action: "updated", + planId: "pro", + itemChanges: [ + { action: "created", feature_id: "api_calls" }, + { action: "deleted", feature_id: "legacy_feature" }, + ], + }); + }); }); diff --git a/server/tests/unit/migrations-v2/pre-process-version-custom-guard.test.ts b/server/tests/unit/migrations-v2/pre-process-version-custom-guard.test.ts new file mode 100644 index 000000000..0288046b7 --- /dev/null +++ b/server/tests/unit/migrations-v2/pre-process-version-custom-guard.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, test } from "bun:test"; +import type { MigrationFilter, Operations, UpdatePlanOp } from "@autumn/shared"; +import { preProcessMigrationOperations } from "@/internal/migrations/v2/run/preProcess/preProcessMigrationOperations"; + +const operations: Operations = { + customer: [ + { + type: "update_plan", + plan_filter: { plan_id: "pro", version: 1 }, + version: 1, + }, + ], +}; + +const firstUpdatePlan = (ops: Operations): UpdatePlanOp => { + const op = ops.customer?.[0]; + if (op?.type === "update_plan") return op; + throw new Error("Expected first operation to update a plan"); +}; + +const process = (filter?: MigrationFilter) => + firstUpdatePlan(preProcessMigrationOperations({ operations, filter })); + +describe("preProcessMigrationOperations custom guard", () => { + test("defaults version migrations to non-custom plans", () => { + expect(process().plan_filter).toEqual({ + plan_id: "pro", + version: 1, + custom: false, + }); + }); + + test("keeps custom plans eligible when the migration targets one customer", () => { + expect( + process({ customer: { customer_id: "cus_1" } }).plan_filter, + ).toEqual({ + plan_id: "pro", + version: 1, + }); + }); + + test("keeps custom plans eligible when the filter explicitly targets custom", () => { + expect( + process({ customer: { plan: { plan_id: "pro", custom: true } } }) + .plan_filter, + ).toEqual({ + plan_id: "pro", + version: 1, + }); + }); + + test("keeps custom plans eligible through plan quantifiers and OR filters", () => { + expect( + process({ + customer: { + plan: { + $some: { + plan_id: "pro", + $or: [{ version: 1 }, { custom: true }], + }, + }, + }, + }).plan_filter, + ).toEqual({ + plan_id: "pro", + version: 1, + }); + }); +}); diff --git a/vite/src/views/main-sidebar/CollapsibleNavGroup.tsx b/vite/src/views/main-sidebar/CollapsibleNavGroup.tsx index f4b5ecb1f..b431e280f 100644 --- a/vite/src/views/main-sidebar/CollapsibleNavGroup.tsx +++ b/vite/src/views/main-sidebar/CollapsibleNavGroup.tsx @@ -19,6 +19,7 @@ interface SubTab { value: string; icon?: ReactNode; path?: string; + badge?: ReactNode; } interface CollapsibleNavGroupProps { @@ -117,6 +118,7 @@ export const CollapsibleNavGroup = ({ subValue={subTab.path ? undefined : subTab.value} icon={subTab.icon} title={keyToTitle(subTab.title)} + badge={subTab.badge} isSubNav /> ))} diff --git a/vite/src/views/main-sidebar/MainSidebar.tsx b/vite/src/views/main-sidebar/MainSidebar.tsx index 3433de499..1860c29a4 100644 --- a/vite/src/views/main-sidebar/MainSidebar.tsx +++ b/vite/src/views/main-sidebar/MainSidebar.tsx @@ -9,6 +9,7 @@ import { KeyIcon, LegoIcon, TerminalWindowIcon, + TestTubeIcon, TriangleIcon, UserCircleIcon, UsersIcon, @@ -18,6 +19,11 @@ import { PanelLeft } from "lucide-react"; import { useHotkeys } from "react-hotkeys-hook"; import { Button } from "@/components/v2/buttons/Button"; import { RevenueCatIcon, StripeIcon } from "@/components/v2/icons/AutumnIcons"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/v2/tooltips/Tooltip"; import { useAutumnFlags } from "@/hooks/common/useAutumnFlags"; import { useLocalStorage } from "@/hooks/common/useLocalStorage"; import { useScopes } from "@/hooks/useScopes"; @@ -213,6 +219,23 @@ export const MainSidebar = ({ value: "migrations", path: "/migrations", icon: , + badge: ( + + + } + /> + + Migrations are in beta. Get in touch with the team for + more complex migrations. + + + ), }, ]} /> diff --git a/vite/src/views/main-sidebar/NavButton.tsx b/vite/src/views/main-sidebar/NavButton.tsx index a4e975051..a08f17d88 100644 --- a/vite/src/views/main-sidebar/NavButton.tsx +++ b/vite/src/views/main-sidebar/NavButton.tsx @@ -1,6 +1,6 @@ import type { AppEnv } from "@autumn/shared"; import { ChevronRight } from "lucide-react"; -import { useState } from "react"; +import { type ReactNode, useState } from "react"; import { Link, useSearchParams } from "react-router"; import { useTab } from "@/hooks/common/useTab"; import { cn } from "@/lib/utils"; @@ -21,6 +21,7 @@ export const NavButton = ({ isOpen, isSubNav = false, isGroup = false, + badge, }: { value?: string; subValue?: string; @@ -34,6 +35,7 @@ export const NavButton = ({ isOpen?: boolean; isSubNav?: boolean; isGroup?: boolean; + badge?: ReactNode; }) => { // Get window path const finalEnv = useEnv(); @@ -67,6 +69,7 @@ export const NavButton = ({ > {title} + {badge && expanded && badge} {online && ( diff --git a/vite/src/views/main-sidebar/SidebarContact.tsx b/vite/src/views/main-sidebar/SidebarContact.tsx index 244792c8a..70f9fd2ce 100644 --- a/vite/src/views/main-sidebar/SidebarContact.tsx +++ b/vite/src/views/main-sidebar/SidebarContact.tsx @@ -60,10 +60,10 @@ export function SidebarContact() { } nativeButton={false}> } title="Contact us" onClick={() => {}} + isGroup /> diff --git a/vite/src/views/migrations/migration-list/MigrationListTable.tsx b/vite/src/views/migrations/migration-list/MigrationListTable.tsx index 4ba02ba9f..6c804e9bf 100644 --- a/vite/src/views/migrations/migration-list/MigrationListTable.tsx +++ b/vite/src/views/migrations/migration-list/MigrationListTable.tsx @@ -1,7 +1,13 @@ -import { ArrowsClockwiseIcon } from "@phosphor-icons/react"; +import { ArrowsClockwiseIcon, TestTubeIcon } from "@phosphor-icons/react"; import { useMemo } from "react"; import { Table } from "@/components/general/table"; +import { Badge } from "@/components/v2/badges/Badge"; import { EmptyState } from "@/components/v2/empty-states/EmptyState"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/v2/tooltips/Tooltip"; import { useMigrationsQuery, type MigrationWithRunInfo, @@ -71,6 +77,21 @@ export function MigrationListTable() { className="text-subtle" /> Migrations + + + + + Beta + + + + Migrations are in beta. Get in touch with the team for more + complex migrations. + +
diff --git a/vite/src/views/migrations/migration/operations/UpdatePlanOpForm.tsx b/vite/src/views/migrations/migration/operations/UpdatePlanOpForm.tsx index 13957d41d..764778784 100644 --- a/vite/src/views/migrations/migration/operations/UpdatePlanOpForm.tsx +++ b/vite/src/views/migrations/migration/operations/UpdatePlanOpForm.tsx @@ -111,6 +111,7 @@ export function UpdatePlanOpForm({ const customize = value.customize; const addItems = customize?.add_items ?? []; + const planVersionActionLabel = getPlanVersionActionLabel(value); const openSheet = (mode: OperationSheetMode, itemIndex?: number) => { setSheetMode(mode); @@ -312,9 +313,9 @@ export function UpdatePlanOpForm({ update({ version: 1 })} - > - Set Plan Version - + > + {planVersionActionLabel} + )} {(!customize || customize.price === undefined) && ( ({ + type: "update_plan", + plan_filter: { ...basePlanFilter, custom }, + version: latestVersion, + }); const filter: MigrationFilter = { customer: { plan: planFilter }, }; const operations: Operations = { - customer: [ - { - type: "update_plan", - plan_filter: planFilter, - version: latestVersion, - }, - ], - } as unknown as Operations; + customer: includeCustom + ? [versionOp(false), versionOp(true)] + : [versionOp(false)], + }; const suffix = scope === "all" ? "migrate-all" : `migrate-v${scope}`; @@ -206,18 +210,20 @@ export function buildMigrationDraft({ const hasCustomize = Object.keys(diff).length > 0; const customize = hasCustomize ? diff : undefined; - const planFilter = { + const basePlanFilter = { plan_id: baseProduct.id, ...(scope === "this_version" ? { version: baseProduct.version } : {}), - ...(!includeCustom ? { custom: false } : {}), }; - const updatePlanOp = { - type: "update_plan" as const, - plan_filter: planFilter, + const planFilter = includeCustom + ? basePlanFilter + : { ...basePlanFilter, custom: false }; + const updatePlanOp = (custom: boolean): UpdatePlanOp => ({ + type: "update_plan", + plan_filter: { ...basePlanFilter, custom }, ...(customize ? { customize } : {}), - }; + }); const filter: MigrationFilter = { customer: { plan: planFilter }, @@ -229,7 +235,11 @@ export function buildMigrationDraft({ return { id: `${baseProduct.id}-${suffix}-${migrationUid()}`, filter, - operations: { customer: [updatePlanOp] } as unknown as Operations, - no_billing_changes: !diffHasBillingChanges(diff), + operations: { + customer: includeCustom + ? [updatePlanOp(false), updatePlanOp(true)] + : [updatePlanOp(false)], + }, + no_billing_changes: diffHasBillingChanges(diff) === false, }; } diff --git a/vite/tests/views/migrations/migration/operations/update-plan-op-form.test.ts b/vite/tests/views/migrations/migration/operations/update-plan-op-form.test.ts new file mode 100644 index 000000000..341a6df6d --- /dev/null +++ b/vite/tests/views/migrations/migration/operations/update-plan-op-form.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, test } from "bun:test"; +import type { UpdatePlanOp } from "@autumn/shared"; +import { getPlanVersionActionLabel } from "@/views/migrations/migration/operations/UpdatePlanOpForm"; + +const op = (patch: Partial): UpdatePlanOp => ({ + type: "update_plan", + plan_filter: {}, + ...patch, +}); + +describe("UpdatePlanOpForm", () => { + test("labels same-version version operations as reset", () => { + expect( + getPlanVersionActionLabel( + op({ plan_filter: { version: 2 }, version: 2 }), + ), + ).toBe("Reset to Plan Version"); + }); + + test("keeps set label when operation version differs from filter version", () => { + expect( + getPlanVersionActionLabel( + op({ plan_filter: { version: 1 }, version: 2 }), + ), + ).toBe("Set Plan Version"); + }); + + test("uses the menu default version before a version is selected", () => { + expect(getPlanVersionActionLabel(op({ plan_filter: { version: 1 } }))).toBe( + "Reset to Plan Version", + ); + }); +}); diff --git a/vite/tests/views/products/plan/versioning/build-migration-draft.test.ts b/vite/tests/views/products/plan/versioning/build-migration-draft.test.ts index 940237df1..f3bd64513 100644 --- a/vite/tests/views/products/plan/versioning/build-migration-draft.test.ts +++ b/vite/tests/views/products/plan/versioning/build-migration-draft.test.ts @@ -9,10 +9,12 @@ import { UsageModel, type Feature, type FrontendProduct, + type UpdatePlanOp, } from "@autumn/shared"; import { buildMigrationDraft, buildVersionMigrationDraft, + type MigrationDraft, } from "@/views/products/plan/versioning/buildMigrationDraft"; const features: Feature[] = [ @@ -48,6 +50,17 @@ const baseProduct: FrontendProduct = { basePriceType: "free", }; +const updatePlanFilters = (draft: MigrationDraft) => + (draft.operations.customer ?? []) + .filter((op): op is UpdatePlanOp => op.type === "update_plan") + .map((op) => op.plan_filter); + +const firstUpdatePlan = (draft: MigrationDraft): UpdatePlanOp => { + const op = draft.operations.customer?.[0]; + if (op?.type === "update_plan") return op; + throw new Error("Expected first migration operation to update a plan"); +}; + describe("buildMigrationDraft", () => { test("excludes custom plans by default", () => { const draft = buildMigrationDraft({ @@ -62,14 +75,14 @@ describe("buildMigrationDraft", () => { version: 2, custom: false, }); - expect(draft.operations.customer?.[0]?.plan_filter).toMatchObject({ + expect(firstUpdatePlan(draft).plan_filter).toMatchObject({ plan_id: "pro", version: 2, custom: false, }); }); - test("includes custom plans when enabled", () => { + test("targets both regular and custom plans when custom plans are included", () => { const draft = buildMigrationDraft({ baseProduct, editedProduct: { ...baseProduct, name: "Pro updated" }, @@ -82,9 +95,56 @@ describe("buildMigrationDraft", () => { plan_id: "pro", version: 2, }); - expect(draft.operations.customer?.[0]?.plan_filter).toEqual({ + expect(updatePlanFilters(draft)).toEqual([ + { + plan_id: "pro", + version: 2, + custom: false, + }, + { + plan_id: "pro", + version: 2, + custom: true, + }, + ]); + }); + + test("keeps custom targeting explicit for version reset migrations", () => { + const draft = buildMigrationDraft({ + baseProduct, + editedProduct: baseProduct, + features, + scope: "this_version", + includeCustom: true, + }); + + expect(updatePlanFilters(draft)).toEqual([ + { + plan_id: "pro", + version: 2, + custom: false, + }, + { + plan_id: "pro", + version: 2, + custom: true, + }, + ]); + }); + + test("keeps a single operation when custom plans are excluded", () => { + const draft = buildMigrationDraft({ + baseProduct, + editedProduct: baseProduct, + features, + scope: "this_version", + }); + + expect(draft.operations.customer).toHaveLength(1); + expect(firstUpdatePlan(draft).plan_filter).toEqual({ plan_id: "pro", version: 2, + custom: false, }); }); @@ -113,7 +173,7 @@ describe("buildMigrationDraft", () => { scope: "this_version", }); - const updatePlan = draft.operations.customer?.[0]; + const updatePlan = firstUpdatePlan(draft); const addItem = updatePlan?.customize?.add_items?.[0]; const price = JSON.parse(JSON.stringify(addItem?.price)); @@ -143,14 +203,14 @@ describe("buildVersionMigrationDraft", () => { version: { $in: [1, 2] }, custom: false, }); - expect(draft.operations.customer?.[0]?.plan_filter).toMatchObject({ + expect(firstUpdatePlan(draft).plan_filter).toMatchObject({ plan_id: "pro", version: { $in: [1, 2] }, custom: false, }); }); - test("omits custom filters when custom plans are included", () => { + test("targets both regular and custom versions when custom plans are included", () => { const draft = buildVersionMigrationDraft({ productId: "pro", latestVersion: 3, @@ -163,9 +223,9 @@ describe("buildVersionMigrationDraft", () => { plan_id: "pro", version: 2, }); - expect(draft.operations.customer?.[0]?.plan_filter).toEqual({ - plan_id: "pro", - version: 2, - }); + expect(updatePlanFilters(draft)).toEqual([ + { plan_id: "pro", version: 2, custom: false }, + { plan_id: "pro", version: 2, custom: true }, + ]); }); });