From f0684dd048acc1ff4186f0ae65e779bee6a9e618 Mon Sep 17 00:00:00 2001 From: Charlie Lamb Date: Wed, 13 May 2026 12:26:04 +0100 Subject: [PATCH] chore: add add_plan operation in migrations --- .../migrations/v2/operations/addPlan/index.ts | 1 + .../v2/operations/addPlan/processAddPlan.ts | 151 ++++++++++++++++++ .../v2/operations/operationRegistry.ts | 32 ++++ .../run/migrateCustomer/processOperations.ts | 25 +-- .../add-plan-op-basic.test.ts | 146 +++++++++++++++++ .../add-plan-op-features.test.ts | 134 ++++++++++++++++ .../add-plan-op-none-filter.test.ts | 53 ++++++ .../add-plan-op-preview.test.ts | 69 ++++++++ .../utils/runMigrationPreview.ts | 97 +++++++++++ .../compiler/none-quantifier.test.ts | 46 ++++++ .../compiler/filterToIr/navs/parsePlanNav.ts | 42 +++-- shared/api/migrations/compiler/ir/irTypes.ts | 5 +- .../migrations/compiler/irToSql/irToSql.ts | 13 +- .../operations/customer/addPlan/addPlanOp.ts | 25 +++ .../operations/customer/addPlan/index.ts | 1 + .../operations/customer/customerOperations.ts | 8 +- .../migrations/operations/customer/index.ts | 1 + 17 files changed, 822 insertions(+), 27 deletions(-) create mode 100644 server/src/internal/migrations/v2/operations/addPlan/index.ts create mode 100644 server/src/internal/migrations/v2/operations/addPlan/processAddPlan.ts create mode 100644 server/src/internal/migrations/v2/operations/operationRegistry.ts create mode 100644 server/tests/integration/billing/migrations-v2/add-plan-operation/add-plan-op-basic.test.ts create mode 100644 server/tests/integration/billing/migrations-v2/add-plan-operation/add-plan-op-features.test.ts create mode 100644 server/tests/integration/billing/migrations-v2/add-plan-operation/add-plan-op-none-filter.test.ts create mode 100644 server/tests/integration/billing/migrations-v2/add-plan-operation/add-plan-op-preview.test.ts create mode 100644 server/tests/integration/billing/migrations-v2/utils/runMigrationPreview.ts create mode 100644 server/tests/unit/migrations-v2/compiler/none-quantifier.test.ts create mode 100644 shared/api/migrations/operations/customer/addPlan/addPlanOp.ts create mode 100644 shared/api/migrations/operations/customer/addPlan/index.ts diff --git a/server/src/internal/migrations/v2/operations/addPlan/index.ts b/server/src/internal/migrations/v2/operations/addPlan/index.ts new file mode 100644 index 000000000..b350cdfe4 --- /dev/null +++ b/server/src/internal/migrations/v2/operations/addPlan/index.ts @@ -0,0 +1 @@ +export * from "./processAddPlan.js"; diff --git a/server/src/internal/migrations/v2/operations/addPlan/processAddPlan.ts b/server/src/internal/migrations/v2/operations/addPlan/processAddPlan.ts new file mode 100644 index 000000000..225db0bd4 --- /dev/null +++ b/server/src/internal/migrations/v2/operations/addPlan/processAddPlan.ts @@ -0,0 +1,151 @@ +import { + BillingVersion, + CollectionMethod, + CusProductStatus, + type Customer, + type CustomerEntitlement, + type CustomerPrice, + customerProductHasActiveStatus, + type FullCusProduct, +} from "@autumn/shared"; +import type { AddPlanOp } from "@autumn/shared/api/migrations/operations/customer/addPlan/index.js"; +import { initCusEntitlement } from "@/internal/customers/add-product/initCusEnt.js"; +import { getEntRelatedPrice } from "@/internal/products/entitlements/entitlementUtils.js"; +import { ProductService } from "@/internal/products/ProductService.js"; +import { getEntOptions } from "@/internal/products/prices/priceUtils.js"; +import { generateId } from "@/utils/genUtils.js"; +import type { OperationProcessor } from "../types/index.js"; +import { mergeAutumnBillingPlans } from "../utils/index.js"; + +export const processAddPlan: OperationProcessor = async ({ + ctx, + op, + plan, + projectedFullCustomer, +}) => { + const product = await ProductService.getFull({ + db: ctx.db, + idOrInternalId: op.plan_id, + orgId: ctx.org.id, + env: ctx.env, + version: op.version, + allowNotFound: true, + }); + + if (!product) { + throw new Error( + `add_plan: product "${op.plan_id}" (version ${op.version ?? "latest"}) not found in catalog`, + ); + } + + const alreadyHasPlan = projectedFullCustomer.customer_products.some( + (cp) => + cp.internal_product_id === product.internal_id && + customerProductHasActiveStatus(cp), + ); + if (alreadyHasPlan) + return { + plan, + projectedFullCustomer, + matchedCustomerProducts: 0, + billingContexts: [], + }; + + const cusProductId = generateId("cus_prod"); + const now = Date.now(); + const optionsList = + op.feature_quantities?.map((fq) => ({ + feature_id: fq.feature_id, + quantity: fq.quantity, + })) ?? []; + + const customer: Pick = { + internal_id: projectedFullCustomer.internal_id, + id: projectedFullCustomer.id, + }; + + const customerEntitlements: CustomerEntitlement[] = product.entitlements.map( + (entitlement) => + initCusEntitlement({ + entitlement, + customer: customer as Customer, + cusProductId, + freeTrial: null, + options: getEntOptions(optionsList, entitlement) || undefined, + relatedPrice: getEntRelatedPrice(entitlement, product.prices), + entities: [], + carryExistingUsages: false, + curCusProduct: undefined, + replaceables: [], + }), + ); + + const customerPrices: CustomerPrice[] = product.prices.map((price) => ({ + id: generateId("cus_price"), + internal_customer_id: projectedFullCustomer.internal_id, + customer_product_id: cusProductId, + created_at: now, + price_id: price.id || null, + })); + + const newCusProduct: FullCusProduct = { + id: cusProductId, + internal_customer_id: projectedFullCustomer.internal_id, + customer_id: projectedFullCustomer.id, + internal_product_id: product.internal_id, + product_id: product.id, + created_at: now, + updated_at: now, + canceled: false, + ended_at: null, + status: CusProductStatus.Active, + processor: projectedFullCustomer.processor ?? { type: "stripe" as const }, + starts_at: now, + trial_ends_at: null, + options: optionsList, + free_trial_id: null, + canceled_at: null, + collection_method: CollectionMethod.ChargeAutomatically, + subscription_ids: [], + scheduled_ids: [], + is_custom: false, + quantity: 1, + internal_entity_id: undefined, + entity_id: undefined, + api_semver: null, + billing_version: BillingVersion.V1, + external_id: null, + product, + customer_entitlements: customerEntitlements.map((ce) => { + const entitlement = product.entitlements.find( + (e) => e.id === ce.entitlement_id, + ); + if (!entitlement) + throw new Error( + `add_plan: entitlement ${ce.entitlement_id} not found on product ${product.id}`, + ); + return { ...ce, entitlement, replaceables: [], rollovers: [] }; + }), + customer_prices: customerPrices.map((cp) => { + const price = product.prices.find((p) => p.id === cp.price_id); + if (!price) + throw new Error( + `add_plan: price ${cp.price_id} not found on product ${product.id}`, + ); + return { ...cp, price }; + }), + }; + + return { + plan: mergeAutumnBillingPlans({ + base: plan, + incoming: { + customerId: plan.customerId, + insertCustomerProducts: [newCusProduct], + }, + }), + projectedFullCustomer, + matchedCustomerProducts: 1, + billingContexts: [], + }; +}; diff --git a/server/src/internal/migrations/v2/operations/operationRegistry.ts b/server/src/internal/migrations/v2/operations/operationRegistry.ts new file mode 100644 index 000000000..59702fd3a --- /dev/null +++ b/server/src/internal/migrations/v2/operations/operationRegistry.ts @@ -0,0 +1,32 @@ +import type { CustomerOperation } from "@autumn/shared/api/migrations/operations/customer/index.js"; +import { processAddPlan } from "./addPlan/index.js"; +import type { OperationProcessor } from "./types/index.js"; +import { processUpdatePlan } from "./updatePlan/index.js"; + +/** Execution order — operations are sorted to match this sequence. */ +const EXECUTION_ORDER: CustomerOperation["type"][] = [ + "add_plan", + "update_plan", +]; + +const processors: Record< + CustomerOperation["type"], + OperationProcessor +> = { + add_plan: processAddPlan as OperationProcessor, + update_plan: processUpdatePlan as OperationProcessor, +}; + +export function getProcessor({ + type, +}: { type: string }): OperationProcessor { + const processor = processors[type as CustomerOperation["type"]]; + if (!processor) + throw new Error(`No processor registered for operation type "${type}"`); + return processor; +} + +export function executionPriority({ type }: { type: string }): number { + const index = EXECUTION_ORDER.indexOf(type as CustomerOperation["type"]); + return index === -1 ? EXECUTION_ORDER.length : index; +} diff --git a/server/src/internal/migrations/v2/run/migrateCustomer/processOperations.ts b/server/src/internal/migrations/v2/run/migrateCustomer/processOperations.ts index 6e1fa2b82..bcea9c1f0 100644 --- a/server/src/internal/migrations/v2/run/migrateCustomer/processOperations.ts +++ b/server/src/internal/migrations/v2/run/migrateCustomer/processOperations.ts @@ -1,18 +1,21 @@ import type { AutumnBillingPlan } from "@autumn/shared"; import type { AutumnContext } from "@/honoUtils/HonoEnv.js"; import { applyAutumnBillingPlanToFullCustomer } from "@/internal/billing/v2/utils/autumnBillingPlanToFinalFullCustomer.js"; +import { + executionPriority, + getProcessor, +} from "@/internal/migrations/v2/operations/operationRegistry.js"; import type { MigrateCustomerContext, ProcessOperationResult, } from "@/internal/migrations/v2/operations/types/index.js"; -import { processUpdatePlan } from "@/internal/migrations/v2/operations/updatePlan/index.js"; /** * Fold ordered customer operations onto one AutumnBillingPlan. * - * Each op matches against the projected customer state produced by all - * previous ops, so later operations can target customer products created or - * patched earlier in the same migration. + * Operations are sorted by execution order (add_plan → update_plan), + * preserving original array order within the same type. Each op sees + * the projected customer state from all previous ops. */ export const processOperations = async ({ ctx, @@ -30,14 +33,18 @@ export const processOperations = async ({ billingContexts: [], }; - for (const [opIndex, op] of ( - context.migration.operations?.customer ?? [] - ).entries()) { - const result = await processUpdatePlan({ + const operations = context.migration.operations?.customer ?? []; + const sorted = operations + .map((op, originalIndex) => ({ op, originalIndex })) + .sort((a, b) => executionPriority(a.op) - executionPriority(b.op)); + + for (const { op, originalIndex } of sorted) { + const processor = getProcessor(op); + const result = await processor({ ctx, context, op, - opIndex, + opIndex: originalIndex, plan: state.plan, projectedFullCustomer: state.projectedFullCustomer, }); diff --git a/server/tests/integration/billing/migrations-v2/add-plan-operation/add-plan-op-basic.test.ts b/server/tests/integration/billing/migrations-v2/add-plan-operation/add-plan-op-basic.test.ts new file mode 100644 index 000000000..207de9af0 --- /dev/null +++ b/server/tests/integration/billing/migrations-v2/add-plan-operation/add-plan-op-basic.test.ts @@ -0,0 +1,146 @@ +/** + * Integration tests for the add_plan migration operation — basic cases. + * + * Contract under test: + * - add_plan attaches a new plan to a customer who doesn't have it. + * - add_plan is idempotent: skipped if the customer already has the plan active. + * - add_plan runs before update_plan so a later update_plan can target the new plan. + * - add_plan with a non-existent plan_id fails the item. + */ + +import { expect, test } from "bun:test"; +import { TestFeature } from "@tests/setup/v2Features"; +import { items } from "@tests/utils/fixtures/items"; +import { products } from "@tests/utils/fixtures/products"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario"; +import chalk from "chalk"; +import { runMigrationAndWait } from "../utils/runMigrationPreview"; + +test(`${chalk.yellowBright("add_plan: attaches a new free plan")}`, async () => { + const suffix = Date.now(); + const customerId = `add-plan-basic-${suffix}`; + const existing = products.base({ + id: `add-plan-existing-${suffix}`, + items: [items.monthlyMessages({ includedUsage: 100 })], + }); + const newPlan = products.base({ + id: `add-plan-new-${suffix}`, + items: [items.dashboard()], + }); + + const { autumnV2_2 } = await initScenario({ + customerId, + setup: [s.customer(), s.products({ list: [existing, newPlan] })], + actions: [s.billing.attach({ productId: existing.id })], + }); + + const result = await runMigrationAndWait({ + migrationClient: autumnV2_2, + migrationId: `${customerId}-mig`, + filter: { customer: { plan: { plan_id: existing.id } } }, + operations: { + customer: [{ type: "add_plan", plan_id: newPlan.id }], + }, + }); + + expect(result.status).toBe("succeeded"); + const preview = result.response.preview as Record; + const planChanges = preview.plan_changes as unknown[]; + expect(planChanges.length).toBe(1); +}); + +test(`${chalk.yellowBright("add_plan: idempotent — skipped if customer already has plan")}`, async () => { + const suffix = Date.now(); + const customerId = `add-plan-idem-${suffix}`; + const plan = products.base({ + id: `add-plan-idem-plan-${suffix}`, + items: [items.dashboard()], + }); + + const { autumnV2_2 } = await initScenario({ + customerId, + setup: [s.customer(), s.products({ list: [plan] })], + actions: [s.billing.attach({ productId: plan.id })], + }); + + const result = await runMigrationAndWait({ + migrationClient: autumnV2_2, + migrationId: `${customerId}-mig`, + filter: { customer: { customer_id: customerId } }, + operations: { + customer: [{ type: "add_plan", plan_id: plan.id }], + }, + }); + + expect(result.status).toBe("skipped"); +}); + +test(`${chalk.yellowBright("add_plan: runs before update_plan (ordering)")}`, async () => { + const suffix = Date.now(); + const customerId = `add-then-update-${suffix}`; + const existing = products.base({ + id: `add-upd-existing-${suffix}`, + items: [items.monthlyMessages({ includedUsage: 100 })], + }); + const newPlan = products.base({ + id: `add-upd-new-${suffix}`, + items: [items.dashboard()], + }); + + const { autumnV2_2 } = await initScenario({ + customerId, + setup: [s.customer(), s.products({ list: [existing, newPlan] })], + actions: [s.billing.attach({ productId: existing.id })], + }); + + const result = await runMigrationAndWait({ + migrationClient: autumnV2_2, + migrationId: `${customerId}-mig`, + filter: { customer: { plan: { plan_id: existing.id } } }, + operations: { + customer: [ + { + type: "update_plan", + plan_filter: { plan_id: newPlan.id }, + customize: { + add_items: [{ feature_id: TestFeature.AdminRights }], + }, + }, + { type: "add_plan", plan_id: newPlan.id }, + ], + }, + }); + + expect(result.status).toBe("succeeded"); + const preview = result.response.preview as Record; + const planChanges = preview.plan_changes as unknown[]; + expect(planChanges.length).toBe(2); +}); + +test(`${chalk.yellowBright("add_plan: non-existent plan_id fails")}`, async () => { + const suffix = Date.now(); + const customerId = `add-plan-missing-${suffix}`; + const existing = products.base({ + id: `add-plan-miss-${suffix}`, + items: [items.dashboard()], + }); + + const { autumnV2_2 } = await initScenario({ + customerId, + setup: [s.customer(), s.products({ list: [existing] })], + actions: [s.billing.attach({ productId: existing.id })], + }); + + const result = await runMigrationAndWait({ + migrationClient: autumnV2_2, + migrationId: `${customerId}-mig`, + filter: { customer: { customer_id: customerId } }, + operations: { + customer: [{ type: "add_plan", plan_id: "nonexistent-plan-id" }], + }, + }); + + expect(result.status).toBe("failed"); + const error = result.response.error as { message: string } | undefined; + expect(error?.message).toContain("not found"); +}); diff --git a/server/tests/integration/billing/migrations-v2/add-plan-operation/add-plan-op-features.test.ts b/server/tests/integration/billing/migrations-v2/add-plan-operation/add-plan-op-features.test.ts new file mode 100644 index 000000000..0eef078d3 --- /dev/null +++ b/server/tests/integration/billing/migrations-v2/add-plan-operation/add-plan-op-features.test.ts @@ -0,0 +1,134 @@ +/** + * Integration tests for add_plan — feature quantities and paid plans. + * + * Contract under test: + * - add_plan with feature_quantities sets initial balances on the new plan. + * - add_plan attaching a paid plan creates prices on the cusProduct. + * - add_plan with a specific version targets that catalog version. + */ + +import { expect, test } from "bun:test"; +import { TestFeature } from "@tests/setup/v2Features"; +import { items } from "@tests/utils/fixtures/items"; +import { products } from "@tests/utils/fixtures/products"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario"; +import chalk from "chalk"; +import { runMigrationAndWait } from "../utils/runMigrationPreview"; + +test(`${chalk.yellowBright("add_plan: feature_quantities sets initial balances")}`, async () => { + const suffix = Date.now(); + const customerId = `add-plan-quantities-${suffix}`; + const existing = products.base({ + id: `add-qty-existing-${suffix}`, + items: [items.dashboard()], + }); + const newPlan = products.base({ + id: `add-qty-new-${suffix}`, + items: [items.prepaidMessages({ includedUsage: 0, billingUnits: 100 })], + }); + + const { autumnV2_2 } = await initScenario({ + customerId, + setup: [s.customer(), s.products({ list: [existing, newPlan] })], + actions: [s.billing.attach({ productId: existing.id })], + }); + + const result = await runMigrationAndWait({ + migrationClient: autumnV2_2, + migrationId: `${customerId}-mig`, + filter: { customer: { customer_id: customerId } }, + operations: { + customer: [ + { + type: "add_plan", + plan_id: newPlan.id, + feature_quantities: [ + { feature_id: TestFeature.Messages, quantity: 500 }, + ], + }, + ], + }, + }); + + expect(result.status).toBe("succeeded"); + const preview = result.response.preview as Record; + const planChanges = preview.plan_changes as unknown[]; + expect(planChanges.length).toBe(1); +}); + +test(`${chalk.yellowBright("add_plan: paid plan creates cusProduct with prices")}`, async () => { + const suffix = Date.now(); + const customerId = `add-plan-paid-${suffix}`; + const free = products.base({ + id: `add-paid-free-${suffix}`, + items: [items.dashboard()], + }); + const paid = products.pro({ + id: `add-paid-pro-${suffix}`, + items: [items.monthlyMessages({ includedUsage: 100 })], + }); + + const { autumnV2_2 } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [free, paid] }), + ], + actions: [s.billing.attach({ productId: free.id })], + }); + + const result = await runMigrationAndWait({ + migrationClient: autumnV2_2, + migrationId: `${customerId}-mig`, + filter: { customer: { customer_id: customerId } }, + operations: { + customer: [{ type: "add_plan", plan_id: paid.id }], + }, + }); + + expect(result.status).toBe("succeeded"); + const preview = result.response.preview as Record; + const planChanges = preview.plan_changes as unknown[]; + expect(planChanges.length).toBe(1); +}); + +test(`${chalk.yellowBright("add_plan: targets specific catalog version")}`, async () => { + const suffix = Date.now(); + const customerId = `add-plan-version-${suffix}`; + const existing = products.base({ + id: `add-ver-existing-${suffix}`, + items: [items.dashboard()], + }); + const versionedPlan = products.base({ + id: `add-ver-target-${suffix}`, + items: [items.monthlyMessages({ includedUsage: 50 })], + }); + + const { autumnV1, autumnV2_2 } = await initScenario({ + customerId, + setup: [s.customer(), s.products({ list: [existing, versionedPlan] })], + actions: [s.billing.attach({ productId: existing.id })], + }); + + await autumnV1.products.update(versionedPlan.id, { + items: [ + { + feature_id: TestFeature.Messages, + included: 200, + reset: { interval: "month" }, + }, + ], + new_version: true, + }); + + const result = await runMigrationAndWait({ + migrationClient: autumnV2_2, + migrationId: `${customerId}-mig`, + filter: { customer: { customer_id: customerId } }, + operations: { + customer: [{ type: "add_plan", plan_id: versionedPlan.id, version: 1 }], + }, + }); + + expect(result.status).toBe("succeeded"); +}); diff --git a/server/tests/integration/billing/migrations-v2/add-plan-operation/add-plan-op-none-filter.test.ts b/server/tests/integration/billing/migrations-v2/add-plan-operation/add-plan-op-none-filter.test.ts new file mode 100644 index 000000000..ce380582f --- /dev/null +++ b/server/tests/integration/billing/migrations-v2/add-plan-operation/add-plan-op-none-filter.test.ts @@ -0,0 +1,53 @@ +/** + * Integration tests for $none filter quantifier with add_plan. + * + * Contract under test: + * - $none with empty filter matches customers with no active plans. + * - $none with plan_id matches customers who don't have that specific plan. + * - Combining $none filter + add_plan attaches a plan to customers who lack it. + */ + +import { expect, test } from "bun:test"; +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 { runMigrationAndWait } from "../utils/runMigrationPreview"; + +test(`${chalk.yellowBright("$none filter: add_plan to customers without a specific plan")}`, async () => { + const suffix = Date.now(); + const customerId = `none-filter-add-${suffix}`; + const planA = products.base({ + id: `none-plan-a-${suffix}`, + items: [items.dashboard()], + }); + const planB = products.base({ + id: `none-plan-b-${suffix}`, + items: [items.monthlyMessages({ includedUsage: 100 })], + }); + + const { autumnV2_2 } = await initScenario({ + customerId, + setup: [s.customer(), s.products({ list: [planA, planB] })], + actions: [s.billing.attach({ productId: planA.id })], + }); + + const result = await runMigrationAndWait({ + migrationClient: autumnV2_2, + migrationId: `${customerId}-mig`, + filter: { + customer: { + plan: { $none: { plan_id: planB.id } }, + }, + }, + operations: { + customer: [{ type: "add_plan", plan_id: planB.id }], + }, + }); + + expect(result.status).toBe("succeeded"); + const preview = result.response.preview as Record; + const planChanges = preview.plan_changes as unknown[]; + expect(planChanges.length).toBe(1); +}); + diff --git a/server/tests/integration/billing/migrations-v2/add-plan-operation/add-plan-op-preview.test.ts b/server/tests/integration/billing/migrations-v2/add-plan-operation/add-plan-op-preview.test.ts new file mode 100644 index 000000000..a0480f25e --- /dev/null +++ b/server/tests/integration/billing/migrations-v2/add-plan-operation/add-plan-op-preview.test.ts @@ -0,0 +1,69 @@ +/** + * Integration tests for add_plan — preview output. + * + * Contract under test: + * - add_plan preview emits a "created" plan_change with the plan_id. + * - add_plan with boolean features emits flag_changes. + * - add_plan with metered features emits balance_changes. + */ + +import { expect, test } from "bun:test"; +import { TestFeature } from "@tests/setup/v2Features"; +import { items } from "@tests/utils/fixtures/items"; +import { products } from "@tests/utils/fixtures/products"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario"; +import chalk from "chalk"; +import { runMigrationAndWait } from "../utils/runMigrationPreview"; + +type PreviewPlanChange = { + action: string; + plan_id: string; + item_changes: Array<{ action: string; feature_id: string }>; +}; + +test(`${chalk.yellowBright("add_plan preview: emits created plan_change")}`, async () => { + const suffix = Date.now(); + const customerId = `add-plan-preview-created-${suffix}`; + const existing = products.base({ + id: `add-prev-existing-${suffix}`, + items: [items.monthlyMessages({ includedUsage: 100 })], + }); + const newPlan = products.base({ + id: `add-prev-new-${suffix}`, + items: [items.dashboard(), items.monthlyCredits({ includedUsage: 50 })], + }); + + const { autumnV2_2 } = await initScenario({ + customerId, + setup: [s.customer(), s.products({ list: [existing, newPlan] })], + actions: [s.billing.attach({ productId: existing.id })], + }); + + const result = await runMigrationAndWait({ + migrationClient: autumnV2_2, + migrationId: `${customerId}-mig`, + filter: { customer: { customer_id: customerId } }, + operations: { + customer: [{ type: "add_plan", plan_id: newPlan.id }], + }, + }); + + expect(result.status).toBe("succeeded"); + const preview = result.response.preview as { + plan_changes: PreviewPlanChange[]; + balance_changes: unknown[]; + flag_changes: unknown[]; + }; + + expect(preview.plan_changes.length).toBe(1); + const planChange = JSON.parse( + typeof preview.plan_changes[0] === "string" + ? preview.plan_changes[0] + : JSON.stringify(preview.plan_changes[0]), + ) as PreviewPlanChange; + expect(planChange.action).toBe("created"); + expect(planChange.plan_id).toBe(newPlan.id); + + expect(preview.flag_changes.length).toBeGreaterThan(0); + expect(preview.balance_changes.length).toBeGreaterThan(0); +}); diff --git a/server/tests/integration/billing/migrations-v2/utils/runMigrationPreview.ts b/server/tests/integration/billing/migrations-v2/utils/runMigrationPreview.ts new file mode 100644 index 000000000..4d578f298 --- /dev/null +++ b/server/tests/integration/billing/migrations-v2/utils/runMigrationPreview.ts @@ -0,0 +1,97 @@ +import type { Migration } from "@autumn/shared"; +import type { MigrationFilter } from "@autumn/shared/api/migrations/filters/migrationFilter.js"; +import type { Operations } from "@autumn/shared/api/migrations/operations/operations.js"; + +type MigrationClient = { + migrationsV2: { + deleteAndCreate: (params: { + id: string; + filter?: MigrationFilter | null; + operations?: Operations | null; + }) => Promise; + run: (params: { id: string; dry_run?: boolean }) => Promise<{ + migration_id: string; + dry_run: boolean; + run_id: string; + }>; + listItemEvents: (params: { + migrationId: string; + migrationRunId?: string; + }) => Promise<{ list: MigrationItemEvent[] }>; + }; +}; + +type MigrationItemEvent = { + status: string; + dry_run: boolean; + item_id: string; + response: unknown; +}; + +type PreviewResult = { + status: string; + dryRun: boolean; + response: Record; +}; + +const timeout = (ms: number) => + new Promise((resolve) => setTimeout(resolve, ms)); + +function parseResponse(response: unknown): Record { + if (typeof response === "string") return JSON.parse(response); + if (response && typeof response === "object") + return response as Record; + throw new Error(`Invalid migration event response: ${String(response)}`); +} + +export const runMigrationAndWait = async ({ + migrationClient, + migrationId, + filter, + operations, + dryRun = true, + timeoutMs = 45_000, +}: { + migrationClient: MigrationClient; + migrationId: string; + filter: MigrationFilter; + operations: Operations; + dryRun?: boolean; + timeoutMs?: number; +}): Promise => { + const migration = await migrationClient.migrationsV2.deleteAndCreate({ + id: migrationId, + filter, + operations, + }); + const runResponse = await migrationClient.migrationsV2.run({ + id: migration.id, + dry_run: dryRun, + }); + + const start = Date.now(); + let lastError: unknown; + while (Date.now() - start < timeoutMs) { + try { + const events = await migrationClient.migrationsV2.listItemEvents({ + migrationId: migration.id, + migrationRunId: runResponse.run_id, + }); + const event = events.list[0]; + if (!event) throw new Error("No migration item event found"); + return { + status: event.status, + dryRun: event.dry_run, + response: parseResponse(event.response), + }; + } catch (error) { + lastError = error; + await timeout(1_000); + } + } + throw new Error( + `Timed out waiting for migration result: ${ + lastError instanceof Error ? lastError.message : String(lastError) + }`, + ); +}; diff --git a/server/tests/unit/migrations-v2/compiler/none-quantifier.test.ts b/server/tests/unit/migrations-v2/compiler/none-quantifier.test.ts new file mode 100644 index 000000000..2e629e617 --- /dev/null +++ b/server/tests/unit/migrations-v2/compiler/none-quantifier.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, test } from "bun:test"; +import { filterToIr } from "@autumn/shared/api/migrations/compiler/filterToIr/filterToIr.js"; +import type { ResolutionContext } from "@autumn/shared/api/migrations/compiler/filterToIr/resolutionContext.js"; +import { irToSql } from "@autumn/shared/api/migrations/compiler/irToSql/irToSql.js"; +import { customerRegistry } from "@autumn/shared/api/migrations/compiler/registry/customerRegistry.js"; +import type { CustomerFilter } from "@autumn/shared/api/migrations/filters/customerFilter.js"; + +const ctx: ResolutionContext = { features: [] }; +const ambient = { orgId: "org_test", env: "sandbox" }; + +function compile(filter: CustomerFilter) { + const ir = filterToIr({ filter, ctx }); + return irToSql({ ir, root: customerRegistry, ambient }); +} + +describe("$none quantifier", () => { + test("$none with empty filter selects customers with no active plans", () => { + const { sql } = compile({ plan: { $none: {} } }); + expect(sql).toContain("NOT EXISTS"); + }); + + test("$none with plan_id filter selects customers without that plan", () => { + const { sql, params } = compile({ + plan: { $none: { plan_id: "pro" } }, + }); + expect(sql).toContain("NOT EXISTS"); + expect(sql).toContain("p.id = ?"); + expect(params).toContain("pro"); + }); + + test("$some still produces EXISTS", () => { + const { sql } = compile({ + plan: { $some: { plan_id: "pro" } }, + }); + expect(sql).not.toContain("NOT EXISTS"); + expect(sql).toContain("EXISTS"); + }); + + test("bare plan filter (implicit $some) produces EXISTS", () => { + const { sql } = compile({ + plan: { plan_id: "pro" }, + }); + expect(sql).not.toContain("NOT EXISTS"); + expect(sql).toContain("EXISTS"); + }); +}); diff --git a/shared/api/migrations/compiler/filterToIr/navs/parsePlanNav.ts b/shared/api/migrations/compiler/filterToIr/navs/parsePlanNav.ts index 42f31f1e5..62d0f8811 100644 --- a/shared/api/migrations/compiler/filterToIr/navs/parsePlanNav.ts +++ b/shared/api/migrations/compiler/filterToIr/navs/parsePlanNav.ts @@ -1,10 +1,15 @@ import type { CustomerFilter } from "../../../filters/customerFilter.js"; import type { PlanFilter } from "../../../filters/planFilter.js"; -import type { IRNav } from "../../ir/irTypes.js"; +import type { IRNav, IRNode, Quantifier } from "../../ir/irTypes.js"; import { isQuantifierWrapper } from "../helpers/isQuantifierWrapper.js"; import type { ResolutionContext } from "../resolutionContext.js"; import { parsePlanFilter } from "../scopes/parsePlanFilter.js"; +const QUANTIFIER_KEYS: Record = { + $some: "some", + $none: "none", +}; + export function parsePlanNav({ raw, ctx, @@ -12,13 +17,30 @@ export function parsePlanNav({ raw: NonNullable; ctx: ResolutionContext; }): IRNav { - // Phase 1: only $some (implicit if bare). $every / $none deferred. - const planFilter = isQuantifierWrapper(raw) ? raw.$some : (raw as PlanFilter); - if (!planFilter) throw new Error("plan: only $some is supported in phase 1"); - return { - kind: "nav", - name: "plan", - quantifier: "some", - child: parsePlanFilter({ filter: planFilter, ctx }), - }; + if (!isQuantifierWrapper(raw)) + return buildNav({ quantifier: "some", filter: raw as PlanFilter, ctx }); + + for (const [key, quantifier] of Object.entries(QUANTIFIER_KEYS)) { + const filter = (raw as Record)[key] as PlanFilter | undefined; + if (filter !== undefined) return buildNav({ quantifier, filter, ctx }); + } + + const unsupported = Object.keys(raw).find((k) => k.startsWith("$")); + throw new Error(`plan: ${unsupported ?? "unknown quantifier"} is not supported yet`); +} + +function buildNav({ + quantifier, + filter, + ctx, +}: { + quantifier: Quantifier; + filter: PlanFilter; + ctx: ResolutionContext; +}): IRNav { + const hasFields = Object.keys(filter).length > 0; + const child: IRNode = hasFields + ? parsePlanFilter({ filter, ctx }) + : { kind: "and", children: [] }; + return { kind: "nav", name: "plan", quantifier, child }; } diff --git a/shared/api/migrations/compiler/ir/irTypes.ts b/shared/api/migrations/compiler/ir/irTypes.ts index c254732c2..76c978cd2 100644 --- a/shared/api/migrations/compiler/ir/irTypes.ts +++ b/shared/api/migrations/compiler/ir/irTypes.ts @@ -38,12 +38,13 @@ export type IROr = { children: readonly IRNode[]; }; +export type Quantifier = "some" | "none"; + export type IRNav = { kind: "nav"; /** Single segment naming the nav, e.g. "plan" or "item". */ name: string; - /** Phase 1: only "some" is supported. */ - quantifier: "some"; + quantifier: Quantifier; child: IRNode; }; diff --git a/shared/api/migrations/compiler/irToSql/irToSql.ts b/shared/api/migrations/compiler/irToSql/irToSql.ts index bb3ba86fa..44466991f 100644 --- a/shared/api/migrations/compiler/irToSql/irToSql.ts +++ b/shared/api/migrations/compiler/irToSql/irToSql.ts @@ -1,4 +1,4 @@ -import type { IRLeaf, IRNode } from "../ir/irTypes.js"; +import type { IRLeaf, IRNode, Quantifier } from "../ir/irTypes.js"; import type { AmbientPredicate, FieldDef, @@ -112,6 +112,7 @@ function compileNode({ return existsForScope({ scope: def.scope, child: node.child, + quantifier: node.quantifier, params, ambient, }); @@ -120,11 +121,13 @@ function compileNode({ function existsForScope({ scope, child, + quantifier, params, ambient, }: { scope: NavScope; child: IRNode; + quantifier: Quantifier; params: unknown[]; ambient: AmbientContext; }): string { @@ -139,10 +142,12 @@ function existsForScope({ params, ambient, }); - const conditions = [scope.correlation, ...ambientPreds, childSql].join( - " AND ", + const conditions = [scope.correlation, ...ambientPreds, childSql].filter( + (s) => s.length > 0 && s !== "TRUE", ); - return `EXISTS (SELECT 1 FROM ${scope.from} WHERE ${conditions})`; + const whereClause = conditions.length > 0 ? conditions.join(" AND ") : "TRUE"; + const keyword = quantifier === "none" ? "NOT EXISTS" : "EXISTS"; + return `${keyword} (SELECT 1 FROM ${scope.from} WHERE ${whereClause})`; } function compileLeaf({ diff --git a/shared/api/migrations/operations/customer/addPlan/addPlanOp.ts b/shared/api/migrations/operations/customer/addPlan/addPlanOp.ts new file mode 100644 index 000000000..5c952fe84 --- /dev/null +++ b/shared/api/migrations/operations/customer/addPlan/addPlanOp.ts @@ -0,0 +1,25 @@ +import { z } from "zod/v4"; + +/** + * Ordered customer operation: attach a plan to the customer. Skipped + * (idempotent) if the customer already has an active cusProduct for the + * target plan_id + version. + * + * Runs BEFORE update_plan operations so that a subsequent update_plan + * can target the newly-attached plan. + */ +export const AddPlanOpSchema = z.object({ + type: z.literal("add_plan"), + plan_id: z.string(), + version: z.number().int().positive().optional(), + feature_quantities: z + .array( + z.object({ + feature_id: z.string(), + quantity: z.number().int().nonnegative(), + }), + ) + .optional(), +}); + +export type AddPlanOp = z.infer; diff --git a/shared/api/migrations/operations/customer/addPlan/index.ts b/shared/api/migrations/operations/customer/addPlan/index.ts new file mode 100644 index 000000000..2d52630ab --- /dev/null +++ b/shared/api/migrations/operations/customer/addPlan/index.ts @@ -0,0 +1 @@ +export * from "./addPlanOp.js"; diff --git a/shared/api/migrations/operations/customer/customerOperations.ts b/shared/api/migrations/operations/customer/customerOperations.ts index f82461376..0bf784fbc 100644 --- a/shared/api/migrations/operations/customer/customerOperations.ts +++ b/shared/api/migrations/operations/customer/customerOperations.ts @@ -1,14 +1,18 @@ import { z } from "zod/v4"; +import { AddPlanOpSchema } from "./addPlan/index.js"; import { UpdatePlanOpSchema } from "./updatePlan/index.js"; /** * Ordered operations applied to each matched customer. * * Each operation sees the projected customer state produced by the - * operations before it. This lets future `add_plan` operations insert a - * cusProduct that a later `update_plan` operation can target. + * operations before it. `add_plan` inserts a cusProduct that a later + * `update_plan` operation can target. + * + * Execution order: add_plan → update_plan (regardless of array order). */ export const CustomerOperationSchema = z.discriminatedUnion("type", [ + AddPlanOpSchema, UpdatePlanOpSchema, ]); diff --git a/shared/api/migrations/operations/customer/index.ts b/shared/api/migrations/operations/customer/index.ts index 0aad6e797..88ec5aead 100644 --- a/shared/api/migrations/operations/customer/index.ts +++ b/shared/api/migrations/operations/customer/index.ts @@ -1,2 +1,3 @@ +export * from "./addPlan/index.js"; export * from "./customerOperations.js"; export * from "./updatePlan/index.js";