Merge pull request #1564 from useautumn/fix/migration-filters
fix: migration filters
This commit is contained in:
9
.vscode/tasks.json
vendored
9
.vscode/tasks.json
vendored
@@ -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
2
ai
Submodule ai updated: ade6ce69f0...761b842553
3
server/src/external/autumn/autumnCli.ts
vendored
3
server/src/external/autumn/autumnCli.ts
vendored
@@ -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;
|
||||
};
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
);
|
||||
@@ -0,0 +1,4 @@
|
||||
export { hasVersionBumpUpdatePlan } from "./hasVersionBumpUpdatePlan.js";
|
||||
export { preProcessMigration } from "./preProcessMigration.js";
|
||||
export { preProcessMigrationFilter } from "./preProcessMigrationFilter.js";
|
||||
export { preProcessMigrationOperations } from "./preProcessMigrationOperations.js";
|
||||
@@ -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 };
|
||||
};
|
||||
@@ -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 },
|
||||
};
|
||||
};
|
||||
@@ -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 };
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(),
|
||||
}),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(),
|
||||
}),
|
||||
});
|
||||
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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" />
|
||||
)}
|
||||
|
||||
@@ -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" />,
|
||||
}));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user