Merge pull request #1564 from useautumn/fix/migration-filters

fix: migration filters
This commit is contained in:
John Yeo
2026-05-15 10:27:46 +01:00
committed by GitHub
24 changed files with 1258 additions and 16 deletions

9
.vscode/tasks.json vendored
View File

@@ -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": []
}
],

2
ai

Submodule ai updated: ade6ce69f0...761b842553

View File

@@ -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;
};
},

View File

@@ -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,
},
};
};

View File

@@ -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,

View File

@@ -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,
),
);

View File

@@ -0,0 +1,4 @@
export { hasVersionBumpUpdatePlan } from "./hasVersionBumpUpdatePlan.js";
export { preProcessMigration } from "./preProcessMigration.js";
export { preProcessMigrationFilter } from "./preProcessMigrationFilter.js";
export { preProcessMigrationOperations } from "./preProcessMigrationOperations.js";

View File

@@ -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 = <M extends MigrationRuntime>(
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 };
};

View File

@@ -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 },
};
};

View File

@@ -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 };
};

View File

@@ -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,
};

View File

@@ -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),
),
);
},
);

View File

@@ -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<ApiCustomerV3>(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<ApiCustomerV3>(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<ApiCustomerV3>(regularCustomerId);
expectCustomerFeatureCorrect({
customer: regularCustomer,
featureId: TestFeature.Messages,
includedUsage: 600,
balance: 600,
usage: 0,
});
// Custom: untouched, still on 800.
const customCustomer =
await autumnV1.customers.get<ApiCustomerV3>(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<ApiCustomerV3>(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<ApiCustomerV5>(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<ApiCustomerV3>(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<ApiCustomerV5>(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<ApiCustomerV3>(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<ApiCustomerV3>(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<ApiCustomerV3>(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<ApiCustomerV3>(customerId);
expectCustomerFeatureCorrect({
customer,
featureId: TestFeature.Messages,
includedUsage: 600,
balance: 600,
usage: 0,
});
});

View File

@@ -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<ApiCustomerV3>(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<ApiCustomerV3>(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<ApiCustomerV3>(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<ApiCustomerV3>(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<ApiCustomerV3>(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<ApiCustomerV3>(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<ApiCustomerV3>(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<ApiCustomerV3>(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<ApiCustomerV3>(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<ApiCustomerV3>(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<ApiCustomerV3>(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<ApiCustomerV3>(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,
});
});

View File

@@ -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,

View File

@@ -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();

View File

@@ -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) {

View File

@@ -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

View File

@@ -48,6 +48,11 @@ export type PlanFilter = {
/** `recurring: true` already implies a paid plan. */
paid?: z.infer<typeof BooleanMatcherSchema>;
recurring?: z.infer<typeof BooleanMatcherSchema>;
/** 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<typeof BooleanMatcherSchema>;
item?:
| z.infer<typeof PlanItemFilterSchema>
| {
@@ -65,6 +70,7 @@ export const PlanFilterSchema: z.ZodType<PlanFilter> = z.lazy(() =>
addon: BooleanMatcherSchema.optional(),
paid: BooleanMatcherSchema.optional(),
recurring: BooleanMatcherSchema.optional(),
custom: BooleanMatcherSchema.optional(),
item: arrayFilter(PlanItemFilterSchema).optional(),
$or: z.array(PlanFilterSchema).optional(),
}),

View File

@@ -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<string, unknown>)[key] !== undefined)

View File

@@ -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(),
}),
});

View File

@@ -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: <UserIcon size={14} className="text-t3" />,
}));
.map((c) => {
const label = c.name ?? c.email ?? c.id;
return {
value: c.id,
label,
sublabel: label === c.id ? undefined : c.id,
icon: <UserIcon size={14} className="text-t3" />,
};
});
}
if (field === "plan_id") return buildPlanSuggestions(products);
if (field === "item_feature_id") {

View File

@@ -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({
<CommandGroup>
{suggestions.map((suggestion) => {
const isSelected = selectedValues.includes(suggestion.value);
const keywords = [suggestion.label];
if (suggestion.sublabel) keywords.push(suggestion.sublabel);
return (
<CommandItem
key={suggestion.value}
value={suggestion.value}
keywords={keywords}
onSelect={() => onToggle(suggestion.value)}
className="text-sm"
>
@@ -121,6 +125,11 @@ export function ValuePicker({
<span className="flex-1 truncate">
{suggestion.label}
</span>
{suggestion.sublabel && (
<span className="shrink-0 max-w-48 truncate text-t3 text-xs font-mono">
{suggestion.sublabel}
</span>
)}
{isSelected && (
<CheckIcon size={14} className="shrink-0" />
)}

View File

@@ -15,6 +15,7 @@ export function buildPlanSuggestions(
.map((p) => ({
value: p.id,
label: p.name || p.id,
sublabel: p.name ? p.id : undefined,
icon: <PackageIcon size={14} weight="duotone" className="text-t3" />,
}));
}