From a34258b3be38760e217745865389ddd3718353ed Mon Sep 17 00:00:00 2001 From: John Yeo Date: Fri, 15 May 2026 17:18:12 +0800 Subject: [PATCH] fix: new migration filters and tests for one off migrations --- .vscode/tasks.json | 9 +- ai | 2 +- server/src/external/autumn/autumnCli.ts | 3 + .../initPatchCustomerProduct.ts | 11 + .../v2/handlers/handleRunMigration.ts | 8 + .../preProcess/hasVersionBumpUpdatePlan.ts | 18 + .../migrations/v2/run/preProcess/index.ts | 4 + .../v2/run/preProcess/preProcessMigration.ts | 27 ++ .../preProcess/preProcessMigrationFilter.ts | 61 +++ .../preProcessMigrationOperations.ts | 40 ++ .../migrations/v2/run/runMigration.ts | 12 +- .../run-handler-lazy-run-body.test.ts | 138 ++++++ .../update-plan-op-custom.test.ts | 405 ++++++++++++++++ .../migration-oneoff-addon-version.test.ts | 459 ++++++++++++++++++ .../utils/runUpdatePlanMigration.ts | 9 +- server/tests/utils/setup/clearOrg.ts | 26 +- .../filterToIr/scopes/parsePlanFilter.ts | 2 + .../compiler/registry/customerRegistry.ts | 1 + shared/api/migrations/filters/planFilter.ts | 6 + .../match/planFilterMatchesCustomerProduct.ts | 8 +- .../billingModels/plan/autumnBillingPlan.ts | 1 + .../migration/filters/FilterGroup.tsx | 14 +- .../migration/shared/ValuePicker.tsx | 9 + .../migration/shared/planSuggestions.tsx | 1 + 24 files changed, 1258 insertions(+), 16 deletions(-) create mode 100644 server/src/internal/migrations/v2/run/preProcess/hasVersionBumpUpdatePlan.ts create mode 100644 server/src/internal/migrations/v2/run/preProcess/index.ts create mode 100644 server/src/internal/migrations/v2/run/preProcess/preProcessMigration.ts create mode 100644 server/src/internal/migrations/v2/run/preProcess/preProcessMigrationFilter.ts create mode 100644 server/src/internal/migrations/v2/run/preProcess/preProcessMigrationOperations.ts create mode 100644 server/tests/integration/billing/migrations-v2/run-handler/run-handler-lazy-run-body.test.ts create mode 100644 server/tests/integration/billing/migrations-v2/update-plan-operation/update-plan-op-custom.test.ts create mode 100644 server/tests/integration/billing/migrations-v2/update-plan-version/migration-oneoff-addon-version.test.ts diff --git a/.vscode/tasks.json b/.vscode/tasks.json index e44c6266d..5e93cff6a 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -4,19 +4,22 @@ { "label": "Run Test Pattern", "type": "shell", - "command": "bun test ${relativeFile} -t \"${input:testPattern}\"", + "command": "infisical run --env=dev --recursive -- bun test ${relativeFile} -t \"${input:testPattern}\"", + "options": { "env": { "NODE_ENV": "development" } }, "problemMatcher": [] }, { "label": "Run Describe at Cursor", "type": "shell", - "command": "bun test ${relativeFile} --timeout 0 -t \"$(bun scripts/testScripts/getDescribeAtCursor.ts ${file} ${lineNumber})\"", + "command": "infisical run --env=dev --recursive -- bun test ${relativeFile} --timeout 0 -t \"$(bun scripts/testScripts/getDescribeAtCursor.ts ${file} ${lineNumber})\"", + "options": { "env": { "NODE_ENV": "development" } }, "problemMatcher": [] }, { "label": "Run Current Test File", "type": "shell", - "command": "bun test ${relativeFile}", + "command": "infisical run --env=dev --recursive -- bun test ${relativeFile}", + "options": { "env": { "NODE_ENV": "development" } }, "problemMatcher": [] } ], diff --git a/ai b/ai index 761b84255..bfc0773e3 160000 --- a/ai +++ b/ai @@ -1 +1 @@ -Subproject commit 761b842553d890355b77e618c3aa703dddb661c1 +Subproject commit bfc0773e315761299d28c98bb7a42e7d0b46f9fe diff --git a/server/src/external/autumn/autumnCli.ts b/server/src/external/autumn/autumnCli.ts index efd8157c3..5f11bc332 100644 --- a/server/src/external/autumn/autumnCli.ts +++ b/server/src/external/autumn/autumnCli.ts @@ -1017,15 +1017,18 @@ export class AutumnInt { run: async (params: { id: string; dry_run?: boolean; + lazy_run?: boolean; }): Promise<{ migration_id: string; dry_run: boolean; + lazy_run: boolean; run_id: string; }> => { const data = await this.post(`/migrations.run`, params); return data as { migration_id: string; dry_run: boolean; + lazy_run: boolean; run_id: string; }; }, 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 ad6c7348f..ba166529b 100644 --- a/server/src/internal/billing/v2/utils/initFullCustomerProduct/initPatchedCustomerProduct/initPatchCustomerProduct.ts +++ b/server/src/internal/billing/v2/utils/initFullCustomerProduct/initPatchedCustomerProduct/initPatchCustomerProduct.ts @@ -99,11 +99,22 @@ export const initPatchCustomerProduct = ({ cusProduct: patchContext.finalCustomerProduct, }); + // Patch-style customization always carries custom items (setupPatchContext + // only runs when isCustomizePlanPatchStyle is true). Flip is_custom on the + // customer_product so version migrations skip it. + const customUpdates = billingContext.isCustom + ? { is_custom: true } + : {}; + if (billingContext.isCustom) { + patchContext.finalCustomerProduct.is_custom = true; + } + return { finalCustomerProduct: patchContext.finalCustomerProduct, customerProductUpdates: { options: patchContext.finalCustomerProduct.options, ...trialUpdates, + ...customUpdates, }, }; }; diff --git a/server/src/internal/migrations/v2/handlers/handleRunMigration.ts b/server/src/internal/migrations/v2/handlers/handleRunMigration.ts index e1349c362..0e23b28df 100644 --- a/server/src/internal/migrations/v2/handlers/handleRunMigration.ts +++ b/server/src/internal/migrations/v2/handlers/handleRunMigration.ts @@ -12,6 +12,11 @@ const RunMigrationBody = z.object({ limit: z.number().int().min(1).optional(), only: z.array(z.string()).optional(), concurrency: z.number().int().min(1).optional(), + /** When true, claim a lazy run alongside the background sweeper. Customers + * hit on the request path get migrated lazily via `runMigrationCustomerTask` + * before the sweeper reaches them. Background and lazy run on the same + * migration_run row — the claim is shared. */ + lazy_run: z.boolean().default(false), }); const getRunMigrationTriggerOptions = ({ @@ -36,6 +41,7 @@ export const handleRunMigration = createRoute({ limit, only, concurrency, + lazy_run: lazyRun, } = c.req.valid("json"); const migration = await migrationRepo.find({ ctx, id }); @@ -52,6 +58,7 @@ export const handleRunMigration = createRoute({ ctx, migration, dryRun, + lazyRun, claimed: async (migrationRunId) => { const handle = await runMigrationTask.trigger( { @@ -86,6 +93,7 @@ export const handleRunMigration = createRoute({ return c.json({ migration_id: id, dry_run: dryRun, + lazy_run: lazyRun, run_id: migrationRunId, trigger_run_id: triggerRunId, public_access_token: publicAccessToken, diff --git a/server/src/internal/migrations/v2/run/preProcess/hasVersionBumpUpdatePlan.ts b/server/src/internal/migrations/v2/run/preProcess/hasVersionBumpUpdatePlan.ts new file mode 100644 index 000000000..f7246b1d5 --- /dev/null +++ b/server/src/internal/migrations/v2/run/preProcess/hasVersionBumpUpdatePlan.ts @@ -0,0 +1,18 @@ +import type { Operations } from "@autumn/shared/api/migrations/operations/operations.js"; + +/** + * True iff at least one update_plan op bumps `version` without already + * specifying a `plan_filter.custom` predicate. Drives the default + * is_custom guard injected by `preProcessMigration`. + */ +export const hasVersionBumpUpdatePlan = ( + operations: Operations | null | undefined, +) => + Boolean( + operations?.customer?.some( + (op) => + op.type === "update_plan" && + op.version !== undefined && + op.plan_filter.custom === undefined, + ), + ); diff --git a/server/src/internal/migrations/v2/run/preProcess/index.ts b/server/src/internal/migrations/v2/run/preProcess/index.ts new file mode 100644 index 000000000..2ffda9654 --- /dev/null +++ b/server/src/internal/migrations/v2/run/preProcess/index.ts @@ -0,0 +1,4 @@ +export { hasVersionBumpUpdatePlan } from "./hasVersionBumpUpdatePlan.js"; +export { preProcessMigration } from "./preProcessMigration.js"; +export { preProcessMigrationFilter } from "./preProcessMigrationFilter.js"; +export { preProcessMigrationOperations } from "./preProcessMigrationOperations.js"; diff --git a/server/src/internal/migrations/v2/run/preProcess/preProcessMigration.ts b/server/src/internal/migrations/v2/run/preProcess/preProcessMigration.ts new file mode 100644 index 000000000..1776d74ca --- /dev/null +++ b/server/src/internal/migrations/v2/run/preProcess/preProcessMigration.ts @@ -0,0 +1,27 @@ +import type { MigrationRuntime } from "../../types/migrationDefinition.js"; +import { preProcessMigrationFilter } from "./preProcessMigrationFilter.js"; +import { preProcessMigrationOperations } from "./preProcessMigrationOperations.js"; + +/** + * Apply every default-guard transform to a migration before it runs. + * + * - Operation-level: any update_plan op bumping `version` gets + * `plan_filter.custom: false` injected (see preProcessMigrationOperations). + * - Filter-level: when any such op is present, `custom: false` is pushed + * into the customer-scope plan filter so the SQL query never even + * fetches admin-customized cusProducts (see preProcessMigrationFilter). + * + * Pure transform — never mutates the input. + */ +export const preProcessMigration = ( + migration: M, +): M => { + const operations = migration.operations + ? preProcessMigrationOperations({ operations: migration.operations }) + : migration.operations; + const filter = preProcessMigrationFilter({ + operations: operations ?? undefined, + filter: migration.filter, + }); + return { ...migration, operations, filter }; +}; diff --git a/server/src/internal/migrations/v2/run/preProcess/preProcessMigrationFilter.ts b/server/src/internal/migrations/v2/run/preProcess/preProcessMigrationFilter.ts new file mode 100644 index 000000000..8ffc8df6c --- /dev/null +++ b/server/src/internal/migrations/v2/run/preProcess/preProcessMigrationFilter.ts @@ -0,0 +1,61 @@ +import type { MigrationFilter } from "@autumn/shared/api/migrations/filters/migrationFilter.js"; +import type { Operations } from "@autumn/shared/api/migrations/operations/operations.js"; +import type { PlanFilter } from "@autumn/shared/api/migrations/filters/planFilter.js"; +import { hasVersionBumpUpdatePlan } from "./hasVersionBumpUpdatePlan.js"; + +type PlanQuantifier = { + $some?: PlanFilter; + $every?: PlanFilter; + $none?: PlanFilter; +}; + +const injectCustomFalse = (planFilter: PlanFilter): PlanFilter => + planFilter.custom !== undefined + ? planFilter + : { ...planFilter, custom: false }; + +const isQuantifierObject = ( + value: PlanFilter | PlanQuantifier, +): value is PlanQuantifier => + typeof value === "object" && + value !== null && + ("$some" in value || "$every" in value || "$none" in value); + +/** + * Filter-level guard. Pushes `custom: false` down into the customer-scope + * plan filter whenever any update_plan op bumps `version`, so the SQL + * query that pulls candidate customers never even fetches admin-customized + * cusProducts. Same opt-out as the op-level hook: if the caller already + * specified a `custom` predicate, leave it alone. + */ +export const preProcessMigrationFilter = ({ + operations, + filter, +}: { + operations: Operations | null | undefined; + filter: MigrationFilter | null | undefined; +}): MigrationFilter | null | undefined => { + if (!filter) return filter; + if (!hasVersionBumpUpdatePlan(operations)) return filter; + if (!filter.customer) return filter; + + const planRule = filter.customer.plan; + if (planRule === undefined || planRule === "$none") return filter; + + const nextPlan: PlanFilter | PlanQuantifier = isQuantifierObject(planRule) + ? { + ...planRule, + ...(planRule.$some + ? { $some: injectCustomFalse(planRule.$some) } + : {}), + ...(planRule.$every + ? { $every: injectCustomFalse(planRule.$every) } + : {}), + } + : injectCustomFalse(planRule); + + return { + ...filter, + customer: { ...filter.customer, plan: nextPlan }, + }; +}; diff --git a/server/src/internal/migrations/v2/run/preProcess/preProcessMigrationOperations.ts b/server/src/internal/migrations/v2/run/preProcess/preProcessMigrationOperations.ts new file mode 100644 index 000000000..530e867e2 --- /dev/null +++ b/server/src/internal/migrations/v2/run/preProcess/preProcessMigrationOperations.ts @@ -0,0 +1,40 @@ +import type { + CustomerOperation, + CustomerOperations, +} from "@autumn/shared/api/migrations/operations/customer/customerOperations.js"; +import type { Operations } from "@autumn/shared/api/migrations/operations/operations.js"; + +/** + * Op-level guard. Any `update_plan` op that bumps `version` automatically + * gets `plan_filter.custom: false` so admin-customized customer_products + * are never silently migrated. Explicit `plan_filter.custom` on the op + * overrides the default — callers opting into migrating custom plans + * have to say so. + * + * Pure transform: returns a new `Operations` object, never mutates input. + */ +export const preProcessMigrationOperations = ({ + operations, +}: { + operations: Operations; +}): Operations => { + if (!operations.customer) return operations; + + 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; + + return { + ...op, + plan_filter: { + ...op.plan_filter, + custom: false, + }, + }; + }, + ); + + return { ...operations, customer: customerOps }; +}; diff --git a/server/src/internal/migrations/v2/run/runMigration.ts b/server/src/internal/migrations/v2/run/runMigration.ts index 0858c3cbd..d21fde89b 100644 --- a/server/src/internal/migrations/v2/run/runMigration.ts +++ b/server/src/internal/migrations/v2/run/runMigration.ts @@ -15,6 +15,7 @@ import { withMigrationEventId, } from "../types/migrationDefinition.js"; import { runScopeIteration } from "./orchestrators/runScopeIteration.js"; +import { preProcessMigration } from "./preProcess/index.js"; import { getRunScopes } from "./types/getRunScopes.js"; /** Top-level migration run: prepare -> per-scope filter+iterate -> per-item ops. */ @@ -45,13 +46,20 @@ export const runMigration = async ({ migration, }); + // Inject default guards (e.g. `custom: false` on version-bumping + // update_plan ops, both at the op-level plan_filter and at the + // migration.filter customer.plan level) so admin-customized + // customer_products are never touched. Has to run before `prepare` + // so the prepared state reflects the guarded filter. + const guardedMigration = preProcessMigration(migrationWithEventId); + const { preparedState } = await prepare({ ctx, - migration: migrationWithEventId, + migration: guardedMigration, dryRun, }); const preparedMigration = { - ...migrationWithEventId, + ...guardedMigration, prepared_state: preparedState, }; diff --git a/server/tests/integration/billing/migrations-v2/run-handler/run-handler-lazy-run-body.test.ts b/server/tests/integration/billing/migrations-v2/run-handler/run-handler-lazy-run-body.test.ts new file mode 100644 index 000000000..9ce6a3166 --- /dev/null +++ b/server/tests/integration/billing/migrations-v2/run-handler/run-handler-lazy-run-body.test.ts @@ -0,0 +1,138 @@ +/** + * Coverage for the `lazy_run` body param on `POST /migrations.run`. + * + * Contract under test: + * - `migrationsV2.run({ id, lazy_run: true })` persists `lazy_run = true` + * on the resulting `migration_runs` row. + * - Default (`lazy_run` omitted / false) leaves the row in its + * background-only shape (`lazy_run = false`). + * - The response echoes the requested `lazy_run` value alongside + * `dry_run` and `run_id`. + */ + +import { expect, test } from "bun:test"; +import { migrationRuns } from "@autumn/shared"; +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 } from "drizzle-orm"; + +const buildDashboardMigration = ({ + id, + planId, +}: { + id: string; + planId: string; +}) => ({ + id, + filter: { customer: { plan: { plan_id: planId } } }, + operations: { + customer: [ + { + type: "update_plan" as const, + plan_filter: { plan_id: planId }, + customize: { add_items: [itemsV2.dashboard()] }, + }, + ], + }, +}); + +test.concurrent( + `${chalk.yellowBright("run-handler lazy_run: lazy_run=true persists on migration_runs")}`, + async () => { + const customerId = "run-handler-lazy-true"; + const plan = products.pro({ id: "run-handler-lazy-true-pro", items: [] }); + + const { autumnV2_2, ctx } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [plan] }), + ], + actions: [s.billing.attach({ productId: plan.id })], + }); + + const migration = await autumnV2_2.migrationsV2.deleteAndCreate( + buildDashboardMigration({ + id: `${customerId}-mig`, + planId: plan.id, + }), + ); + + const response = await autumnV2_2.migrationsV2.run({ + id: migration.id, + lazy_run: true, + }); + + expect(response.migration_id).toBe(migration.id); + expect(response.lazy_run).toBe(true); + + // Cleanup so other tests can claim this migration. Direct delete by + // the returned run_id (idempotent — survives if the trigger task + // already terminally marked it). + const [row] = await ctx.db + .select() + .from(migrationRuns) + .where(eq(migrationRuns.internal_id, response.run_id)); + expect(row).toBeDefined(); + expect(row?.lazy_run).toBe(true); + + await ctx.db + .delete(migrationRuns) + .where( + and( + eq(migrationRuns.internal_id, response.run_id), + eq(migrationRuns.org_id, ctx.org.id), + ), + ); + }, +); + +test.concurrent( + `${chalk.yellowBright("run-handler lazy_run: default lazy_run=false on migration_runs")}`, + async () => { + const customerId = "run-handler-lazy-default"; + const plan = products.pro({ + id: "run-handler-lazy-default-pro", + items: [], + }); + + const { autumnV2_2, ctx } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [plan] }), + ], + actions: [s.billing.attach({ productId: plan.id })], + }); + + const migration = await autumnV2_2.migrationsV2.deleteAndCreate( + buildDashboardMigration({ + id: `${customerId}-mig`, + planId: plan.id, + }), + ); + + const response = await autumnV2_2.migrationsV2.run({ + id: migration.id, + }); + + expect(response.lazy_run).toBe(false); + + const [row] = await ctx.db + .select() + .from(migrationRuns) + .where(eq(migrationRuns.internal_id, response.run_id)); + expect(row?.lazy_run).toBe(false); + + await ctx.db + .delete(migrationRuns) + .where( + and( + eq(migrationRuns.internal_id, response.run_id), + eq(migrationRuns.org_id, ctx.org.id), + ), + ); + }, +); diff --git a/server/tests/integration/billing/migrations-v2/update-plan-operation/update-plan-op-custom.test.ts b/server/tests/integration/billing/migrations-v2/update-plan-operation/update-plan-op-custom.test.ts new file mode 100644 index 000000000..5d9c58fad --- /dev/null +++ b/server/tests/integration/billing/migrations-v2/update-plan-operation/update-plan-op-custom.test.ts @@ -0,0 +1,405 @@ +/** + * Coverage for the custom-plan guard on `update_plan` version migrations. + * + * Contract under test: + * - `update_plan` with `version` set auto-injects `plan_filter.custom: false` + * via `preProcessMigrationOperations`. Customers whose customer_product + * has `is_custom = true` must NOT be touched by such migrations. + * - When a batch contains both custom and regular customers on the same + * plan, only the regular customers are migrated; the custom customer's + * version stays put and their custom feature config is preserved. + * + * Mirrors the legacy `migrate-custom-plans.test.ts` cases ported to the + * migrations-v2 `update_plan` + `version` flow. + */ + +import { expect, test } from "bun:test"; +import type { ApiCustomerV3, ApiCustomerV5 } from "@autumn/shared"; +import { expectCustomerFeatureCorrect } from "@tests/integration/billing/utils/expectCustomerFeatureCorrect"; +import { expectCustomerProducts } from "@tests/integration/billing/utils/expectCustomerProductCorrect"; +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 { runUpdatePlanMigration } from "../utils/runUpdatePlanMigration"; + +test.concurrent(`${chalk.yellowBright("update_plan custom: customer with is_custom plan is skipped")}`, async () => { + const customerId = "migration-v2-custom-skip"; + + const pro = products.pro({ + id: "v2-custom-pro", + items: [items.monthlyMessages({ includedUsage: 500 })], + }); + const monthlyPrice = items.monthlyPrice({ price: 20 }); + + const { autumnV1, autumnV2_2, ctx } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [pro] }), + ], + actions: [ + // Custom items at attach time → customer_product.is_custom = true. + s.billing.attach({ + productId: pro.id, + items: [monthlyPrice, items.monthlyMessages({ includedUsage: 750 })], + }), + ], + }); + + // Sanity: custom included usage applied. + let customer = await autumnV1.customers.get(customerId); + expectCustomerFeatureCorrect({ + customer, + featureId: TestFeature.Messages, + includedUsage: 750, + balance: 750, + usage: 0, + }); + const versionBefore = customer.products?.find( + (productOnCustomer) => productOnCustomer.id === pro.id, + )?.version; + + // Bump the product to v2 with a smaller included usage. + await autumnV1.products.update(pro.id, { + items: [monthlyPrice, items.monthlyMessages({ includedUsage: 600 })], + }); + + 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 }, + version: 2, + }, + ], + }, + }); + + // Custom plan was SKIPPED — version unchanged, custom config preserved. + customer = await autumnV1.customers.get(customerId); + await expectCustomerProducts({ customer, active: [pro.id] }); + + const versionAfter = customer.products?.find( + (productOnCustomer) => productOnCustomer.id === pro.id, + )?.version; + expect(versionAfter).toBe(versionBefore); + + expectCustomerFeatureCorrect({ + customer, + featureId: TestFeature.Messages, + includedUsage: 750, + balance: 750, + usage: 0, + }); +}); + +test.concurrent(`${chalk.yellowBright("update_plan custom: mix of custom + regular → only regular migrated")}`, async () => { + const regularCustomerId = "migration-v2-custom-mix-regular"; + const customCustomerId = "migration-v2-custom-mix-custom"; + + const pro = products.pro({ + id: "v2-custom-mix-pro", + items: [items.monthlyMessages({ includedUsage: 500 })], + }); + const monthlyPrice = items.monthlyPrice({ price: 20 }); + + const { autumnV1, autumnV2_2, ctx } = await initScenario({ + customerId: regularCustomerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.otherCustomers([{ id: customCustomerId, paymentMethod: "success" }]), + s.products({ list: [pro] }), + ], + actions: [ + // Regular customer: default product config. + s.billing.attach({ productId: pro.id }), + // Custom customer: overridden items → is_custom = true. + s.billing.attach({ + customerId: customCustomerId, + productId: pro.id, + items: [monthlyPrice, items.monthlyMessages({ includedUsage: 800 })], + }), + ], + }); + + // Bump product to v2. + await autumnV1.products.update(pro.id, { + items: [monthlyPrice, items.monthlyMessages({ includedUsage: 600 })], + }); + + await runUpdatePlanMigration({ + ctx, + migrationClient: autumnV2_2, + migrationId: `${regularCustomerId}-mig`, + customerId: regularCustomerId, + filter: { customer: { plan: { plan_id: pro.id } } }, + operations: { + customer: [ + { + type: "update_plan", + plan_filter: { plan_id: pro.id }, + version: 2, + }, + ], + }, + }); + + // Regular: migrated to v2 included usage = 600. + const regularCustomer = + await autumnV1.customers.get(regularCustomerId); + expectCustomerFeatureCorrect({ + customer: regularCustomer, + featureId: TestFeature.Messages, + includedUsage: 600, + balance: 600, + usage: 0, + }); + + // Custom: untouched, still on 800. + const customCustomer = + await autumnV1.customers.get(customCustomerId); + expectCustomerFeatureCorrect({ + customer: customCustomer, + featureId: TestFeature.Messages, + includedUsage: 800, + balance: 800, + usage: 0, + }); +}); + +test.concurrent(`${chalk.yellowBright("update_plan custom: subscriptions.update PATCH (add_items) marks is_custom and migration skips")}`, async () => { + const customerId = "migration-v2-custom-patch-update"; + + const pro = products.pro({ + id: "v2-custom-patch-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 })], + }); + + // Sanity: starts on default (500), no Dashboard. + let customer = await autumnV1.customers.get(customerId); + expectCustomerFeatureCorrect({ + customer, + featureId: TestFeature.Messages, + includedUsage: 500, + balance: 500, + usage: 0, + }); + const versionBefore = customer.products?.find( + (productOnCustomer) => productOnCustomer.id === pro.id, + )?.version; + + // PATCH-style: add Dashboard via subscriptions.update.add_items → flips is_custom = true. + await autumnV2_2.subscriptions.update({ + customer_id: customerId, + plan_id: pro.id, + customize: { + add_items: [itemsV2.dashboard()], + }, + }); + + // Dashboard is now present on the customer (patch landed). + let customerV5 = await autumnV2_2.customers.get(customerId); + expectFlagCorrect({ + customer: customerV5, + featureId: TestFeature.Dashboard, + present: true, + }); + + // Bump product to v2 with different Messages count (v2 still has no Dashboard). + await autumnV1.products.update(pro.id, { + items: [items.monthlyMessages({ includedUsage: 600 })], + }); + + 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 }, + version: 2, + }, + ], + }, + }); + + // Custom plan SKIPPED — version unchanged, custom Dashboard preserved, + // Messages stays on v1's 500 (NOT migrated to v2's 600). + customer = await autumnV1.customers.get(customerId); + const versionAfter = customer.products?.find( + (productOnCustomer) => productOnCustomer.id === pro.id, + )?.version; + expect(versionAfter).toBe(versionBefore); + + expectCustomerFeatureCorrect({ + customer, + featureId: TestFeature.Messages, + includedUsage: 500, + balance: 500, + usage: 0, + }); + + customerV5 = await autumnV2_2.customers.get(customerId); + expectFlagCorrect({ + customer: customerV5, + featureId: TestFeature.Dashboard, + present: true, + }); +}); + +test.concurrent(`${chalk.yellowBright("update_plan custom: subscriptions.update PUT (items replace) marks is_custom and migration skips")}`, async () => { + const customerId = "migration-v2-custom-put-update"; + + const pro = products.pro({ + id: "v2-custom-put-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 })], + }); + + const versionBefore = ( + await autumnV1.customers.get(customerId) + ).products?.find((productOnCustomer) => productOnCustomer.id === pro.id) + ?.version; + + // PUT-style customization → replaces items entirely, flips is_custom = true. + await autumnV2_2.subscriptions.update({ + customer_id: customerId, + plan_id: pro.id, + customize: { + price: itemsV2.monthlyPrice({ amount: 20 }), + items: [itemsV2.monthlyMessages({ included: 850 })], + }, + }); + + let customer = await autumnV1.customers.get(customerId); + expectCustomerFeatureCorrect({ + customer, + featureId: TestFeature.Messages, + includedUsage: 850, + balance: 850, + usage: 0, + }); + + await autumnV1.products.update(pro.id, { + items: [items.monthlyMessages({ includedUsage: 600 })], + }); + + 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 }, + version: 2, + }, + ], + }, + }); + + customer = await autumnV1.customers.get(customerId); + const versionAfter = customer.products?.find( + (productOnCustomer) => productOnCustomer.id === pro.id, + )?.version; + expect(versionAfter).toBe(versionBefore); + + expectCustomerFeatureCorrect({ + customer, + featureId: TestFeature.Messages, + includedUsage: 850, + balance: 850, + usage: 0, + }); +}); + +test.concurrent(`${chalk.yellowBright("update_plan custom: explicit `custom: true` opts in to migrating custom plans")}`, async () => { + const customerId = "migration-v2-custom-explicit-opt-in"; + + const pro = products.pro({ + id: "v2-custom-opt-in-pro", + items: [items.monthlyMessages({ includedUsage: 500 })], + }); + const monthlyPrice = items.monthlyPrice({ price: 20 }); + + const { autumnV1, autumnV2_2, ctx } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [pro] }), + ], + actions: [ + s.billing.attach({ + productId: pro.id, + items: [monthlyPrice, items.monthlyMessages({ includedUsage: 750 })], + }), + ], + }); + + await autumnV1.products.update(pro.id, { + items: [monthlyPrice, items.monthlyMessages({ includedUsage: 600 })], + }); + + // Explicit `plan_filter.custom: true` overrides the auto-injected guard — + // caller is opting in to migrate custom plans. + 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, custom: true }, + version: 2, + }, + ], + }, + }); + + // Migrated — included usage reflects v2 (600). + const customer = await autumnV1.customers.get(customerId); + expectCustomerFeatureCorrect({ + customer, + featureId: TestFeature.Messages, + includedUsage: 600, + balance: 600, + usage: 0, + }); +}); diff --git a/server/tests/integration/billing/migrations-v2/update-plan-version/migration-oneoff-addon-version.test.ts b/server/tests/integration/billing/migrations-v2/update-plan-version/migration-oneoff-addon-version.test.ts new file mode 100644 index 000000000..3ecdc94f0 --- /dev/null +++ b/server/tests/integration/billing/migrations-v2/update-plan-version/migration-oneoff-addon-version.test.ts @@ -0,0 +1,459 @@ +/** + * TDD coverage for update_plan version migrations on one-off addon plans. + * + * Contract under test: + * Behavior: + * - update_plan with version: 2 moves customers from v1 -> v2 of a + * one-off addon plan (type: one_off, isAddOn: true) where v2 only + * adds feature entitlements (no price change). + * - Post-migration: customer's active product reflects v2; new + * entitlements are present on the customer. + * Side effects: + * - No new Stripe invoice is generated for the migrated customer. + * - If the customer also has a separate recurring main subscription, + * its Stripe subscription is untouched (anchor + items unchanged). + * - no_billing_changes=true: migration completes via DB-only path + * without raising the "produced Stripe mutations" error. + * - no_billing_changes=false: migration still completes; with no + * price delta there is nothing to bill and no invoice is created. + */ + +import { expect, test } from "bun:test"; +import type { ApiCustomerV3 } from "@autumn/shared"; +import { expectCustomerInvoiceCorrect } from "@tests/integration/billing/utils/expectCustomerInvoiceCorrect"; +import { expectCustomerProducts } from "@tests/integration/billing/utils/expectCustomerProductCorrect"; +import { expectStripeSubscriptionUnchanged } from "@tests/integration/billing/utils/stripe/expectStripeSubscriptionUnchanged"; +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 { prepare } from "@/internal/migrations/v2/prepare/prepare.js"; +import { migrateCustomer } from "@/internal/migrations/v2/run/migrateCustomer/index.js"; +import { preProcessMigration } from "@/internal/migrations/v2/run/preProcess/index.js"; +import { runUpdatePlanMigration } from "../utils/runUpdatePlanMigration"; + +const newFeatureItem = () => items.dashboard(); + +test.concurrent(`${chalk.yellowBright("migrations update_plan: one-off addon v1->v2 adds features without invoicing")}`, async () => { + const customerId = "mig-oneoff-addon-basic"; + const addon = products.oneOffAddOn({ + id: "oneoff-addon-basic", + items: [], + }); + + const { autumnV1, autumnV2_2, ctx } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [addon] }), + ], + actions: [s.billing.attach({ productId: addon.id })], + }); + + const invoiceCountBefore = + (await autumnV1.customers.get(customerId)).invoices + ?.length ?? 0; + + // v2: just adds an extra feature (Dashboard boolean). Base price unchanged. + await autumnV1.products.update(addon.id, { + items: [items.oneOffPrice({ price: 10 }), newFeatureItem()], + }); + + await runUpdatePlanMigration({ + ctx, + migrationClient: autumnV2_2, + migrationId: `${customerId}-mig`, + customerId, + filter: { customer: { plan: { plan_id: addon.id } } }, + operations: { + customer: [ + { + type: "update_plan", + plan_filter: { plan_id: addon.id }, + version: 2, + }, + ], + }, + runOnServer: false, + }); + + const customerV3 = await autumnV1.customers.get(customerId); + await expectCustomerProducts({ customer: customerV3, active: [addon.id] }); + expect(customerV3.features?.[TestFeature.Dashboard]).toBeDefined(); + await expectCustomerInvoiceCorrect({ + customer: customerV3, + count: invoiceCountBefore, + }); +}); + +test.concurrent(`${chalk.yellowBright("migrations update_plan: one-off addon v2 migration leaves main subscription untouched")}`, async () => { + const customerId = "mig-oneoff-addon-with-main"; + const pro = products.pro({ + id: "mig-oneoff-main-pro", + items: [items.monthlyMessages({ includedUsage: 500 })], + }); + const addon = products.oneOffAddOn({ + id: "mig-oneoff-addon-with-main", + items: [], + }); + + const { autumnV1, autumnV2_2, ctx } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [pro, addon] }), + ], + actions: [ + s.billing.attach({ productId: pro.id }), + s.billing.attach({ productId: addon.id }), + ], + }); + + const fullCustomerBefore = + await autumnV1.customers.get(customerId); + const stripeCustomerId = fullCustomerBefore.stripe_id; + expect(stripeCustomerId).toBeDefined(); + + const subsBefore = await ctx.stripeCli.subscriptions.list({ + customer: stripeCustomerId as string, + status: "all", + }); + const mainSubBefore = subsBefore.data.find( + (sub) => sub.status === "active" || sub.status === "trialing", + ); + expect(mainSubBefore).toBeDefined(); + + const invoiceCountBefore = fullCustomerBefore.invoices?.length ?? 0; + + await autumnV1.products.update(addon.id, { + items: [items.oneOffPrice({ price: 10 }), newFeatureItem()], + }); + + await runUpdatePlanMigration({ + ctx, + migrationClient: autumnV2_2, + migrationId: `${customerId}-mig`, + customerId, + filter: { customer: { plan: { plan_id: addon.id } } }, + operations: { + customer: [ + { + type: "update_plan", + plan_filter: { plan_id: addon.id }, + version: 2, + }, + ], + }, + runOnServer: false, + }); + + const customerV3 = await autumnV1.customers.get(customerId); + await expectCustomerProducts({ + customer: customerV3, + active: [pro.id, addon.id], + }); + expect(customerV3.features?.[TestFeature.Dashboard]).toBeDefined(); + + const mainSubAfter = await ctx.stripeCli.subscriptions.retrieve( + mainSubBefore!.id, + ); + expectStripeSubscriptionUnchanged({ + before: mainSubBefore!, + after: mainSubAfter, + }); + + await expectCustomerInvoiceCorrect({ + customer: customerV3, + count: invoiceCountBefore, + }); +}); + +test.concurrent(`${chalk.yellowBright("migrations update_plan: one-off addon v2 with no_billing_changes=true takes DB-only path")}`, async () => { + const customerId = "mig-oneoff-addon-nbc-true"; + const addon = products.oneOffAddOn({ + id: "mig-oneoff-addon-nbc-true", + items: [], + }); + + const { autumnV1, autumnV2_2, ctx } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [addon] }), + ], + actions: [s.billing.attach({ productId: addon.id })], + }); + + const invoiceCountBefore = + (await autumnV1.customers.get(customerId)).invoices + ?.length ?? 0; + + await autumnV1.products.update(addon.id, { + items: [items.oneOffPrice({ price: 10 }), newFeatureItem()], + }); + + const migration = await autumnV2_2.migrationsV2.deleteAndCreate({ + id: `${customerId}-mig`, + filter: { customer: { plan: { plan_id: addon.id } } }, + operations: { + customer: [ + { + type: "update_plan", + plan_filter: { plan_id: addon.id }, + version: 2, + }, + ], + }, + }); + + const migrationWithFlag = preProcessMigration({ + ...migration, + no_billing_changes: true, + }); + const { preparedState } = await prepare({ + ctx, + migration: migrationWithFlag, + dryRun: false, + }); + const preparedMigration = { + ...migrationWithFlag, + prepared_state: preparedState, + }; + + await migrateCustomer({ + ctx, + customerId, + migration: preparedMigration, + }); + + const customerV3 = await autumnV1.customers.get(customerId); + await expectCustomerProducts({ customer: customerV3, active: [addon.id] }); + expect(customerV3.features?.[TestFeature.Dashboard]).toBeDefined(); + await expectCustomerInvoiceCorrect({ + customer: customerV3, + count: invoiceCountBefore, + }); +}); + +test.concurrent(`${chalk.yellowBright("migrations update_plan: one-off addon v2 with no_billing_changes=false still does not invoice")}`, async () => { + const customerId = "mig-oneoff-addon-nbc-false"; + const addon = products.oneOffAddOn({ + id: "mig-oneoff-addon-nbc-false", + items: [], + }); + + const { autumnV1, autumnV2_2, ctx } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [addon] }), + ], + actions: [s.billing.attach({ productId: addon.id })], + }); + + const invoiceCountBefore = + (await autumnV1.customers.get(customerId)).invoices + ?.length ?? 0; + + await autumnV1.products.update(addon.id, { + items: [items.oneOffPrice({ price: 10 }), newFeatureItem()], + }); + + const migration = await autumnV2_2.migrationsV2.deleteAndCreate({ + id: `${customerId}-mig`, + filter: { customer: { plan: { plan_id: addon.id } } }, + operations: { + customer: [ + { + type: "update_plan", + plan_filter: { plan_id: addon.id }, + version: 2, + }, + ], + }, + }); + + const migrationWithFlag = preProcessMigration({ + ...migration, + no_billing_changes: false, + }); + const { preparedState } = await prepare({ + ctx, + migration: migrationWithFlag, + dryRun: false, + }); + const preparedMigration = { + ...migrationWithFlag, + prepared_state: preparedState, + }; + + await migrateCustomer({ + ctx, + customerId, + migration: preparedMigration, + }); + + const customerV3 = await autumnV1.customers.get(customerId); + await expectCustomerProducts({ customer: customerV3, active: [addon.id] }); + expect(customerV3.features?.[TestFeature.Dashboard]).toBeDefined(); + await expectCustomerInvoiceCorrect({ + customer: customerV3, + count: invoiceCountBefore, + }); +}); + +test.concurrent(`${chalk.yellowBright("migrations update_plan: non-addon one-off v1->v2 adds features without invoicing")}`, async () => { + const customerId = "mig-oneoff-nonaddon"; + const plan = products.oneOff({ + id: "mig-oneoff-nonaddon-plan", + items: [], + isAddOn: false, + }); + + const { autumnV1, autumnV2_2, ctx } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [plan] }), + ], + actions: [s.billing.attach({ productId: plan.id })], + }); + + const invoiceCountBefore = + (await autumnV1.customers.get(customerId)).invoices + ?.length ?? 0; + + await autumnV1.products.update(plan.id, { + items: [items.oneOffPrice({ price: 10 }), newFeatureItem()], + }); + + await runUpdatePlanMigration({ + ctx, + migrationClient: autumnV2_2, + migrationId: `${customerId}-mig`, + customerId, + filter: { customer: { plan: { plan_id: plan.id } } }, + operations: { + customer: [ + { + type: "update_plan", + plan_filter: { plan_id: plan.id }, + version: 2, + }, + ], + }, + runOnServer: false, + }); + + const customerV3 = await autumnV1.customers.get(customerId); + await expectCustomerProducts({ customer: customerV3, active: [plan.id] }); + expect(customerV3.features?.[TestFeature.Dashboard]).toBeDefined(); + const migratedProduct = customerV3.products?.find((p) => p.id === plan.id); + expect(migratedProduct?.version).toBe(2); + await expectCustomerInvoiceCorrect({ + customer: customerV3, + count: invoiceCountBefore, + }); +}); + +test.concurrent(`${chalk.yellowBright("migrations update_plan: customized cusProduct is not touched by migration")}`, async () => { + const customerId = "mig-oneoff-addon-customized"; + const addon = products.oneOffAddOn({ + id: "mig-oneoff-addon-customized", + items: [ + items.oneOffMessages({ includedUsage: 100, billingUnits: 100, price: 5 }), + ], + }); + + const { autumnV1, autumnV2_2, ctx } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [addon] }), + ], + actions: [ + s.billing.attach({ + productId: addon.id, + items: [ + items.oneOffMessages({ + includedUsage: 999, + billingUnits: 100, + price: 5, + }), + ], + }), + ], + }); + + const customerBefore = + await autumnV1.customers.get(customerId); + const invoiceCountBefore = customerBefore.invoices?.length ?? 0; + const productBefore = customerBefore.products?.find((p) => p.id === addon.id); + expect(productBefore?.version).toBe(1); + const messagesItemBefore = productBefore?.items?.find( + (i) => "feature_id" in i && i.feature_id === TestFeature.Messages, + ); + expect(messagesItemBefore).toBeDefined(); + const customizedIncludedUsage = + messagesItemBefore && "included_usage" in messagesItemBefore + ? messagesItemBefore.included_usage + : undefined; + expect(customizedIncludedUsage).toBe(999); + + await autumnV1.products.update(addon.id, { + items: [ + items.oneOffPrice({ price: 5 }), + items.oneOffMessages({ includedUsage: 100, billingUnits: 100, price: 5 }), + newFeatureItem(), + ], + }); + + await runUpdatePlanMigration({ + ctx, + migrationClient: autumnV2_2, + migrationId: `${customerId}-mig`, + customerId, + filter: { customer: { plan: { plan_id: addon.id } } }, + operations: { + customer: [ + { + type: "update_plan", + plan_filter: { plan_id: addon.id }, + version: 2, + }, + ], + }, + runOnServer: false, + }); + + const customerAfter = await autumnV1.customers.get(customerId); + const productAfter = customerAfter.products?.find((p) => p.id === addon.id); + expect(productAfter, "customized cusProduct should still exist").toBeDefined(); + expect( + productAfter?.version, + "customized cusProduct should NOT be migrated to v2", + ).toBe(1); + + const messagesItemAfter = productAfter?.items?.find( + (i) => "feature_id" in i && i.feature_id === TestFeature.Messages, + ); + const includedUsageAfter = + messagesItemAfter && "included_usage" in messagesItemAfter + ? messagesItemAfter.included_usage + : undefined; + expect( + includedUsageAfter, + "customized included_usage should be preserved", + ).toBe(999); + + expect( + customerAfter.features?.[TestFeature.Dashboard], + "v2 feature should NOT be granted to customized cusProduct", + ).toBeUndefined(); + + await expectCustomerInvoiceCorrect({ + customer: customerAfter, + count: invoiceCountBefore, + }); +}); diff --git a/server/tests/integration/billing/migrations-v2/utils/runUpdatePlanMigration.ts b/server/tests/integration/billing/migrations-v2/utils/runUpdatePlanMigration.ts index 0ae75278e..1ede7395c 100644 --- a/server/tests/integration/billing/migrations-v2/utils/runUpdatePlanMigration.ts +++ b/server/tests/integration/billing/migrations-v2/utils/runUpdatePlanMigration.ts @@ -4,6 +4,7 @@ import type { Operations } from "@autumn/shared/api/migrations/operations/operat import type { AutumnContext } from "@/honoUtils/HonoEnv.js"; import { prepare } from "@/internal/migrations/v2/prepare/prepare.js"; import { migrateCustomer } from "@/internal/migrations/v2/run/migrateCustomer/index.js"; +import { preProcessMigration } from "@/internal/migrations/v2/run/preProcess/index.js"; type MigrationClient = { migrationsV2: { @@ -96,13 +97,17 @@ export const runUpdatePlanMigration = async ({ return migration; } + const guardedMigration = preProcessMigration(migration); const { preparedState } = await prepare({ ctx, - migration, + migration: guardedMigration, dryRun: false, }); - const preparedMigration = { ...migration, prepared_state: preparedState }; + const preparedMigration = { + ...guardedMigration, + prepared_state: preparedState, + }; await migrateCustomer({ ctx, diff --git a/server/tests/utils/setup/clearOrg.ts b/server/tests/utils/setup/clearOrg.ts index 93da0fa9c..576f10a47 100644 --- a/server/tests/utils/setup/clearOrg.ts +++ b/server/tests/utils/setup/clearOrg.ts @@ -1,4 +1,9 @@ -import { AppEnv } from "@autumn/shared"; +import { + AppEnv, + migrationItemRuns, + migrations, +} from "@autumn/shared"; +import { and, eq, inArray } from "drizzle-orm"; import { initDrizzle } from "@/db/initDrizzle.js"; import { AutumnInt } from "@/external/autumn/autumnCli.js"; import { CusService } from "@/internal/customers/CusService.js"; @@ -86,6 +91,25 @@ export const clearOrg = async ({ await FeatureService.deleteByOrgId({ db, orgId, env }); console.log(" ✅ Deleted features"); + // migration_item_runs has no FK to migrations/org; clear by joining first. + // migrations cascades to migration_runs, so deleting it is enough. + const orgMigrations = await db + .select({ internalId: migrations.internal_id }) + .from(migrations) + .where(and(eq(migrations.org_id, orgId), eq(migrations.env, env))); + if (orgMigrations.length > 0) { + await db.delete(migrationItemRuns).where( + inArray( + migrationItemRuns.migration_internal_id, + orgMigrations.map((m) => m.internalId), + ), + ); + } + await db + .delete(migrations) + .where(and(eq(migrations.org_id, orgId), eq(migrations.env, env))); + console.log(" ✅ Deleted migrations + migration item runs"); + console.log(`✅ Cleared org ${orgSlug} (${env})`); await client.end(); diff --git a/shared/api/migrations/compiler/filterToIr/scopes/parsePlanFilter.ts b/shared/api/migrations/compiler/filterToIr/scopes/parsePlanFilter.ts index 0b47539ff..bd7a85d77 100644 --- a/shared/api/migrations/compiler/filterToIr/scopes/parsePlanFilter.ts +++ b/shared/api/migrations/compiler/filterToIr/scopes/parsePlanFilter.ts @@ -29,6 +29,8 @@ export function parsePlanFilter({ children.push( parseLeaf({ field: "recurring", rawValue: filter.recurring, ctx }), ); + if (filter.custom !== undefined) + children.push(parseLeaf({ field: "custom", rawValue: filter.custom, ctx })); if (filter.item !== undefined) children.push(parseItemNav({ raw: filter.item, ctx })); if (filter.$or !== undefined) { diff --git a/shared/api/migrations/compiler/registry/customerRegistry.ts b/shared/api/migrations/compiler/registry/customerRegistry.ts index ce63482de..6cea929f3 100644 --- a/shared/api/migrations/compiler/registry/customerRegistry.ts +++ b/shared/api/migrations/compiler/registry/customerRegistry.ts @@ -87,6 +87,7 @@ const planScope: NavScope = { fields: { plan_id: { kind: "leaf", sql: "p.id" }, addon: { kind: "leaf", sql: "p.is_add_on" }, + custom: { kind: "leaf", sql: "cp.is_custom" }, // Base price existence: a leaf whose SQL is a scalar subquery that // evaluates to NULL when the customer has no base customer_price on // this cusproduct, non-NULL otherwise. The `exists` op (compiled diff --git a/shared/api/migrations/filters/planFilter.ts b/shared/api/migrations/filters/planFilter.ts index fc2df579f..ffae27426 100644 --- a/shared/api/migrations/filters/planFilter.ts +++ b/shared/api/migrations/filters/planFilter.ts @@ -48,6 +48,11 @@ export type PlanFilter = { /** `recurring: true` already implies a paid plan. */ paid?: z.infer; recurring?: z.infer; + /** Mirrors `customer_products.custom`. Migrations that bump a plan + * version inject `custom: false` automatically (see + * `preProcessMigrationOperations`) so admin-customized plans are never + * touched. Set explicitly to override. */ + custom?: z.infer; item?: | z.infer | { @@ -65,6 +70,7 @@ export const PlanFilterSchema: z.ZodType = z.lazy(() => addon: BooleanMatcherSchema.optional(), paid: BooleanMatcherSchema.optional(), recurring: BooleanMatcherSchema.optional(), + custom: BooleanMatcherSchema.optional(), item: arrayFilter(PlanItemFilterSchema).optional(), $or: z.array(PlanFilterSchema).optional(), }), diff --git a/shared/api/products/utils/match/planFilterMatchesCustomerProduct.ts b/shared/api/products/utils/match/planFilterMatchesCustomerProduct.ts index 468b84cc5..5cc21cee3 100644 --- a/shared/api/products/utils/match/planFilterMatchesCustomerProduct.ts +++ b/shared/api/products/utils/match/planFilterMatchesCustomerProduct.ts @@ -12,8 +12,8 @@ import type { PlanFilter } from "../../../migrations/filters/planFilter.js"; * * JS-side mirror of `compilePlanFilter` for callers that already have * the cusproduct in memory (migration runner, scripts). Today supports - * `plan_id`, `addon`, `paid`, `recurring`, and `$or`; `price` and `item` throw to - * make the gap explicit. + * `plan_id`, `addon`, `paid`, `recurring`, `custom`, and `$or`; `price` + * and `item` throw to make the gap explicit. */ export const planFilterMatchesCustomerProduct = ({ filter, @@ -63,6 +63,10 @@ export const planFilterMatchesCustomerProduct = ({ return false; } + if (filter.custom !== undefined && cusProduct.is_custom !== filter.custom) { + return false; + } + const unsupported = ["price", "item"] as const; for (const key of unsupported) { if ((filter as Record)[key] !== undefined) diff --git a/shared/models/billingModels/plan/autumnBillingPlan.ts b/shared/models/billingModels/plan/autumnBillingPlan.ts index 3a672dc89..5de6edc58 100644 --- a/shared/models/billingModels/plan/autumnBillingPlan.ts +++ b/shared/models/billingModels/plan/autumnBillingPlan.ts @@ -55,6 +55,7 @@ export const CustomerProductUpdateSchema = z.object({ scheduled_ids: z.array(z.string()).optional(), subscription_ids: z.array(z.string()).optional(), updated_at: z.number().optional(), + is_custom: z.boolean().optional(), }), }); diff --git a/vite/src/views/migrations/migration/filters/FilterGroup.tsx b/vite/src/views/migrations/migration/filters/FilterGroup.tsx index 2cabdc93a..1d1a8006f 100644 --- a/vite/src/views/migrations/migration/filters/FilterGroup.tsx +++ b/vite/src/views/migrations/migration/filters/FilterGroup.tsx @@ -41,11 +41,15 @@ function useSuggestionsForField( if (field === "customer_id") { return customers .filter((c): c is typeof c & { id: string } => Boolean(c.id)) - .map((c) => ({ - value: c.id, - label: c.name ?? c.email ?? c.id, - icon: , - })); + .map((c) => { + const label = c.name ?? c.email ?? c.id; + return { + value: c.id, + label, + sublabel: label === c.id ? undefined : c.id, + icon: , + }; + }); } if (field === "plan_id") return buildPlanSuggestions(products); if (field === "item_feature_id") { diff --git a/vite/src/views/migrations/migration/shared/ValuePicker.tsx b/vite/src/views/migrations/migration/shared/ValuePicker.tsx index cf69a7483..569aa0831 100644 --- a/vite/src/views/migrations/migration/shared/ValuePicker.tsx +++ b/vite/src/views/migrations/migration/shared/ValuePicker.tsx @@ -21,6 +21,7 @@ const MAX_VISIBLE_CHIPS = 3; export type ValuePickerOption = { value: string; label: string; + sublabel?: string; icon?: ReactNode; }; @@ -108,10 +109,13 @@ export function ValuePicker({ {suggestions.map((suggestion) => { const isSelected = selectedValues.includes(suggestion.value); + const keywords = [suggestion.label]; + if (suggestion.sublabel) keywords.push(suggestion.sublabel); return ( onToggle(suggestion.value)} className="text-sm" > @@ -121,6 +125,11 @@ export function ValuePicker({ {suggestion.label} + {suggestion.sublabel && ( + + {suggestion.sublabel} + + )} {isSelected && ( )} diff --git a/vite/src/views/migrations/migration/shared/planSuggestions.tsx b/vite/src/views/migrations/migration/shared/planSuggestions.tsx index ce4fe3025..1f56e3e24 100644 --- a/vite/src/views/migrations/migration/shared/planSuggestions.tsx +++ b/vite/src/views/migrations/migration/shared/planSuggestions.tsx @@ -15,6 +15,7 @@ export function buildPlanSuggestions( .map((p) => ({ value: p.id, label: p.name || p.id, + sublabel: p.name ? p.id : undefined, icon: , })); }