diff --git a/knip.json b/knip.json index c23fe5b83..ffb33f6ea 100644 --- a/knip.json +++ b/knip.json @@ -20,7 +20,7 @@ ], "workspaces": { ".": { - "entry": ["apps/scope-picker/**/*"] + "entry": ["apps/scope-picker/**/*", "trigger.config.ts"] }, "server": { "entry": [ diff --git a/server/src/internal/billing/v2/actions/updateSubscription/setup/setupUpdateSubscriptionProductContext.ts b/server/src/internal/billing/v2/actions/updateSubscription/setup/setupUpdateSubscriptionProductContext.ts index afe66f099..aa5c46f7b 100644 --- a/server/src/internal/billing/v2/actions/updateSubscription/setup/setupUpdateSubscriptionProductContext.ts +++ b/server/src/internal/billing/v2/actions/updateSubscription/setup/setupUpdateSubscriptionProductContext.ts @@ -8,7 +8,10 @@ import { type UpdateSubscriptionV1Params, } from "@autumn/shared"; import type { AutumnContext } from "@/honoUtils/HonoEnv"; -import { setupPatchContext } from "@/internal/billing/v2/setup/patch"; +import { + type ReusePricesAndEntitlements, + setupPatchContext, +} from "@/internal/billing/v2/setup/patch"; import { ProductService } from "@/internal/products/ProductService"; import { setupCustomFullProduct } from "../../../setup/setupCustomFullProduct"; import { findTargetCustomerProduct } from "./findTargetCustomerProduct"; @@ -18,11 +21,13 @@ export const setupUpdateSubscriptionProductContext = async ({ fullCustomer, params, contextOverride = {}, + reusePricesAndEntitlements, }: { ctx: AutumnContext; fullCustomer: FullCustomer; params: UpdateSubscriptionV1Params; contextOverride?: UpdateSubscriptionBillingContextOverride; + reusePricesAndEntitlements?: ReusePricesAndEntitlements; }) => { const { productContext } = contextOverride; @@ -63,6 +68,7 @@ export const setupUpdateSubscriptionProductContext = async ({ params, customerProduct: targetCustomerProduct, fullProduct, + reusePricesAndEntitlements, }); const { diff --git a/server/src/internal/billing/v2/setup/patch/handleCustomizeAddItems.ts b/server/src/internal/billing/v2/setup/patch/handleCustomizeAddItems.ts index 99a0798e3..2ba52b657 100644 --- a/server/src/internal/billing/v2/setup/patch/handleCustomizeAddItems.ts +++ b/server/src/internal/billing/v2/setup/patch/handleCustomizeAddItems.ts @@ -7,15 +7,18 @@ import type { SharedContext, } from "@autumn/shared"; import { planItemV1ToPriceAndEnt } from "@shared/api/products/items/mappers/planItemV1ToPriceAndEnt"; +import type { ReusePricesAndEntitlements } from "./types"; export const handleCustomizeAddItems = ({ ctx, customize, fullProduct, + reusePricesAndEntitlements, }: { ctx: SharedContext; customize: CustomizePlanV1; fullProduct: FullProduct; + reusePricesAndEntitlements?: ReusePricesAndEntitlements; }): { prices: Price[]; entitlements: Entitlement[]; @@ -24,6 +27,19 @@ export const handleCustomizeAddItems = ({ const entitlements: Entitlement[] = []; for (const item of customize.add_items ?? []) { + const overridePrice = item.price_id + ? reusePricesAndEntitlements?.pricesById.get(item.price_id) + : undefined; + const overrideEntitlement = item.entitlement_id + ? reusePricesAndEntitlements?.entitlementsById.get(item.entitlement_id) + : undefined; + + if (overridePrice || overrideEntitlement) { + if (overridePrice) prices.push(overridePrice); + if (overrideEntitlement) entitlements.push(overrideEntitlement); + continue; + } + const { newPrice, newEnt } = planItemV1ToPriceAndEnt({ ctx, item, diff --git a/server/src/internal/billing/v2/setup/patch/handleCustomizePrice.ts b/server/src/internal/billing/v2/setup/patch/handleCustomizePrice.ts index d5799599e..bd45c8744 100644 --- a/server/src/internal/billing/v2/setup/patch/handleCustomizePrice.ts +++ b/server/src/internal/billing/v2/setup/patch/handleCustomizePrice.ts @@ -9,6 +9,7 @@ import type { import { basePriceToProductItem } from "@shared/api/products/components/basePrice/basePriceToProductItem"; import { customerProductToBasePrice } from "@shared/utils/cusProductUtils/convertCusProduct/customerProductToPrice"; import { itemToPriceAndEnt } from "@shared/utils/productV2Utils/productItemUtils/mappers/itemToPriceAndEnt"; +import type { ReusePricesAndEntitlements } from "./types"; const removeCurrentBasePrice = ({ targetCustomerProduct, @@ -41,11 +42,13 @@ export const handleCustomizePrice = ({ customize, targetCustomerProduct, fullProduct, + reusePricesAndEntitlements, }: { ctx: SharedContext; customize: CustomizePlanV1; targetCustomerProduct: FullCusProduct; fullProduct: FullProduct; + reusePricesAndEntitlements?: ReusePricesAndEntitlements; }): { customerPrices: FullCustomerPrice[]; prices: Price[]; @@ -60,18 +63,24 @@ export const handleCustomizePrice = ({ return { customerPrices, prices: [] }; } - const item = basePriceToProductItem({ - ctx, - basePrice: customize.price, - }); - const { newPrice, updatedPrice } = itemToPriceAndEnt({ - item, - orgId: fullProduct.org_id, - internalProductId: fullProduct.internal_id, - isCustom: true, - features: ctx.features, - }); - const price = newPrice ?? updatedPrice; + const overridePrice = customize.price.price_id + ? reusePricesAndEntitlements?.pricesById.get(customize.price.price_id) + : undefined; + let price = overridePrice; + if (!price) { + const item = basePriceToProductItem({ + ctx, + basePrice: customize.price, + }); + const { newPrice, updatedPrice } = itemToPriceAndEnt({ + item, + orgId: fullProduct.org_id, + internalProductId: fullProduct.internal_id, + isCustom: true, + features: ctx.features, + }); + price = newPrice ?? updatedPrice ?? undefined; + } const prices = price ? [price] : []; fullProduct.prices.push(...prices); diff --git a/server/src/internal/billing/v2/setup/patch/index.ts b/server/src/internal/billing/v2/setup/patch/index.ts index c02713267..8a1ddf6d8 100644 --- a/server/src/internal/billing/v2/setup/patch/index.ts +++ b/server/src/internal/billing/v2/setup/patch/index.ts @@ -1,3 +1,4 @@ export * from "./handleCustomizeAddItems"; export * from "./handleCustomizeDeleteItems"; export * from "./setupPatchContext"; +export * from "./types"; diff --git a/server/src/internal/billing/v2/setup/patch/setupPatchContext.ts b/server/src/internal/billing/v2/setup/patch/setupPatchContext.ts index 2a882e9d2..3d118b4d1 100644 --- a/server/src/internal/billing/v2/setup/patch/setupPatchContext.ts +++ b/server/src/internal/billing/v2/setup/patch/setupPatchContext.ts @@ -13,6 +13,7 @@ import { generateId } from "@/utils/genUtils"; import { handleCustomizeAddItems } from "./handleCustomizeAddItems"; import { handleCustomizeDeleteItems } from "./handleCustomizeDeleteItems"; import { handleCustomizePrice } from "./handleCustomizePrice"; +import type { ReusePricesAndEntitlements } from "./types"; const applyProductDefinitionToCustomerProduct = ({ fullProduct, @@ -52,8 +53,8 @@ const applyProductBasePriceToCustomerProduct = ({ const productBasePrice = fullProduct.prices.find(isFixedPrice); if (!productBasePrice) return; - const currentBasePrice = customerProduct.customer_prices.find((customerPrice) => - isFixedPrice(customerPrice.price), + const currentBasePrice = customerProduct.customer_prices.find( + (customerPrice) => isFixedPrice(customerPrice.price), ); if (!currentBasePrice) { @@ -77,11 +78,13 @@ export const setupPatchContext = ({ params, customerProduct, fullProduct, + reusePricesAndEntitlements, }: { ctx: SharedContext; params: UpdateSubscriptionV1Params; customerProduct: FullCusProduct; fullProduct: FullProduct; + reusePricesAndEntitlements?: ReusePricesAndEntitlements; }): PatchContext | undefined => { if (!isCustomizePlanPatchStyle(params.customize)) return undefined; @@ -131,6 +134,7 @@ export const setupPatchContext = ({ customize: params.customize, targetCustomerProduct: finalCustomerProduct, fullProduct: patchFullProduct, + reusePricesAndEntitlements, }); const { prices: customItemPrices, entitlements: customEntitlements } = @@ -138,6 +142,7 @@ export const setupPatchContext = ({ ctx, customize: params.customize, fullProduct: patchFullProduct, + reusePricesAndEntitlements, }); const patchContext: PatchContext = { diff --git a/server/src/internal/billing/v2/setup/patch/types.ts b/server/src/internal/billing/v2/setup/patch/types.ts new file mode 100644 index 000000000..427ed1f68 --- /dev/null +++ b/server/src/internal/billing/v2/setup/patch/types.ts @@ -0,0 +1,6 @@ +import type { Entitlement, Price } from "@autumn/shared"; + +export type ReusePricesAndEntitlements = { + pricesById: Map; + entitlementsById: Map; +}; diff --git a/server/src/internal/migrations/v2/filters/customers/index.ts b/server/src/internal/migrations/v2/filters/customers/index.ts deleted file mode 100644 index 3c22f081a..000000000 --- a/server/src/internal/migrations/v2/filters/customers/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from "./buildCustomerSelect.js"; -export * from "./filterCustomers.js"; diff --git a/server/src/internal/migrations/v2/filters/getFilterCount.ts b/server/src/internal/migrations/v2/filters/getFilterCount.ts deleted file mode 100644 index b85ddb000..000000000 --- a/server/src/internal/migrations/v2/filters/getFilterCount.ts +++ /dev/null @@ -1,24 +0,0 @@ -import type { Migration } from "@autumn/shared"; -import type { AutumnContext } from "@/honoUtils/HonoEnv.js"; -import type { RunScopeKind } from "../run/types/runScope.js"; -import { countCustomers } from "./customers/filterCustomers.js"; - -/** Migration-fed shim. Delegates to per-kind pure counters. */ -export const getFilterCount = async ({ - ctx, - migration, - kind, -}: { - ctx: AutumnContext; - migration: Migration; - kind: RunScopeKind; -}): Promise => { - if (kind !== "customer") - throw new Error( - `getFilterCount: scope kind "${kind}" not supported yet (phase 2+)`, - ); - return countCustomers({ - ctx, - filter: migration.filter?.customer ?? {}, - }); -}; diff --git a/server/src/internal/migrations/v2/filters/index.ts b/server/src/internal/migrations/v2/filters/index.ts deleted file mode 100644 index 09d2486ef..000000000 --- a/server/src/internal/migrations/v2/filters/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -export * from "./customers/index.js"; -export * from "./getFilterCount.js"; -export * from "./iterateOverFilterResults.js"; -export * from "./rawWithParamsToDrizzle.js"; -export * from "./runFilter.js"; diff --git a/server/src/internal/migrations/v2/handlers/handlePatchMigration.ts b/server/src/internal/migrations/v2/handlers/handlePatchMigration.ts index e63973ed6..b745ed450 100644 --- a/server/src/internal/migrations/v2/handlers/handlePatchMigration.ts +++ b/server/src/internal/migrations/v2/handlers/handlePatchMigration.ts @@ -22,7 +22,14 @@ export const handlePatchMigration = createRoute({ const ctx = c.get("ctx"); const { id, updates } = c.req.valid("json"); - const updated = await migrationRepo.update({ ctx, id, updates }); + const updated = await migrationRepo.update({ + ctx, + id, + updates: { + ...updates, + ...(updates.operations !== undefined ? { prepared_state: null } : {}), + }, + }); if (!updated) throw new RecaseError({ diff --git a/server/src/internal/migrations/v2/handlers/handlePrepareMigration.ts b/server/src/internal/migrations/v2/handlers/handlePrepareMigration.ts index dd4f3a119..e68ec388c 100644 --- a/server/src/internal/migrations/v2/handlers/handlePrepareMigration.ts +++ b/server/src/internal/migrations/v2/handlers/handlePrepareMigration.ts @@ -1,7 +1,7 @@ import { ErrCode, RecaseError, Scopes } from "@autumn/shared"; import { z } from "zod/v4"; import { createRoute } from "@/honoMiddlewares/routeHandler"; -import { runPrepare } from "@/internal/migrations/v2/prepare/index.js"; +import { prepare } from "@/internal/migrations/v2/prepare/index.js"; import { migrationRepo } from "@/internal/migrations/v2/repos/index.js"; const PrepareMigrationBody = z.object({ @@ -15,7 +15,7 @@ export const handlePrepareMigration = createRoute({ body: PrepareMigrationBody, handler: async (c) => { const ctx = c.get("ctx"); - const { id, dry_run } = c.req.valid("json"); + const { id, dry_run: dryRun } = c.req.valid("json"); const migration = await migrationRepo.find({ ctx, id }); @@ -26,7 +26,7 @@ export const handlePrepareMigration = createRoute({ statusCode: 400, }); - const { response } = await runPrepare({ ctx, migration, dry_run }); + const { response } = await prepare({ ctx, migration, dryRun }); return c.json(response); }, }); diff --git a/server/src/internal/migrations/v2/handlers/handleRunMigration.ts b/server/src/internal/migrations/v2/handlers/handleRunMigration.ts index bb0baf203..ca81b333e 100644 --- a/server/src/internal/migrations/v2/handlers/handleRunMigration.ts +++ b/server/src/internal/migrations/v2/handlers/handleRunMigration.ts @@ -19,7 +19,7 @@ export const handleRunMigration = createRoute({ body: RunMigrationBody, handler: async (c) => { const ctx = c.get("ctx"); - const { id, dry_run } = c.req.valid("json"); + const { id, dry_run: dryRun } = c.req.valid("json"); const migration = await migrationRepo.find({ ctx, id }); @@ -37,14 +37,14 @@ export const handleRunMigration = createRoute({ orgId: ctx.org.id, env: ctx.env, migrationId: id, - dryRun: dry_run, + dryRun, }, isDev ? { region: "eu-west-1" } : undefined, ); return c.json({ migration_id: id, - dry_run, + dry_run: dryRun, run_id: handle.id, }); }, diff --git a/server/src/internal/migrations/v2/operations/index.ts b/server/src/internal/migrations/v2/operations/index.ts deleted file mode 100644 index 6751dbf8b..000000000 --- a/server/src/internal/migrations/v2/operations/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -export * from "./errors/index.js"; -export * from "./types/index.js"; -export * from "./updatePlan/index.js"; -export * from "./utils/index.js"; diff --git a/server/src/internal/migrations/v2/operations/types/processOperationTypes.ts b/server/src/internal/migrations/v2/operations/types/processOperationTypes.ts index 5b596146c..33034621f 100644 --- a/server/src/internal/migrations/v2/operations/types/processOperationTypes.ts +++ b/server/src/internal/migrations/v2/operations/types/processOperationTypes.ts @@ -26,6 +26,7 @@ export type ProcessOperationResult = { export type OperationProcessor = (args: { ctx: AutumnContext; op: Op; + opIndex: number; context: MigrateCustomerContext; plan: AutumnBillingPlan; projectedFullCustomer: FullCustomer; diff --git a/server/src/internal/migrations/v2/operations/updatePlan/applyPrepareResults/applyPrepareResults.ts b/server/src/internal/migrations/v2/operations/updatePlan/applyPrepareResults/applyPrepareResults.ts new file mode 100644 index 000000000..ee8801afc --- /dev/null +++ b/server/src/internal/migrations/v2/operations/updatePlan/applyPrepareResults/applyPrepareResults.ts @@ -0,0 +1,234 @@ +import type { AutumnBillingPlan } from "@autumn/shared"; +import type { UpdatePlanOp } from "@autumn/shared/api/migrations/operations/customer/updatePlan/index.js"; +import type { ReusePricesAndEntitlements } from "@/internal/billing/v2/setup/patch/index.js"; +import { + EnsurePricesAndEntitlementsResultSchema, + type EnsurePricesAndEntitlementsResult, + type PreparedArtifactRef, +} from "@/internal/migrations/v2/prepare/modules/ensurePricesAndEntitlements/index.js"; +import { buildPrepareModuleKey } from "@/internal/migrations/v2/prepare/utils/index.js"; +import { hashJson } from "@/utils/hash/hashJson.js"; +import { MigrationOperationError } from "../../errors/index.js"; +import type { MigrateCustomerContext } from "../../types/index.js"; + +export type PreparedUpdatePlanArtifactIds = { + priceIds: Set; + entitlementIds: Set; +}; + +export const stripPreparedCatalogRows = ({ + plan, + preparedIds, +}: { + plan: AutumnBillingPlan; + preparedIds: PreparedUpdatePlanArtifactIds; +}): AutumnBillingPlan => ({ + ...plan, + customPrices: plan.customPrices?.filter( + (price) => !preparedIds.priceIds.has(price.id), + ), + customEntitlements: plan.customEntitlements?.filter( + (entitlement) => !preparedIds.entitlementIds.has(entitlement.id), + ), +}); + +const ensurePricesAndEntitlementsKey = buildPrepareModuleKey({ + kind: "ensure_prices_and_entitlements", + parts: ["update_plan"], +}); + +const getPreparedArtifacts = ({ + context, +}: { + context: MigrateCustomerContext; +}): EnsurePricesAndEntitlementsResult => { + const preparedState = + context.migration.prepared_state?.[ensurePricesAndEntitlementsKey]; + const result = + EnsurePricesAndEntitlementsResultSchema.safeParse(preparedState); + + if (!result.success) { + throw new MigrationOperationError({ + code: "missing_prepared_state", + operationType: "update_plan", + field: "prepared_state", + message: + "Migration update_plan requires prepared prices and entitlements. Run prepare before migrating customers.", + details: { prepareKey: ensurePricesAndEntitlementsKey }, + }); + } + + return result.data; +}; + +const findArtifact = ({ + artifacts, + opIndex, + kind, + itemIndex, + hash, +}: { + artifacts: PreparedArtifactRef[]; + opIndex: number; + kind: PreparedArtifactRef["kind"]; + itemIndex?: number; + hash: string; +}) => { + const artifact = artifacts.find( + (candidate) => + candidate.op_index === opIndex && + candidate.kind === kind && + candidate.item_index === itemIndex && + candidate.hash === hash, + ); + + if (!artifact) { + throw new MigrationOperationError({ + code: "missing_prepared_state", + operationType: "update_plan", + field: "prepared_state", + message: + "Migration update_plan prepared_state is missing an artifact for the current operation input.", + details: { opIndex, kind, itemIndex, hash }, + }); + } + + return artifact; +}; + +const addPreparedId = ({ + ids, + artifact, + reusePricesAndEntitlements, +}: { + ids: PreparedUpdatePlanArtifactIds; + artifact: PreparedArtifactRef; + reusePricesAndEntitlements: ReusePricesAndEntitlements; +}) => { + if (artifact.price_id) { + if (!reusePricesAndEntitlements.pricesById.has(artifact.price_id)) { + throw new MigrationOperationError({ + code: "missing_prepared_state", + operationType: "update_plan", + field: "prepared_state", + message: + "Migration update_plan prepared_state references a missing prepared price.", + details: { priceId: artifact.price_id }, + }); + } + ids.priceIds.add(artifact.price_id); + } + if (artifact.entitlement_id) { + if ( + !reusePricesAndEntitlements.entitlementsById.has(artifact.entitlement_id) + ) { + throw new MigrationOperationError({ + code: "missing_prepared_state", + operationType: "update_plan", + field: "prepared_state", + message: + "Migration update_plan prepared_state references a missing prepared entitlement.", + details: { entitlementId: artifact.entitlement_id }, + }); + } + ids.entitlementIds.add(artifact.entitlement_id); + } +}; + +export const applyPrepareResultsToUpdatePlan = ({ + context, + op, + opIndex, +}: { + context: MigrateCustomerContext; + op: UpdatePlanOp; + opIndex: number; +}): { + op: UpdatePlanOp; + preparedIds: PreparedUpdatePlanArtifactIds; + reusePricesAndEntitlements: ReusePricesAndEntitlements; +} => { + const preparedIds: PreparedUpdatePlanArtifactIds = { + priceIds: new Set(), + entitlementIds: new Set(), + }; + const emptyReusableRows: ReusePricesAndEntitlements = { + pricesById: new Map(), + entitlementsById: new Map(), + }; + + const customize = op.customize; + const needsPreparedArtifacts = + (customize?.price !== undefined && customize.price !== null) || + (customize?.add_items?.length ?? 0) > 0; + + if (!customize || !needsPreparedArtifacts) { + return { + op, + preparedIds, + reusePricesAndEntitlements: emptyReusableRows, + }; + } + + const preparedResult = getPreparedArtifacts({ context }); + const artifacts = preparedResult.artifacts; + const reusePricesAndEntitlements: ReusePricesAndEntitlements = { + pricesById: new Map( + preparedResult.prices.map((price) => [price.id, price]), + ), + entitlementsById: new Map( + preparedResult.entitlements.map((entitlement) => [ + entitlement.id, + entitlement, + ]), + ), + }; + const nextCustomize = { ...customize }; + + if (customize.price !== undefined && customize.price !== null) { + const artifact = findArtifact({ + artifacts, + opIndex, + kind: "base_price", + hash: hashJson({ value: customize.price }), + }); + addPreparedId({ ids: preparedIds, artifact, reusePricesAndEntitlements }); + nextCustomize.price = { + ...customize.price, + ...(artifact.price_id ? { price_id: artifact.price_id } : {}), + }; + } + + if (customize.add_items) { + nextCustomize.add_items = customize.add_items.map((item, itemIndex) => { + const artifact = findArtifact({ + artifacts, + opIndex, + kind: "add_item", + itemIndex, + hash: hashJson({ value: item }), + }); + addPreparedId({ + ids: preparedIds, + artifact, + reusePricesAndEntitlements, + }); + return { + ...item, + ...(artifact.price_id ? { price_id: artifact.price_id } : {}), + ...(artifact.entitlement_id + ? { entitlement_id: artifact.entitlement_id } + : {}), + }; + }); + } + + return { + op: { + ...op, + customize: nextCustomize, + }, + preparedIds, + reusePricesAndEntitlements, + }; +}; diff --git a/server/src/internal/migrations/v2/operations/updatePlan/applyPrepareResults/index.ts b/server/src/internal/migrations/v2/operations/updatePlan/applyPrepareResults/index.ts new file mode 100644 index 000000000..31f646122 --- /dev/null +++ b/server/src/internal/migrations/v2/operations/updatePlan/applyPrepareResults/index.ts @@ -0,0 +1 @@ +export * from "./applyPrepareResults.js"; diff --git a/server/src/internal/migrations/v2/operations/updatePlan/processUpdatePlan.ts b/server/src/internal/migrations/v2/operations/updatePlan/processUpdatePlan.ts index b0dc544d8..adde216ad 100644 --- a/server/src/internal/migrations/v2/operations/updatePlan/processUpdatePlan.ts +++ b/server/src/internal/migrations/v2/operations/updatePlan/processUpdatePlan.ts @@ -13,6 +13,7 @@ import { filterCustomerProductsByPlanFilter, mergeAutumnBillingPlans, } from "../utils/index.js"; +import { stripPreparedCatalogRows } from "./applyPrepareResults/index.js"; import { setupUpdatePlanProductContext } from "./setup/index.js"; const assertNoChargeArtifacts = ({ @@ -47,6 +48,7 @@ export const processUpdatePlan = async ({ ctx, context, op, + opIndex, plan, projectedFullCustomer, }: Parameters>[0]) => { @@ -64,6 +66,7 @@ export const processUpdatePlan = async ({ ctx, context, op, + opIndex, projectedFullCustomer, customerProduct, }); @@ -99,10 +102,14 @@ export const processUpdatePlan = async ({ plan: computedPlan, customerProductId: customerProduct.id, }); + const executablePlan = stripPreparedCatalogRows({ + plan: computedPlan, + preparedIds: productContext.preparedIds, + }); nextPlan = mergeAutumnBillingPlans({ base: nextPlan, - incoming: computedPlan, + incoming: executablePlan, }); billingContexts.push(productContext.billingContext); } diff --git a/server/src/internal/migrations/v2/operations/updatePlan/setup/setupUpdatePlanProductContext.ts b/server/src/internal/migrations/v2/operations/updatePlan/setup/setupUpdatePlanProductContext.ts index a09bc31ae..0abd526ad 100644 --- a/server/src/internal/migrations/v2/operations/updatePlan/setup/setupUpdatePlanProductContext.ts +++ b/server/src/internal/migrations/v2/operations/updatePlan/setup/setupUpdatePlanProductContext.ts @@ -17,6 +17,7 @@ import { setupFeatureQuantitiesContext } from "@/internal/billing/v2/setup/setup import { setupInvoiceModeContext } from "@/internal/billing/v2/setup/setupInvoiceModeContext.js"; import { setupMigrationOperationBillingContext } from "@/internal/migrations/v2/run/migrateCustomer/setup/index.js"; import type { MigrateCustomerContext } from "../../types/index.js"; +import { applyPrepareResultsToUpdatePlan } from "../applyPrepareResults/index.js"; import { itemAlreadyExists } from "./itemAlreadyExists.js"; import type { UpdatePlanProductContext } from "./types.js"; @@ -24,30 +25,42 @@ export const setupUpdatePlanProductContext = async ({ ctx, context, op, + opIndex, projectedFullCustomer, customerProduct, }: { ctx: AutumnContext; context: MigrateCustomerContext; op: UpdatePlanOp; + opIndex: number; projectedFullCustomer: FullCustomer; customerProduct: FullCusProduct; }): Promise => { - const addItems = op.customize?.add_items?.filter( + const { + op: preparedOp, + preparedIds, + reusePricesAndEntitlements, + } = applyPrepareResultsToUpdatePlan({ + context, + op, + opIndex, + }); + + const addItems = preparedOp.customize?.add_items?.filter( (item) => !itemAlreadyExists({ ctx, customerProduct, item, - removeItems: op.customize?.remove_items, + removeItems: preparedOp.customize?.remove_items, }), ); const customize = { - ...op.customize, + ...preparedOp.customize, ...(addItems ? { add_items: addItems } : {}), }; if ( - op.version === undefined && + preparedOp.version === undefined && customize.price === undefined && customize.add_items?.length === 0 && customize.remove_items === undefined @@ -72,8 +85,8 @@ export const setupUpdatePlanProductContext = async ({ entity_id: customerProduct.entity_id ?? undefined, customer_product_id: customerProduct.id, plan_id: customerProduct.product.id, - version: op.version, - ...(op.customize ? { customize } : {}), + version: preparedOp.version, + ...(preparedOp.customize ? { customize } : {}), proration_behavior: "none", no_billing_changes: context.migration.no_billing_changes === true ? true : undefined, @@ -89,6 +102,7 @@ export const setupUpdatePlanProductContext = async ({ ctx, fullCustomer: productFullCustomer, params, + reusePricesAndEntitlements, }); const operationBillingContext = await setupMigrationOperationBillingContext({ @@ -152,5 +166,6 @@ export const setupUpdatePlanProductContext = async ({ customerProduct: targetCustomerProduct, params, billingContext, + preparedIds, }; }; diff --git a/server/src/internal/migrations/v2/operations/updatePlan/setup/types.ts b/server/src/internal/migrations/v2/operations/updatePlan/setup/types.ts index 11aedf1fc..19860f8c2 100644 --- a/server/src/internal/migrations/v2/operations/updatePlan/setup/types.ts +++ b/server/src/internal/migrations/v2/operations/updatePlan/setup/types.ts @@ -3,9 +3,11 @@ import type { UpdateSubscriptionBillingContext, UpdateSubscriptionV1Params, } from "@autumn/shared"; +import type { PreparedUpdatePlanArtifactIds } from "../applyPrepareResults/index.js"; export interface UpdatePlanProductContext { customerProduct: FullCusProduct; params: UpdateSubscriptionV1Params; billingContext: UpdateSubscriptionBillingContext; + preparedIds: PreparedUpdatePlanArtifactIds; } diff --git a/server/src/internal/migrations/v2/prepare/getImplicitPrepareModules.ts b/server/src/internal/migrations/v2/prepare/getImplicitPrepareModules.ts new file mode 100644 index 000000000..ca7b530fa --- /dev/null +++ b/server/src/internal/migrations/v2/prepare/getImplicitPrepareModules.ts @@ -0,0 +1,59 @@ +import type { Operations } from "@autumn/shared"; +import type { UpdatePlanOp } from "@autumn/shared/api/migrations/operations/customer/updatePlan/index.js"; +import type { + EnsurePricesAndEntitlementsInput, + ensurePricesAndEntitlements, +} from "./modules/ensurePricesAndEntitlements/index.js"; +import { ensurePricesAndEntitlements as ensurePricesAndEntitlementsModule } from "./modules/ensurePricesAndEntitlements/index.js"; +import { buildPrepareModuleKey } from "./utils/index.js"; + +/** One instance of a prep module to run. */ +export type ImplicitPrepInstance = { + key: string; + module: typeof ensurePricesAndEntitlements; + input: EnsurePricesAndEntitlementsInput; +}; + +/** + * Pure walker. Takes an `operations` object directly so scripts and + * other callers can derive prep instances without a Migration row. + * Module key format: `:update_plan`. + */ +export const getImplicitPrepareModules = ({ + operations, +}: { + operations: Operations | null | undefined; +}): ImplicitPrepInstance[] => { + const modulesByKey = new Map(); + const updatePlanOps: { opIndex: number; op: UpdatePlanOp }[] = []; + + for (const [opIndex, op] of (operations?.customer ?? []).entries()) { + if ( + op.type !== "update_plan" || + !( + (op.customize?.price !== undefined && op.customize.price !== null) || + (op.customize?.add_items?.length ?? 0) > 0 + ) + ) { + continue; + } + + updatePlanOps.push({ opIndex, op }); + } + + if (updatePlanOps.length > 0) { + const key = buildPrepareModuleKey({ + kind: ensurePricesAndEntitlementsModule.kind, + parts: ["update_plan"], + }); + modulesByKey.set(key, { + key, + module: ensurePricesAndEntitlementsModule, + input: { + updatePlanOps, + }, + }); + } + + return Array.from(modulesByKey.values()); +}; diff --git a/server/src/internal/migrations/v2/prepare/index.ts b/server/src/internal/migrations/v2/prepare/index.ts index 059215c1f..dfbaf0e8f 100644 --- a/server/src/internal/migrations/v2/prepare/index.ts +++ b/server/src/internal/migrations/v2/prepare/index.ts @@ -1,5 +1,5 @@ -export * from "./inferImplicitPrep.js"; +export * from "./getImplicitPrepareModules.js"; export * from "./modules/ensurePricesAndEntitlements/index.js"; -export * from "./runPrepare.js"; +export * from "./prepare.js"; export * from "./runPrepareModules.js"; export * from "./types/index.js"; diff --git a/server/src/internal/migrations/v2/prepare/inferImplicitPrep.ts b/server/src/internal/migrations/v2/prepare/inferImplicitPrep.ts deleted file mode 100644 index 587e33b45..000000000 --- a/server/src/internal/migrations/v2/prepare/inferImplicitPrep.ts +++ /dev/null @@ -1,32 +0,0 @@ -import type { Migration, Operations } from "@autumn/shared"; -import type { - EnsurePricesAndEntitlementsInput, - ensurePricesAndEntitlements, -} from "./modules/ensurePricesAndEntitlements/index.js"; - -/** One instance of a prep module to run. */ -export type ImplicitPrepInstance = { - key: string; - module: typeof ensurePricesAndEntitlements; - input: EnsurePricesAndEntitlementsInput; -}; - -/** - * Pure walker. Takes an `operations` object directly so scripts (and - * any other caller) can derive prep instances without a Migration row. - * Module key format: `::`. - */ -export const inferPrepareModules = ({ - operations, -}: { - operations: Operations | null | undefined; -}): ImplicitPrepInstance[] => { - void operations; - return []; -}; - -/** Migration-fed shim. */ -export const inferImplicitPrep = ( - migration: Migration, -): ImplicitPrepInstance[] => - inferPrepareModules({ operations: migration.operations }); diff --git a/server/src/internal/migrations/v2/prepare/modules/ensurePricesAndEntitlements/ensurePricesAndEntitlements.ts b/server/src/internal/migrations/v2/prepare/modules/ensurePricesAndEntitlements/ensurePricesAndEntitlements.ts index d1bee46bb..3072eb82f 100644 --- a/server/src/internal/migrations/v2/prepare/modules/ensurePricesAndEntitlements/ensurePricesAndEntitlements.ts +++ b/server/src/internal/migrations/v2/prepare/modules/ensurePricesAndEntitlements/ensurePricesAndEntitlements.ts @@ -1,32 +1,97 @@ -import { type Entitlement, entitlements } from "@autumn/shared"; -import { inArray } from "drizzle-orm"; +import { type Entitlement, findFeatureById, type Price } from "@autumn/shared"; +import type { UpdatePlanOp } from "@autumn/shared/api/migrations/operations/customer/updatePlan/index.js"; +import { basePriceToProductItem } from "@autumn/shared/api/products/components/basePrice/basePriceToProductItem.js"; +import { planItemV1ToPriceAndEnt } from "@autumn/shared/api/products/items/mappers/planItemV1ToPriceAndEnt.js"; +import { itemToPriceAndEnt } from "@autumn/shared/utils/productV2Utils/productItemUtils/mappers/itemToPriceAndEnt.js"; import { EntitlementService } from "@/internal/products/entitlements/EntitlementService.js"; -import { ProductService } from "@/internal/products/ProductService.js"; +import { PriceService } from "@/internal/products/prices/PriceService.js"; +import { hashJson } from "@/utils/hash/hashJson.js"; import type { PrepareModule } from "../../types/prepareModule.js"; import type { EnsurePricesAndEntitlementsResult, - EntitlementItemRef, + PreparedArtifactRef, } from "./types.js"; export type EnsurePricesAndEntitlementsInput = { - target_plan_id: string; - feature_id: string; + updatePlanOps: { + opIndex: number; + op: UpdatePlanOp; + }[]; }; -/** - * Deterministic ID per (scope, product version, feature). Migration - * scopes pass `scopeId = mig_` to preserve the original - * `ent_mig__<...>` format; scripts pass their own prefix. - */ -export const entitlementIdFor = ({ +const artifactHash = ({ value }: { value: unknown }) => hashJson({ value }); + +const preparedRowId = ({ + prefix, + value, +}: { + prefix: "ent" | "pr"; + value: unknown; +}) => `${prefix}_${hashJson({ value })}`; + +export const basePriceIdFor = ({ scopeId, - productInternalId, - internalFeatureId, + opIndex, + hash, }: { scopeId: string; - productInternalId: string; + opIndex: number; + hash: string; +}): string => + preparedRowId({ + prefix: "pr", + value: { scopeId, opIndex, kind: "base_price", hash }, + }); + +export const priceIdFor = ({ + scopeId, + opIndex, + itemIndex, + internalFeatureId, + hash, +}: { + scopeId: string; + opIndex: number; + itemIndex: number; internalFeatureId: string; -}): string => `ent_${scopeId}_${productInternalId}_${internalFeatureId}`; + hash: string; +}): string => + preparedRowId({ + prefix: "pr", + value: { + scopeId, + opIndex, + itemIndex, + internalFeatureId, + kind: "add_item", + hash, + }, + }); + +export const entitlementIdFor = ({ + scopeId, + opIndex, + itemIndex, + internalFeatureId, + hash, +}: { + scopeId: string; + opIndex: number; + itemIndex: number; + internalFeatureId: string; + hash: string; +}): string => + preparedRowId({ + prefix: "ent", + value: { + scopeId, + opIndex, + itemIndex, + internalFeatureId, + kind: "add_item", + hash, + }, + }); export const ensurePricesAndEntitlements: PrepareModule< EnsurePricesAndEntitlementsInput, @@ -34,75 +99,119 @@ export const ensurePricesAndEntitlements: PrepareModule< > = { kind: "ensure_prices_and_entitlements", - async plan({ ctx, scope_id, input }) { - const feature = ctx.features.find((f) => f.id === input.feature_id); - if (!feature) - throw new Error( - `ensurePricesAndEntitlements: unknown feature_id "${input.feature_id}"`, - ); + async plan({ ctx, scopeId, input }) { + const entitlementsById = new Map(); + const pricesById = new Map(); + const artifacts: PreparedArtifactRef[] = []; - // All versions of the target plan in the org's catalog. - const matchingProducts = await ProductService.listFull({ - db: ctx.db, - orgId: ctx.org.id, - env: ctx.env, - inIds: [input.target_plan_id], - returnAll: true, - excludeEnts: true, - }); + for (const { opIndex, op } of input.updatePlanOps) { + const customize = op.customize; + if (!customize) continue; - const desired: EntitlementItemRef[] = matchingProducts.map((product) => ({ - entitlement_id: entitlementIdFor({ - scopeId: scope_id, - productInternalId: product.internal_id, - internalFeatureId: feature.internal_id, - }), - product_internal_id: product.internal_id, - product_id: product.id, - feature_id: feature.id, - internal_feature_id: feature.internal_id, - })); + if (customize.price) { + const hash = artifactHash({ value: customize.price }); + const priceId = basePriceIdFor({ scopeId, opIndex, hash }); + const item = basePriceToProductItem({ + ctx, + basePrice: customize.price, + }); + const { newPrice, updatedPrice } = itemToPriceAndEnt({ + item, + orgId: ctx.org.id, + isCustom: true, + features: ctx.features, + }); + const price = newPrice ?? updatedPrice; - return { entitlements: desired }; + if (price) { + pricesById.set(priceId, { + ...price, + id: priceId, + internal_product_id: null, + }); + artifacts.push({ + op_index: opIndex, + kind: "base_price", + hash, + price_id: priceId, + }); + } + } + + for (const [itemIndex, item] of (customize.add_items ?? []).entries()) { + const feature = findFeatureById({ + features: ctx.features, + featureId: item.feature_id, + errorOnNotFound: true, + }); + const hash = artifactHash({ value: item }); + const entitlementId = entitlementIdFor({ + scopeId, + opIndex, + itemIndex, + internalFeatureId: feature.internal_id, + hash, + }); + const priceId = priceIdFor({ + scopeId, + opIndex, + itemIndex, + internalFeatureId: feature.internal_id, + hash, + }); + + const { newEnt, newPrice } = planItemV1ToPriceAndEnt({ + ctx, + item, + orgId: ctx.org.id, + isCustom: true, + }); + + if (newEnt) { + entitlementsById.set(entitlementId, { + ...newEnt, + id: entitlementId, + internal_product_id: null, + }); + } + if (newPrice) { + pricesById.set(priceId, { + ...newPrice, + id: priceId, + entitlement_id: newEnt ? entitlementId : newPrice.entitlement_id, + internal_product_id: null, + }); + } + + artifacts.push({ + op_index: opIndex, + kind: "add_item", + item_index: itemIndex, + hash, + ...(newPrice ? { price_id: priceId } : {}), + ...(newEnt ? { entitlement_id: entitlementId } : {}), + }); + } + } + + return { + entitlements: Array.from(entitlementsById.values()), + prices: Array.from(pricesById.values()), + artifacts, + }; }, async apply({ ctx, planned }) { - const desired = planned.entitlements; - const ids = desired.map((d) => d.entitlement_id); - - // Deterministic IDs let us skip rows already present in DB. - const existing = ids.length - ? await ctx.db - .select({ id: entitlements.id }) - .from(entitlements) - .where(inArray(entitlements.id, ids)) - : []; - const existingIds = new Set(existing.map((r) => r.id)); - - const toInsert: Entitlement[] = desired - .filter((d) => !existingIds.has(d.entitlement_id)) - .map((d) => ({ - id: d.entitlement_id, - created_at: Date.now(), - internal_feature_id: d.internal_feature_id, - internal_product_id: d.product_internal_id, - is_custom: false, - allowance_type: null, - allowance: null, - interval: null, - interval_count: 1, - carry_from_previous: false, - entity_feature_id: null, - org_id: ctx.org.id, - feature_id: d.feature_id, - usage_limit: null, - rollover: null, - })); - - if (toInsert.length > 0) { - await EntitlementService.insert({ db: ctx.db, data: toInsert }); + if (planned.entitlements.length > 0) { + await EntitlementService.upsert({ + db: ctx.db, + data: planned.entitlements, + }); + } + if (planned.prices.length > 0) { + await PriceService.upsert({ db: ctx.db, data: planned.prices }); } - return { entitlements: desired }; + return planned; }, }; diff --git a/server/src/internal/migrations/v2/prepare/modules/ensurePricesAndEntitlements/types.ts b/server/src/internal/migrations/v2/prepare/modules/ensurePricesAndEntitlements/types.ts index f3a0ce20b..4792bedc1 100644 --- a/server/src/internal/migrations/v2/prepare/modules/ensurePricesAndEntitlements/types.ts +++ b/server/src/internal/migrations/v2/prepare/modules/ensurePricesAndEntitlements/types.ts @@ -1,19 +1,16 @@ +import { EntitlementSchema, PriceSchema } from "@autumn/shared"; import { z } from "zod/v4"; -/** - * Per-item shape produced by the `ensure_prices_and_entitlements` - * prepare module. Identifies one shared entitlement row keyed by - * deterministic id per (migration, product version, feature). - */ -export const EntitlementItemRefSchema = z.object({ - entitlement_id: z.string(), - product_internal_id: z.string(), - product_id: z.string(), - feature_id: z.string(), - internal_feature_id: z.string(), +export const PreparedArtifactRefSchema = z.object({ + op_index: z.number(), + kind: z.enum(["base_price", "add_item"]), + item_index: z.number().optional(), + hash: z.string(), + price_id: z.string().optional(), + entitlement_id: z.string().optional(), }); -export type EntitlementItemRef = z.infer; +export type PreparedArtifactRef = z.infer; /** * Strict typed payload for this module. Stored under the module key in @@ -21,7 +18,9 @@ export type EntitlementItemRef = z.infer; * response envelope. */ export const EnsurePricesAndEntitlementsResultSchema = z.object({ - entitlements: z.array(EntitlementItemRefSchema), + entitlements: z.array(EntitlementSchema), + prices: z.array(PriceSchema), + artifacts: z.array(PreparedArtifactRefSchema), }); export type EnsurePricesAndEntitlementsResult = z.infer< diff --git a/server/src/internal/migrations/v2/prepare/runPrepare.ts b/server/src/internal/migrations/v2/prepare/prepare.ts similarity index 55% rename from server/src/internal/migrations/v2/prepare/runPrepare.ts rename to server/src/internal/migrations/v2/prepare/prepare.ts index 4157104d0..276fff1ba 100644 --- a/server/src/internal/migrations/v2/prepare/runPrepare.ts +++ b/server/src/internal/migrations/v2/prepare/prepare.ts @@ -1,54 +1,55 @@ import type { Migration } from "@autumn/shared"; import type { AutumnContext } from "../../../../honoUtils/HonoEnv.js"; import { migrationRepo } from "../repos/index.js"; -import { inferImplicitPrep } from "./inferImplicitPrep.js"; +import { getImplicitPrepareModules } from "./getImplicitPrepareModules.js"; import { runPrepareModules } from "./runPrepareModules.js"; import type { PreparedState, PrepareResponse } from "./types/index.js"; -/** Stable scope_id for a Migration. Preserves the historical entitlement ID format. */ +/** Stable scopeId for a Migration. Preserves the historical entitlement ID format. */ const scopeIdFor = (migration: Migration): string => `mig_${migration.internal_id}`; /** * Migration-fed shim around `runPrepareModules`. Walks implicit prep * modules from `migration.operations`, runs the pure orchestrator, then - * persists the new `prepared_state` back to the migrations row (skipped + * persists the new `preparedState` back to the migrations row (skipped * on dry-run). */ -export const runPrepare = async ({ +export const prepare = async ({ ctx, migration, - dry_run, + dryRun, }: { ctx: AutumnContext; migration: Migration; - dry_run: boolean; -}): Promise<{ response: PrepareResponse; prepared_state: PreparedState }> => { - const modules = inferImplicitPrep(migration); - - const { results, prepared_state } = await runPrepareModules({ - ctx, - scope_id: scopeIdFor(migration), - modules, - dry_run, - prior_state: migration.prepared_state ?? {}, + dryRun: boolean; +}): Promise<{ response: PrepareResponse; preparedState: PreparedState }> => { + const modules = getImplicitPrepareModules({ + operations: migration.operations, }); - if (!dry_run) { + const { results, preparedState } = await runPrepareModules({ + ctx, + scopeId: scopeIdFor(migration), + modules, + dryRun, + }); + + if (!dryRun) { await migrationRepo.update({ ctx, id: migration.id, - updates: { prepared_state }, + updates: { prepared_state: preparedState }, }); } return { response: { migration_id: migration.id, - dry_run, + dry_run: dryRun, modules: results, warnings: [], }, - prepared_state, + preparedState, }; }; diff --git a/server/src/internal/migrations/v2/prepare/runPrepareModules.ts b/server/src/internal/migrations/v2/prepare/runPrepareModules.ts index c081bc20b..fe114987d 100644 --- a/server/src/internal/migrations/v2/prepare/runPrepareModules.ts +++ b/server/src/internal/migrations/v2/prepare/runPrepareModules.ts @@ -1,40 +1,38 @@ import type { AutumnContext } from "../../../../honoUtils/HonoEnv.js"; -import type { ImplicitPrepInstance } from "./inferImplicitPrep.js"; +import type { ImplicitPrepInstance } from "./getImplicitPrepareModules.js"; import type { PreparedState, PrepareModuleResult } from "./types/index.js"; /** * Pure orchestrator. Walks a list of prep module instances under a - * given `scope_id`, runs plan → apply per module (apply skipped on - * dry-run), threads `prepared_state` through. No DB reads/writes + * given `scopeId`, runs plan → apply per module (apply skipped on + * dry-run), threads `preparedState` through. No DB reads/writes * outside what the modules themselves do — script-callable. */ export const runPrepareModules = async ({ ctx, - scope_id, + scopeId, modules, - dry_run, - prior_state = {}, + dryRun, }: { ctx: AutumnContext; - scope_id: string; + scopeId: string; modules: ImplicitPrepInstance[]; - dry_run: boolean; - prior_state?: PreparedState; + dryRun: boolean; }): Promise<{ results: PrepareModuleResult[]; - prepared_state: PreparedState; + preparedState: PreparedState; }> => { const results: PrepareModuleResult[] = []; - const next_state: PreparedState = { ...prior_state }; + const nextState: PreparedState = {}; for (const { key, module, input } of modules) { - const planned = await module.plan({ ctx, scope_id, input }); - const result = dry_run + const planned = await module.plan({ ctx, scopeId, input }); + const result = dryRun ? planned - : await module.apply({ ctx, scope_id, input, planned }); - if (!dry_run) next_state[key] = result; + : await module.apply({ ctx, scopeId, input, planned }); + nextState[key] = result; results.push({ key, kind: module.kind, result }); } - return { results, prepared_state: next_state }; + return { results, preparedState: nextState }; }; diff --git a/server/src/internal/migrations/v2/prepare/types/prepareModule.ts b/server/src/internal/migrations/v2/prepare/types/prepareModule.ts index c88cc80b9..1a676c622 100644 --- a/server/src/internal/migrations/v2/prepare/types/prepareModule.ts +++ b/server/src/internal/migrations/v2/prepare/types/prepareModule.ts @@ -5,7 +5,7 @@ import type { AutumnContext } from "../../../../../honoUtils/HonoEnv"; * are module-specific. The orchestrator wraps the returned `Result` in * the loose `{ key, kind, result }` envelope. * - * `scope_id` is the namespace under which deterministic catalog rows + * `scopeId` is the namespace under which deterministic catalog rows * are created — `mig_` for migrations, or any other prefix * for ad-hoc script invocations. */ @@ -15,14 +15,14 @@ export type PrepareModule = { /** Pure planning. No writes. */ plan: (args: { ctx: AutumnContext; - scope_id: string; + scopeId: string; input: Input; }) => Promise; /** Persist the desired set. Idempotent (deterministic IDs). */ apply: (args: { ctx: AutumnContext; - scope_id: string; + scopeId: string; input: Input; planned: Result; }) => Promise; diff --git a/server/src/internal/migrations/v2/prepare/types/preparedState.ts b/server/src/internal/migrations/v2/prepare/types/preparedState.ts index dc614d2a7..04fe26aaf 100644 --- a/server/src/internal/migrations/v2/prepare/types/preparedState.ts +++ b/server/src/internal/migrations/v2/prepare/types/preparedState.ts @@ -3,7 +3,7 @@ import { z } from "zod/v4"; /** * Server-side mirror of the `migrations.prepared_state` JSONB column. * Keyed by per-module deterministic keys (e.g. - * `ensure_prices_and_entitlements::`). + * `ensure_prices_and_entitlements:update_plan`). * * Per-module output schemas live alongside each module * (`modules//types.ts`). At the orchestrator layer we keep the diff --git a/server/src/internal/migrations/v2/prepare/utils/buildPrepareModuleKey.ts b/server/src/internal/migrations/v2/prepare/utils/buildPrepareModuleKey.ts new file mode 100644 index 000000000..181582af8 --- /dev/null +++ b/server/src/internal/migrations/v2/prepare/utils/buildPrepareModuleKey.ts @@ -0,0 +1,7 @@ +export const buildPrepareModuleKey = ({ + kind, + parts, +}: { + kind: string; + parts: string[]; +}) => [kind, ...parts].join(":"); diff --git a/server/src/internal/migrations/v2/prepare/utils/index.ts b/server/src/internal/migrations/v2/prepare/utils/index.ts new file mode 100644 index 000000000..f528da839 --- /dev/null +++ b/server/src/internal/migrations/v2/prepare/utils/index.ts @@ -0,0 +1 @@ +export * from "./buildPrepareModuleKey.js"; diff --git a/server/src/internal/migrations/v2/run/index.ts b/server/src/internal/migrations/v2/run/index.ts deleted file mode 100644 index 3dc0be07f..000000000 --- a/server/src/internal/migrations/v2/run/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -export * from "./migrateCustomer/index.js"; -export * from "./orchestrators/index.js"; -export * from "./runMigration.js"; -export * from "./types/index.js"; diff --git a/server/src/internal/migrations/v2/run/migrateCustomer/processOperations.ts b/server/src/internal/migrations/v2/run/migrateCustomer/processOperations.ts index 7752a8635..6e1fa2b82 100644 --- a/server/src/internal/migrations/v2/run/migrateCustomer/processOperations.ts +++ b/server/src/internal/migrations/v2/run/migrateCustomer/processOperations.ts @@ -30,11 +30,14 @@ export const processOperations = async ({ billingContexts: [], }; - for (const op of context.migration.operations?.customer ?? []) { + for (const [opIndex, op] of ( + context.migration.operations?.customer ?? [] + ).entries()) { const result = await processUpdatePlan({ ctx, context, op, + opIndex, plan: state.plan, projectedFullCustomer: state.projectedFullCustomer, }); diff --git a/server/src/internal/migrations/v2/run/orchestrators/index.ts b/server/src/internal/migrations/v2/run/orchestrators/index.ts deleted file mode 100644 index 475f311b5..000000000 --- a/server/src/internal/migrations/v2/run/orchestrators/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export * from "./iterateScope.js"; -export * from "./runPreparation.js"; -export * from "./runScopeIteration.js"; diff --git a/server/src/internal/migrations/v2/run/orchestrators/runPreparation.ts b/server/src/internal/migrations/v2/run/orchestrators/runPreparation.ts deleted file mode 100644 index c3380e453..000000000 --- a/server/src/internal/migrations/v2/run/orchestrators/runPreparation.ts +++ /dev/null @@ -1,27 +0,0 @@ -import type { Migration } from "@autumn/shared"; -import type { AutumnContext } from "../../../../../honoUtils/HonoEnv.js"; -import { runPrepare } from "../../prepare/runPrepare.js"; -import type { - PreparedState, - PrepareResponse, -} from "../../prepare/types/index.js"; - -/** - * Run-phase wrapper around the prepare orchestrator. Returns the - * freshly written `prepared_state` so per-item handlers can read it - * without re-fetching the migration row. - * - * On `dry_run: true` this still computes and returns the planned - * `prepared_state` shape (so dry-run end-to-end can show what each - * customer would see) — but the migrations row is not updated. - */ -export const runPreparation = async ({ - ctx, - migration, - dry_run, -}: { - ctx: AutumnContext; - migration: Migration; - dry_run: boolean; -}): Promise<{ response: PrepareResponse; prepared_state: PreparedState }> => - runPrepare({ ctx, migration, dry_run }); diff --git a/server/src/internal/migrations/v2/run/runMigration.ts b/server/src/internal/migrations/v2/run/runMigration.ts index 694061716..e9037f4c4 100644 --- a/server/src/internal/migrations/v2/run/runMigration.ts +++ b/server/src/internal/migrations/v2/run/runMigration.ts @@ -5,8 +5,9 @@ import { recordMigrationFailedEvent, recordMigrationTerminalEvent, } from "./events/index.js"; -import { runPreparation, runScopeIteration } from "./orchestrators/index.js"; -import { getRunScopes } from "./types/index.js"; +import { prepare } from "../prepare/index.js"; +import { runScopeIteration } from "./orchestrators/runScopeIteration.js"; +import { getRunScopes } from "./types/getRunScopes.js"; import type { RunMigrationResponse, RunMigrationScopeResult, @@ -16,13 +17,13 @@ import type { export const runMigration = async ({ ctx, migration, - dry_run, migrationRunId, + dry_run, }: { ctx: AutumnContext; migration: Migration; - dry_run: boolean; migrationRunId: string; + dry_run: boolean; }): Promise => { const scopeResults: RunMigrationScopeResult[] = []; @@ -30,8 +31,8 @@ export const runMigration = async ({ return await executeMigrationRun({ ctx, migration, - dry_run, migrationRunId, + dry_run, scopeResults, }); } catch (error) { @@ -50,14 +51,14 @@ export const runMigration = async ({ const executeMigrationRun = async ({ ctx, migration, - dry_run, migrationRunId, + dry_run, scopeResults, }: { ctx: AutumnContext; migration: Migration; - dry_run: boolean; migrationRunId: string; + dry_run: boolean; scopeResults: RunMigrationScopeResult[]; }): Promise => { await recordMigrationCustomerEvent({ @@ -71,12 +72,12 @@ const executeMigrationRun = async ({ }, }); - const { response: prepareResponse, prepared_state } = await runPreparation({ + const { response: prepareResponse, preparedState } = await prepare({ ctx, migration, - dry_run, + dryRun: dry_run, }); - const preparedMigration = { ...migration, prepared_state }; + const preparedMigration = { ...migration, prepared_state: preparedState }; for (const kind of getRunScopes({ migration: preparedMigration })) { const scopeResult = await runScopeIteration({ diff --git a/server/src/internal/migrations/v2/run/types/index.ts b/server/src/internal/migrations/v2/run/types/index.ts deleted file mode 100644 index 7da0dda88..000000000 --- a/server/src/internal/migrations/v2/run/types/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export * from "./getRunScopes.js"; -export * from "./runMigrationResponse.js"; -export * from "./runScope.js"; diff --git a/server/src/internal/rewards/rewardUtils.ts b/server/src/internal/rewards/rewardUtils.ts index f5f03fbe8..7f4218f99 100644 --- a/server/src/internal/rewards/rewardUtils.ts +++ b/server/src/internal/rewards/rewardUtils.ts @@ -3,6 +3,7 @@ import { DiscountConfigSchema, ErrCode, isFixedPrice, + notNullish, type Price, type Product, type Reward, @@ -148,7 +149,7 @@ export const initRewardStripePrices = async ({ } const internalProductIds = getUnique( - prices.map((p: Price) => p.internal_product_id), + prices.map((p: Price) => p.internal_product_id).filter(notNullish), ); const products = await ProductService.listByInternalIds({ db: ctx.db, diff --git a/server/src/trigger/utils/index.ts b/server/src/trigger/utils/index.ts deleted file mode 100644 index 181a14ef9..000000000 --- a/server/src/trigger/utils/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./createTriggerContext.js"; diff --git a/server/src/utils/hash/hashJson.ts b/server/src/utils/hash/hashJson.ts index 810277519..91743ff3f 100644 --- a/server/src/utils/hash/hashJson.ts +++ b/server/src/utils/hash/hashJson.ts @@ -1,3 +1,5 @@ +import { createHash } from "node:crypto"; + /** * Recursively sort object keys, strip undefined values, and produce a stable string. * Array element order is preserved; object key order is not. @@ -23,7 +25,7 @@ const deterministicStringify = (value: unknown): string => { /** Produce a SHA-256 hex digest from any JSON-serialisable value, key-order independent. */ export const hashJson = ({ value }: { value: unknown }): string => { - const hasher = new Bun.CryptoHasher("sha256"); - hasher.update(deterministicStringify(value)); - return hasher.digest("hex"); + return createHash("sha256") + .update(deterministicStringify(value)) + .digest("hex"); }; diff --git a/server/tests/integration/billing/migrations-v2/prepare/ensure-prices-and-ents/ensure-prices-and-ents-runtime.test.ts b/server/tests/integration/billing/migrations-v2/prepare/ensure-prices-and-ents/ensure-prices-and-ents-runtime.test.ts new file mode 100644 index 000000000..3a22fb804 --- /dev/null +++ b/server/tests/integration/billing/migrations-v2/prepare/ensure-prices-and-ents/ensure-prices-and-ents-runtime.test.ts @@ -0,0 +1,151 @@ +/** + * Runtime coverage for ensure_prices_and_entitlements preparation. + * + * These tests verify that prepared productless catalog rows can feed the + * customer migration execution path and are reused by customer_prices and + * customer_entitlements. + */ + +import { test } from "bun:test"; +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 { + buildUpdatePlanOperations, + createMigration, + updateMigrationOperations, +} from "../../utils/migrationTestUtils.js"; +import { + expectPreparedArtifact, + expectPreparedArtifactFieldsChanged, + expectPreparedArtifactRowIds, + expectPreparedCatalogContainsRows, + prepareMigration, +} from "./utils/ensurePrepareTestUtils.js"; +import { + expectPreparedRowsProductless, + waitForPreparedRowsReusedByCustomers, +} from "./utils/expectPreparedCustomerRows.js"; +import { getPreparedCustomerRows } from "./utils/getPreparedCustomerRows.js"; + +test.concurrent(`${chalk.yellowBright("migrations prepare runtime: prepared productless catalog rows are reused across customers")}`, async () => { + const customerId = "prep-ensure-reuse-primary"; + const otherCustomerId = "prep-ensure-reuse-secondary"; + const free = products.base({ + id: "free", + items: [], + }); + + const { autumnV2_2, ctx } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.otherCustomers([{ id: otherCustomerId, paymentMethod: "success" }]), + s.products({ list: [free] }), + ], + actions: [ + s.billing.attach({ productId: free.id }), + s.billing.attach({ + productId: free.id, + customerId: otherCustomerId, + }), + ], + }); + + const firstOperations = buildUpdatePlanOperations({ + planId: free.id, + customize: { + add_items: [itemsV2.dashboard(), itemsV2.prepaidWords({ amount: 3 })], + }, + }); + const migration = await createMigration({ + migrationClient: autumnV2_2, + id: `${customerId}-mig`, + filter: { customer: { plan: { plan_id: free.id } } }, + operations: firstOperations, + }); + const firstPrepared = await prepareMigration({ + ctx, + migration, + dryRun: true, + }); + expectPreparedArtifact({ + result: firstPrepared, + opIndex: 0, + kind: "add_item", + itemIndex: 1, + }); + + const operations = buildUpdatePlanOperations({ + planId: free.id, + customize: { + add_items: [itemsV2.dashboard(), itemsV2.prepaidWords({ amount: 4 })], + }, + }); + const updatedMigration = await updateMigrationOperations({ + migrationClient: autumnV2_2, + id: `${customerId}-mig`, + operations, + }); + const updatedPrepared = await prepareMigration({ + ctx, + migration: updatedMigration, + dryRun: true, + }); + + expectPreparedArtifactFieldsChanged({ + before: firstPrepared, + after: updatedPrepared, + artifact: { opIndex: 0, kind: "add_item", itemIndex: 1 }, + fields: ["price_id", "entitlement_id"], + }); + + const dashboard = expectPreparedArtifact({ + result: updatedPrepared, + opIndex: 0, + kind: "add_item", + itemIndex: 0, + }); + const priced = expectPreparedArtifact({ + result: updatedPrepared, + opIndex: 0, + kind: "add_item", + itemIndex: 1, + }); + const dashboardEntitlementId = dashboard.entitlement_id; + const pricedRows = expectPreparedArtifactRowIds({ artifact: priced }); + if (!dashboardEntitlementId) { + throw new Error("Expected prepared artifacts to include reusable row IDs"); + } + expectPreparedCatalogContainsRows({ + result: updatedPrepared, + priceIds: [pricedRows.priceId], + entitlementIds: [pricedRows.entitlementId], + }); + + await autumnV2_2.migrationsV2.run({ + id: `${customerId}-mig`, + dry_run: false, + }); + + const loadRows = () => + getPreparedCustomerRows({ + ctx, + customerIds: [customerId, otherCustomerId], + productId: free.id, + }); + + const rows = await waitForPreparedRowsReusedByCustomers({ + loadRows, + customerIds: [customerId, otherCustomerId], + priceId: pricedRows.priceId, + entitlementIds: [dashboardEntitlementId, pricedRows.entitlementId], + }); + + expectPreparedRowsProductless({ + rows, + priceIds: [pricedRows.priceId], + entitlementIds: [dashboardEntitlementId, pricedRows.entitlementId], + }); +}); diff --git a/server/tests/integration/billing/migrations-v2/prepare/ensure-prices-and-ents/ensure-prices-and-ents.test.ts b/server/tests/integration/billing/migrations-v2/prepare/ensure-prices-and-ents/ensure-prices-and-ents.test.ts new file mode 100644 index 000000000..29e596554 --- /dev/null +++ b/server/tests/integration/billing/migrations-v2/prepare/ensure-prices-and-ents/ensure-prices-and-ents.test.ts @@ -0,0 +1,250 @@ +/** + * TDD coverage for ensure_prices_and_entitlements preparation. + * + * Contract under test: + * - prepared catalog rows are content-addressed by the exact update_plan input + * and operation position. + * - unchanged add_items keep their prepared row IDs across migration edits. + * - changed add_items/base prices receive new prepared row IDs. + */ + +import { expect, test } from "bun:test"; +import type { Price } from "@autumn/shared"; +import { prepare } from "@/internal/migrations/v2/prepare/prepare.js"; +import { itemsV2 } from "@tests/utils/fixtures/itemsV2"; +import { initScenario } from "@tests/utils/testInitUtils/initScenario"; +import chalk from "chalk"; +import { + buildUpdatePlanOperations, + createMigration, + updateMigrationOperations, +} from "../../utils/migrationTestUtils.js"; +import { + prepaidWordsWithMaxPurchase, + rolloverCredits, +} from "./utils/ensurePrepareItems.js"; +import { + expectPreparedArtifact, + expectPreparedArtifactFieldsChanged, + expectPreparedArtifactFieldsStable, + expectPreparedArtifactRowIds, + expectPreparedCatalogContainsRows, + prepareMigration, +} from "./utils/ensurePrepareTestUtils.js"; + +test.concurrent(`${chalk.yellowBright("migrations prepare: unchanged add_items keep row IDs while edited add_items change")}`, async () => { + const id = "prep-ensure-stable-add-items"; + const { autumnV2_2, ctx } = await initScenario({ + setup: [], + actions: [], + }); + + const firstOps = buildUpdatePlanOperations({ + customize: { + add_items: [itemsV2.dashboard(), itemsV2.prepaidWords({ amount: 2 })], + }, + }); + const migration = await createMigration({ + migrationClient: autumnV2_2, + id, + operations: firstOps, + }); + const first = await prepareMigration({ ctx, migration }); + + const secondOps = buildUpdatePlanOperations({ + customize: { + add_items: [itemsV2.dashboard(), itemsV2.prepaidWords({ amount: 3 })], + }, + }); + const updatedMigration = await updateMigrationOperations({ + migrationClient: autumnV2_2, + id, + operations: secondOps, + }); + expect(updatedMigration.prepared_state).toBeNull(); + const second = await prepareMigration({ ctx, migration: updatedMigration }); + + expectPreparedArtifactFieldsStable({ + before: first, + after: second, + artifact: { opIndex: 0, kind: "add_item", itemIndex: 0 }, + fields: ["entitlement_id"], + }); + + expectPreparedArtifactFieldsChanged({ + before: first, + after: second, + artifact: { opIndex: 0, kind: "add_item", itemIndex: 1 }, + fields: ["hash", "price_id", "entitlement_id"], + }); +}); + +test.concurrent(`${chalk.yellowBright("migrations prepare: base price create update and remove rewrite prepared_state")}`, async () => { + const id = "prep-ensure-base-price"; + const { autumnV2_2, ctx } = await initScenario({ + setup: [], + actions: [], + }); + + const createOps = buildUpdatePlanOperations({ + customize: { price: itemsV2.monthlyPrice({ amount: 20 }) }, + }); + const migration = await createMigration({ + migrationClient: autumnV2_2, + id, + operations: createOps, + }); + const created = await prepareMigration({ ctx, migration }); + const createdBasePrice = expectPreparedArtifact({ + result: created, + opIndex: 0, + kind: "base_price", + }); + expect(created.result.prices).toHaveLength(1); + expect((created.result.prices[0] as Price).internal_product_id).toBeNull(); + + const updateOps = buildUpdatePlanOperations({ + customize: { price: itemsV2.monthlyPrice({ amount: 25 }) }, + }); + const updatedMigration = await updateMigrationOperations({ + migrationClient: autumnV2_2, + id, + operations: updateOps, + }); + const updated = await prepareMigration({ ctx, migration: updatedMigration }); + expectPreparedArtifactFieldsChanged({ + before: created, + after: updated, + artifact: { opIndex: 0, kind: "base_price" }, + fields: ["hash", "price_id"], + }); + expect(createdBasePrice.price_id).toBeDefined(); + + const removeOps = buildUpdatePlanOperations({ customize: { price: null } }); + const removedMigration = await updateMigrationOperations({ + migrationClient: autumnV2_2, + id, + operations: removeOps, + }); + const { preparedState: removedState } = await prepare({ + ctx, + migration: removedMigration, + dryRun: false, + }); + expect(removedState).toEqual({}); +}); + +test.concurrent(`${chalk.yellowBright("migrations prepare: nested item field changes produce new artifacts")}`, async () => { + const id = "prep-ensure-nested-item-hash"; + const { autumnV2_2, ctx } = await initScenario({ + setup: [], + actions: [], + }); + + const migration = await createMigration({ + migrationClient: autumnV2_2, + id, + operations: buildUpdatePlanOperations({ + customize: { + add_items: [prepaidWordsWithMaxPurchase({ maxPurchase: 100 })], + }, + }), + }); + const first = await prepareMigration({ ctx, migration, dryRun: true }); + + const maxPurchaseMigration = await updateMigrationOperations({ + migrationClient: autumnV2_2, + id, + operations: buildUpdatePlanOperations({ + customize: { + add_items: [prepaidWordsWithMaxPurchase({ maxPurchase: 101 })], + }, + }), + }); + const maxPurchase = await prepareMigration({ + ctx, + migration: maxPurchaseMigration, + dryRun: true, + }); + expectPreparedArtifactFieldsChanged({ + before: first, + after: maxPurchase, + artifact: { opIndex: 0, kind: "add_item", itemIndex: 0 }, + fields: ["hash", "price_id", "entitlement_id"], + }); + + const rolloverMigration = await updateMigrationOperations({ + migrationClient: autumnV2_2, + id, + operations: buildUpdatePlanOperations({ + customize: { add_items: [rolloverCredits({ max: 250 })] }, + }), + }); + const rollover = await prepareMigration({ + ctx, + migration: rolloverMigration, + dryRun: true, + }); + + const rolloverChangedMigration = await updateMigrationOperations({ + migrationClient: autumnV2_2, + id, + operations: buildUpdatePlanOperations({ + customize: { add_items: [rolloverCredits({ max: 251 })] }, + }), + }); + const rolloverChanged = await prepareMigration({ + ctx, + migration: rolloverChangedMigration, + dryRun: true, + }); + expectPreparedArtifactFieldsChanged({ + before: rollover, + after: rolloverChanged, + artifact: { opIndex: 0, kind: "add_item", itemIndex: 0 }, + fields: ["hash", "entitlement_id"], + }); +}); + +test.concurrent(`${chalk.yellowBright("migrations prepare: same item on different op indexes gets distinct rows")}`, async () => { + const id = "prep-ensure-op-index-isolation"; + const { autumnV2_2, ctx } = await initScenario({ + setup: [], + actions: [], + }); + + const migration = await createMigration({ + migrationClient: autumnV2_2, + id, + operations: buildUpdatePlanOperations({ + customize: { add_items: [itemsV2.prepaidWords({ amount: 6 })] }, + secondCustomize: { + add_items: [itemsV2.prepaidWords({ amount: 6 })], + }, + }), + }); + const prepared = await prepareMigration({ ctx, migration }); + + const first = expectPreparedArtifact({ + result: prepared, + opIndex: 0, + kind: "add_item", + itemIndex: 0, + }); + const second = expectPreparedArtifact({ + result: prepared, + opIndex: 1, + kind: "add_item", + itemIndex: 0, + }); + expect(second.hash).toBe(first.hash); + expect(second.price_id).not.toBe(first.price_id); + expect(second.entitlement_id).not.toBe(first.entitlement_id); + const firstRows = expectPreparedArtifactRowIds({ artifact: first }); + const secondRows = expectPreparedArtifactRowIds({ artifact: second }); + expectPreparedCatalogContainsRows({ + result: prepared, + priceIds: [firstRows.priceId, secondRows.priceId], + entitlementIds: [firstRows.entitlementId, secondRows.entitlementId], + }); +}); diff --git a/server/tests/integration/billing/migrations-v2/prepare/ensure-prices-and-ents/utils/ensurePrepareItems.ts b/server/tests/integration/billing/migrations-v2/prepare/ensure-prices-and-ents/utils/ensurePrepareItems.ts new file mode 100644 index 000000000..882c2821a --- /dev/null +++ b/server/tests/integration/billing/migrations-v2/prepare/ensure-prices-and-ents/utils/ensurePrepareItems.ts @@ -0,0 +1,29 @@ +import { ResetInterval, RolloverExpiryDurationType } from "@autumn/shared"; +import { TestFeature } from "@tests/setup/v2Features"; +import { itemsV2 } from "@tests/utils/fixtures/itemsV2"; + +export const prepaidWordsWithMaxPurchase = ({ + maxPurchase, +}: { + maxPurchase: number; +}) => { + const item = itemsV2.prepaidWords(); + return { + ...item, + price: { + ...item.price, + max_purchase: maxPurchase, + }, + }; +}; + +export const rolloverCredits = ({ max = 100 }: { max?: number } = {}) => ({ + feature_id: TestFeature.Credits, + included: 50, + reset: { interval: ResetInterval.Month }, + rollover: { + max, + expiry_duration_type: RolloverExpiryDurationType.Month, + expiry_duration_length: 2, + }, +}); diff --git a/server/tests/integration/billing/migrations-v2/prepare/ensure-prices-and-ents/utils/ensurePrepareTestUtils.ts b/server/tests/integration/billing/migrations-v2/prepare/ensure-prices-and-ents/utils/ensurePrepareTestUtils.ts new file mode 100644 index 000000000..639bfb355 --- /dev/null +++ b/server/tests/integration/billing/migrations-v2/prepare/ensure-prices-and-ents/utils/ensurePrepareTestUtils.ts @@ -0,0 +1,163 @@ +import { expect } from "bun:test"; +import type { Migration } from "@autumn/shared"; +import { + EnsurePricesAndEntitlementsResultSchema, + type PreparedArtifactRef, + type EnsurePricesAndEntitlementsResult, +} from "@/internal/migrations/v2/prepare/modules/ensurePricesAndEntitlements/index.js"; +import { prepare } from "@/internal/migrations/v2/prepare/prepare.js"; +import type { PreparedState } from "@/internal/migrations/v2/prepare/types/index.js"; +import type { AutumnContext } from "@/honoUtils/HonoEnv.js"; + +const prepareKey = "ensure_prices_and_entitlements:update_plan"; + +export type PreparedMigrationResult = { + preparedState: PreparedState; + result: EnsurePricesAndEntitlementsResult; +}; + +type PreparedResultLike = + | EnsurePricesAndEntitlementsResult + | PreparedMigrationResult; + +type PreparedArtifactSelector = { + opIndex: number; + kind: "base_price" | "add_item"; + itemIndex?: number; +}; + +type PreparedArtifactField = "hash" | "price_id" | "entitlement_id"; + +const toResult = ({ + prepared, +}: { + prepared: PreparedResultLike; +}): EnsurePricesAndEntitlementsResult => + "artifacts" in prepared ? prepared : prepared.result; + +export const extractEnsureResult = ({ + preparedState, +}: { + preparedState: unknown; +}): EnsurePricesAndEntitlementsResult => { + const state = preparedState as Record; + return EnsurePricesAndEntitlementsResultSchema.parse(state[prepareKey]); +}; + +export const expectPreparedArtifact = ({ + result, + opIndex, + kind, + itemIndex, +}: { + result: PreparedResultLike; +} & PreparedArtifactSelector): PreparedArtifactRef => { + const preparedResult = toResult({ prepared: result }); + const artifact = preparedResult.artifacts.find( + (candidate) => + candidate.op_index === opIndex && + candidate.kind === kind && + candidate.item_index === itemIndex, + ); + expect(artifact).toBeDefined(); + return artifact!; +}; + +export const expectPreparedArtifactFieldsStable = ({ + before, + after, + artifact, + fields, +}: { + before: PreparedResultLike; + after: PreparedResultLike; + artifact: PreparedArtifactSelector; + fields: PreparedArtifactField[]; +}) => { + const beforeArtifact = expectPreparedArtifact({ + result: before, + ...artifact, + }); + const afterArtifact = expectPreparedArtifact({ result: after, ...artifact }); + + for (const field of fields) { + expect(afterArtifact[field]).toBe(beforeArtifact[field]); + } +}; + +export const expectPreparedArtifactFieldsChanged = ({ + before, + after, + artifact, + fields, +}: { + before: PreparedResultLike; + after: PreparedResultLike; + artifact: PreparedArtifactSelector; + fields: PreparedArtifactField[]; +}) => { + const beforeArtifact = expectPreparedArtifact({ + result: before, + ...artifact, + }); + const afterArtifact = expectPreparedArtifact({ result: after, ...artifact }); + + for (const field of fields) { + expect(afterArtifact[field]).not.toBe(beforeArtifact[field]); + } +}; + +export const expectPreparedArtifactRowIds = ({ + artifact, +}: { + artifact: PreparedArtifactRef; +}) => { + const priceId = artifact.price_id; + const entitlementId = artifact.entitlement_id; + if (!priceId || !entitlementId) { + throw new Error( + "Expected prepared artifact to include price and entitlement IDs", + ); + } + + return { priceId, entitlementId }; +}; + +export const expectPreparedCatalogContainsRows = ({ + result, + priceIds = [], + entitlementIds = [], +}: { + result: PreparedResultLike; + priceIds?: string[]; + entitlementIds?: string[]; +}) => { + const preparedResult = toResult({ prepared: result }); + const preparedPriceIds = preparedResult.prices.map((price) => price.id); + const preparedEntitlementIds = preparedResult.entitlements.map( + (entitlement) => entitlement.id, + ); + + for (const priceId of priceIds) { + expect(preparedPriceIds).toContain(priceId); + } + for (const entitlementId of entitlementIds) { + expect(preparedEntitlementIds).toContain(entitlementId); + } +}; + +export const prepareMigration = async ({ + ctx, + migration, + dryRun = false, +}: { + ctx: AutumnContext; + migration: Migration; + dryRun?: boolean; +}): Promise => { + const { preparedState } = await prepare({ ctx, migration, dryRun }); + return { + preparedState, + result: extractEnsureResult({ preparedState }), + }; +}; diff --git a/server/tests/integration/billing/migrations-v2/prepare/ensure-prices-and-ents/utils/expectPreparedCustomerRows.ts b/server/tests/integration/billing/migrations-v2/prepare/ensure-prices-and-ents/utils/expectPreparedCustomerRows.ts new file mode 100644 index 000000000..f3e48af11 --- /dev/null +++ b/server/tests/integration/billing/migrations-v2/prepare/ensure-prices-and-ents/utils/expectPreparedCustomerRows.ts @@ -0,0 +1,98 @@ +import { expect } from "bun:test"; +import { waitForMigrationResult } from "../../../utils/runUpdatePlanMigration.js"; +import type { getPreparedCustomerRows } from "./getPreparedCustomerRows.js"; + +type PreparedCustomerRows = Awaited>; + +export const expectPreparedRowsReusedByCustomers = ({ + rows, + customerIds, + priceId, + entitlementIds, +}: { + rows: PreparedCustomerRows; + customerIds: string[]; + priceId: string; + entitlementIds: string[]; +}) => { + const migratedCustomerIds = new Set(rows.map((row) => row.customerId)); + expect(migratedCustomerIds).toEqual(new Set(customerIds)); + + for (const customerId of customerIds) { + const customerRows = rows.filter((row) => row.customerId === customerId); + + expect( + customerRows.some((row) => row.priceId === priceId), + `expected customer ${customerId} to reuse prepared price ${priceId}`, + ).toBe(true); + + for (const entitlementId of entitlementIds) { + expect( + customerRows.some((row) => row.entitlementId === entitlementId), + `expected customer ${customerId} to reuse prepared entitlement ${entitlementId}`, + ).toBe(true); + } + } +}; + +export const waitForPreparedRowsReusedByCustomers = async ({ + loadRows, + customerIds, + priceId, + entitlementIds, + timeoutMs = 60_000, + pollIntervalMs = 1_000, +}: { + loadRows: () => Promise; + customerIds: string[]; + priceId: string; + entitlementIds: string[]; + timeoutMs?: number; + pollIntervalMs?: number; +}) => { + await waitForMigrationResult({ + timeoutMs, + pollIntervalMs, + waitFor: async () => { + const rows = await loadRows(); + expectPreparedRowsReusedByCustomers({ + rows, + customerIds, + priceId, + entitlementIds, + }); + }, + }); + + return loadRows(); +}; + +export const expectPreparedRowsProductless = ({ + rows, + priceIds, + entitlementIds, +}: { + rows: PreparedCustomerRows; + priceIds: string[]; + entitlementIds: string[]; +}) => { + for (const priceId of priceIds) { + const preparedPriceRows = rows.filter((row) => row.priceId === priceId); + expect(preparedPriceRows.length).toBeGreaterThan(0); + expect( + preparedPriceRows.every((row) => row.priceInternalProductId === null), + ).toBe(true); + } + + for (const entitlementId of entitlementIds) { + const preparedEntitlementRows = rows.filter( + (row) => row.entitlementId === entitlementId, + ); + expect(preparedEntitlementRows.length).toBeGreaterThan(0); + expect( + preparedEntitlementRows.every( + (row) => row.entitlementInternalProductId === null, + ), + ).toBe(true); + } +}; diff --git a/server/tests/integration/billing/migrations-v2/prepare/ensure-prices-and-ents/utils/getPreparedCustomerRows.ts b/server/tests/integration/billing/migrations-v2/prepare/ensure-prices-and-ents/utils/getPreparedCustomerRows.ts new file mode 100644 index 000000000..fa2f5cbee --- /dev/null +++ b/server/tests/integration/billing/migrations-v2/prepare/ensure-prices-and-ents/utils/getPreparedCustomerRows.ts @@ -0,0 +1,55 @@ +import { + customerEntitlements, + customerPrices, + customerProducts, + customers, + entitlements, + prices, +} from "@autumn/shared"; +import type { AutumnContext } from "@/honoUtils/HonoEnv.js"; +import { and, eq, inArray } from "drizzle-orm"; + +export const getPreparedCustomerRows = async ({ + ctx, + customerIds, + productId, +}: { + ctx: AutumnContext; + customerIds: string[]; + productId: string; +}) => + ctx.db + .select({ + customerId: customers.id, + customerProductId: customerProducts.id, + priceId: customerPrices.price_id, + priceInternalProductId: prices.internal_product_id, + entitlementId: customerEntitlements.entitlement_id, + entitlementInternalProductId: entitlements.internal_product_id, + }) + .from(customers) + .innerJoin( + customerProducts, + eq(customerProducts.internal_customer_id, customers.internal_id), + ) + .leftJoin( + customerPrices, + eq(customerPrices.customer_product_id, customerProducts.id), + ) + .leftJoin(prices, eq(customerPrices.price_id, prices.id)) + .leftJoin( + customerEntitlements, + eq(customerEntitlements.customer_product_id, customerProducts.id), + ) + .leftJoin( + entitlements, + eq(customerEntitlements.entitlement_id, entitlements.id), + ) + .where( + and( + eq(customers.org_id, ctx.org.id), + eq(customers.env, ctx.env), + inArray(customers.id, customerIds), + eq(customerProducts.product_id, productId), + ), + ); diff --git a/server/tests/integration/billing/migrations-v2/update-plan-operation/update-plan-op-items.test.ts b/server/tests/integration/billing/migrations-v2/update-plan-operation/update-plan-op-items.test.ts index 987e2b9f5..1295f40aa 100644 --- a/server/tests/integration/billing/migrations-v2/update-plan-operation/update-plan-op-items.test.ts +++ b/server/tests/integration/billing/migrations-v2/update-plan-operation/update-plan-op-items.test.ts @@ -75,7 +75,9 @@ test.concurrent(`${chalk.yellowBright("migrations update_plan: add boolean and m ], }, runOnServer: true, - waitFor: expectMigrationApplied, + waitFor: async () => { + await expectMigrationApplied(); + }, timeoutMs: 60_000, }); diff --git a/server/tests/integration/billing/migrations-v2/update-plan-operation/update-plan-op-multi-targets.test.ts b/server/tests/integration/billing/migrations-v2/update-plan-operation/update-plan-op-multi-targets.test.ts index 1b5dd008c..4bf8c4d71 100644 --- a/server/tests/integration/billing/migrations-v2/update-plan-operation/update-plan-op-multi-targets.test.ts +++ b/server/tests/integration/billing/migrations-v2/update-plan-operation/update-plan-op-multi-targets.test.ts @@ -6,9 +6,15 @@ * - multiple update_plan operations run in order on the same customer. */ -import { test } from "bun:test"; +import { expect, test } from "bun:test"; import type { ApiCustomerV3, ApiEntityV2 } from "@autumn/shared"; -import { ResetInterval } from "@autumn/shared"; +import { + customerPrices, + customerProducts, + customers, + prices, + ResetInterval, +} from "@autumn/shared"; import { expectCustomerInvoiceCorrect } from "@tests/integration/billing/utils/expectCustomerInvoiceCorrect"; import { expectNoExpiredCustomerProducts } from "@tests/integration/billing/utils/expectNoExpiredCustomerProducts"; import { expectStripeSubscriptionCorrect } from "@tests/integration/billing/utils/expectStripeSubCorrect"; @@ -20,8 +26,49 @@ 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"; import { runUpdatePlanMigration } from "../utils/runUpdatePlanMigration"; +const getCustomerProductPriceAmounts = async ({ + ctx, + customerId, + productId, + entityId, +}: { + ctx: Awaited>["ctx"]; + customerId: string; + productId: string; + entityId: string; +}) => + ( + await ctx.db + .select({ config: prices.config }) + .from(customerProducts) + .innerJoin( + customers, + eq(customerProducts.internal_customer_id, customers.internal_id), + ) + .innerJoin( + customerPrices, + eq(customerPrices.customer_product_id, customerProducts.id), + ) + .innerJoin(prices, eq(customerPrices.price_id, prices.id)) + .where( + and( + eq(customers.org_id, ctx.org.id), + eq(customers.env, ctx.env), + eq(customers.id, customerId), + eq(customerProducts.product_id, productId), + eq(customerProducts.entity_id, entityId), + ), + ) + ) + .map((row) => + row.config && "amount" in row.config ? row.config.amount : undefined, + ) + .filter((amount): amount is number => typeof amount === "number") + .sort((a, b) => a - b); + test.concurrent(`${chalk.yellowBright("migrations update_plan: plan filter patches multiple entity products")}`, async () => { const customerId = "migration-update-multi-entity"; const pro = products.pro({ items: [] }); @@ -198,3 +245,88 @@ test.concurrent(`${chalk.yellowBright("migrations update_plan: two operations ru }); await expectStripeSubscriptionCorrect({ ctx, customerId }); }); + +test.concurrent(`${chalk.yellowBright("migrations update_plan: multiple plan price updates run in one customer migration")}`, async () => { + const customerId = "migration-update-multi-price"; + const pro = products.pro({ items: [] }); + const premium = products.premium({ items: [] }); + + const { autumnV1, autumnV2_2, ctx, entities } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [pro, premium] }), + s.entities({ count: 2, featureId: TestFeature.Users }), + ], + actions: [ + s.billing.attach({ productId: pro.id, entityIndex: 0 }), + s.billing.attach({ productId: premium.id, entityIndex: 1 }), + ], + }); + + await runUpdatePlanMigration({ + ctx, + migrationClient: autumnV2_2, + migrationId: `${customerId}-mig`, + customerId, + runOnServer: false, + filter: { + customer: { + plan: { $or: [{ plan_id: pro.id }, { plan_id: premium.id }] }, + }, + }, + operations: { + customer: [ + { + type: "update_plan", + plan_filter: { plan_id: pro.id }, + customize: { + price: itemsV2.monthlyPrice({ amount: 50 }), + }, + }, + { + type: "update_plan", + plan_filter: { plan_id: premium.id }, + customize: { + price: itemsV2.monthlyPrice({ amount: 100 }), + }, + }, + ], + }, + }); + + expect( + await getCustomerProductPriceAmounts({ + ctx, + customerId, + productId: pro.id, + entityId: entities[0].id, + }), + ).toEqual([50]); + expect( + await getCustomerProductPriceAmounts({ + ctx, + customerId, + productId: premium.id, + entityId: entities[1].id, + }), + ).toEqual([100]); + + await expectCustomerInvoiceCorrect({ + customer: await autumnV1.customers.get(customerId), + count: 2, + }); + await expectNoExpiredCustomerProducts({ + ctx, + customerId, + productId: pro.id, + entityId: entities[0].id, + }); + await expectNoExpiredCustomerProducts({ + ctx, + customerId, + productId: premium.id, + entityId: entities[1].id, + }); + await expectStripeSubscriptionCorrect({ ctx, customerId }); +}); diff --git a/server/tests/integration/billing/migrations-v2/update-plan-operation/update-plan-op-states.test.ts b/server/tests/integration/billing/migrations-v2/update-plan-operation/update-plan-op-states.test.ts new file mode 100644 index 000000000..28a4cf79d --- /dev/null +++ b/server/tests/integration/billing/migrations-v2/update-plan-operation/update-plan-op-states.test.ts @@ -0,0 +1,476 @@ +/** + * TDD coverage for update_plan migrations preserving in-flight subscription + * states. + * + * Contract under test: + * - Updating the active plan's base price does not clear a scheduled downgrade. + * - Updating a canceling plan's base price does not clear end-of-cycle cancel. + * - Entity-scoped and multi-product states survive a customer migration. + */ + +import { expect, test } from "bun:test"; +import type { ApiCustomerV3, ApiEntityV0 } from "@autumn/shared"; +import { + CusProductStatus, + customerPrices, + customerProducts, + customers, + prices, +} from "@autumn/shared"; +import { + expectProductCanceling, + expectProductNotPresent, + expectProductScheduled, +} from "@tests/integration/billing/utils/expectCustomerProductCorrect"; +import { expectNoExpiredCustomerProducts } from "@tests/integration/billing/utils/expectNoExpiredCustomerProducts"; +import { expectStripeSubscriptionCorrect } from "@tests/integration/billing/utils/expectStripeSubCorrect"; +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 { and, eq, isNull } from "drizzle-orm"; +import { runUpdatePlanMigration } from "../utils/runUpdatePlanMigration"; + +const getScheduledIds = async ({ + ctx, + customerId, + productId, + entityId, +}: { + ctx: Awaited>["ctx"]; + customerId: string; + productId: string; + entityId?: string; +}) => + ( + await ctx.db + .select({ scheduledIds: customerProducts.scheduled_ids }) + .from(customerProducts) + .innerJoin( + customers, + eq(customerProducts.internal_customer_id, customers.internal_id), + ) + .where( + and( + eq(customers.org_id, ctx.org.id), + eq(customers.env, ctx.env), + eq(customers.id, customerId), + eq(customerProducts.product_id, productId), + eq(customerProducts.status, CusProductStatus.Scheduled), + entityId + ? eq(customerProducts.entity_id, entityId) + : isNull(customerProducts.entity_id), + ), + ) + ) + .map((row) => row.scheduledIds ?? []) + .flat() + .sort(); + +const getCustomerProductPriceAmounts = async ({ + ctx, + customerId, + productId, + entityId, +}: { + ctx: Awaited>["ctx"]; + customerId: string; + productId: string; + entityId?: string; +}) => + ( + await ctx.db + .select({ config: prices.config }) + .from(customerProducts) + .innerJoin( + customers, + eq(customerProducts.internal_customer_id, customers.internal_id), + ) + .innerJoin( + customerPrices, + eq(customerPrices.customer_product_id, customerProducts.id), + ) + .innerJoin(prices, eq(customerPrices.price_id, prices.id)) + .where( + and( + eq(customers.org_id, ctx.org.id), + eq(customers.env, ctx.env), + eq(customers.id, customerId), + eq(customerProducts.product_id, productId), + entityId + ? eq(customerProducts.entity_id, entityId) + : isNull(customerProducts.entity_id), + ), + ) + ) + .map((row) => + row.config && "amount" in row.config ? row.config.amount : undefined, + ) + .filter((amount): amount is number => typeof amount === "number") + .sort((a, b) => a - b); + +test.concurrent(`${chalk.yellowBright("migrations update_plan states: scheduled downgrade survives active plan price update")}`, async () => { + const customerId = "migration-update-state-downgrade"; + const pro = products.pro({ + items: [items.monthlyMessages({ includedUsage: 500 })], + }); + const premium = products.premium({ + items: [items.monthlyMessages({ includedUsage: 1000 })], + }); + + const { autumnV1, autumnV2_2, ctx } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [pro, premium] }), + ], + actions: [ + s.billing.attach({ productId: premium.id }), + s.billing.attach({ productId: pro.id }), + ], + }); + + const before = await autumnV1.customers.get(customerId); + await expectProductCanceling({ customer: before, productId: premium.id }); + await expectProductScheduled({ customer: before, productId: pro.id }); + const scheduledIdsBefore = await getScheduledIds({ + ctx, + customerId, + productId: pro.id, + }); + expect(scheduledIdsBefore.length).toBeGreaterThan(0); + + await runUpdatePlanMigration({ + ctx, + migrationClient: autumnV2_2, + migrationId: `${customerId}-mig`, + customerId, + runOnServer: false, + filter: { customer: { plan: { plan_id: premium.id } } }, + operations: { + customer: [ + { + type: "update_plan", + plan_filter: { plan_id: premium.id }, + customize: { + price: itemsV2.monthlyPrice({ amount: 100 }), + }, + }, + ], + }, + }); + + const after = await autumnV1.customers.get(customerId); + await expectProductCanceling({ customer: after, productId: premium.id }); + await expectProductScheduled({ customer: after, productId: pro.id }); + expect( + await getCustomerProductPriceAmounts({ + ctx, + customerId, + productId: premium.id, + }), + ).toEqual([100]); + expect( + await getScheduledIds({ + ctx, + customerId, + productId: pro.id, + }), + ).toEqual(scheduledIdsBefore); + await expectNoExpiredCustomerProducts({ + ctx, + customerId, + productId: premium.id, + }); + await expectStripeSubscriptionCorrect({ ctx, customerId }); +}); + +test.concurrent(`${chalk.yellowBright("migrations update_plan states: end-of-cycle cancel survives price update")}`, async () => { + const customerId = "migration-update-state-cancel"; + const pro = products.pro({ + items: [items.monthlyMessages({ includedUsage: 500 })], + }); + + const { autumnV1, autumnV2_2, ctx } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [pro] }), + ], + actions: [ + s.billing.attach({ productId: pro.id }), + s.updateSubscription({ + productId: pro.id, + cancelAction: "cancel_end_of_cycle", + }), + ], + }); + + const before = await autumnV1.customers.get(customerId); + await expectProductCanceling({ customer: before, productId: pro.id }); + + await runUpdatePlanMigration({ + ctx, + migrationClient: autumnV2_2, + migrationId: `${customerId}-mig`, + customerId, + runOnServer: false, + filter: { customer: { plan: { plan_id: pro.id } } }, + operations: { + customer: [ + { + type: "update_plan", + plan_filter: { plan_id: pro.id }, + customize: { + price: itemsV2.monthlyPrice({ amount: 50 }), + }, + }, + ], + }, + }); + + const after = await autumnV1.customers.get(customerId); + await expectProductCanceling({ customer: after, productId: pro.id }); + expect( + await getCustomerProductPriceAmounts({ + ctx, + customerId, + productId: pro.id, + }), + ).toEqual([50]); + expect( + await getScheduledIds({ + ctx, + customerId, + productId: pro.id, + }), + ).toEqual([]); + await expectNoExpiredCustomerProducts({ ctx, customerId, productId: pro.id }); + await expectStripeSubscriptionCorrect({ ctx, customerId }); +}); + +test.concurrent(`${chalk.yellowBright("migrations update_plan states: entity scheduled and canceling states survive")}`, async () => { + const customerId = "migration-update-state-entities"; + const pro = products.pro({ + items: [items.monthlyMessages({ includedUsage: 500 })], + }); + const premium = products.premium({ + items: [items.monthlyMessages({ includedUsage: 1000 })], + }); + + const { autumnV1, autumnV2_2, ctx, entities } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [pro, premium] }), + s.entities({ count: 2, featureId: TestFeature.Users }), + ], + actions: [ + s.billing.attach({ productId: premium.id, entityIndex: 0 }), + s.billing.attach({ productId: premium.id, entityIndex: 1 }), + s.billing.attach({ productId: pro.id, entityIndex: 0 }), + s.updateSubscription({ + productId: premium.id, + entityIndex: 1, + cancelAction: "cancel_end_of_cycle", + }), + ], + }); + + const entity1Before = await autumnV1.entities.get( + customerId, + entities[0].id, + ); + const entity2Before = await autumnV1.entities.get( + customerId, + entities[1].id, + ); + await expectProductCanceling({ + customer: entity1Before, + productId: premium.id, + }); + await expectProductScheduled({ customer: entity1Before, productId: pro.id }); + await expectProductCanceling({ + customer: entity2Before, + productId: premium.id, + }); + await expectProductNotPresent({ customer: entity2Before, productId: pro.id }); + const scheduledIdsBefore = await getScheduledIds({ + ctx, + customerId, + productId: pro.id, + entityId: entities[0].id, + }); + expect(scheduledIdsBefore.length).toBeGreaterThan(0); + + await runUpdatePlanMigration({ + ctx, + migrationClient: autumnV2_2, + migrationId: `${customerId}-mig`, + customerId, + runOnServer: false, + filter: { customer: { plan: { plan_id: premium.id } } }, + operations: { + customer: [ + { + type: "update_plan", + plan_filter: { plan_id: premium.id }, + customize: { + price: itemsV2.monthlyPrice({ amount: 100 }), + }, + }, + ], + }, + }); + + const entity1After = await autumnV1.entities.get( + customerId, + entities[0].id, + ); + const entity2After = await autumnV1.entities.get( + customerId, + entities[1].id, + ); + await expectProductCanceling({ + customer: entity1After, + productId: premium.id, + }); + await expectProductScheduled({ customer: entity1After, productId: pro.id }); + await expectProductCanceling({ + customer: entity2After, + productId: premium.id, + }); + await expectProductNotPresent({ customer: entity2After, productId: pro.id }); + expect( + await getCustomerProductPriceAmounts({ + ctx, + customerId, + productId: premium.id, + entityId: entities[0].id, + }), + ).toEqual([100]); + expect( + await getCustomerProductPriceAmounts({ + ctx, + customerId, + productId: premium.id, + entityId: entities[1].id, + }), + ).toEqual([100]); + expect( + await getScheduledIds({ + ctx, + customerId, + productId: pro.id, + entityId: entities[0].id, + }), + ).toEqual(scheduledIdsBefore); + await expectStripeSubscriptionCorrect({ ctx, customerId }); +}); + +test.concurrent(`${chalk.yellowBright("migrations update_plan states: multi-product scheduled downgrade and canceling addon survive")}`, async () => { + const customerId = "migration-update-state-products"; + const pro = products.pro({ + items: [items.monthlyMessages({ includedUsage: 500 })], + }); + const premium = products.premium({ + items: [items.monthlyMessages({ includedUsage: 1000 })], + }); + const addon = products.recurringAddOn({ + items: [items.monthlyWords({ includedUsage: 300 })], + }); + + const { autumnV1, autumnV2_2, ctx } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [pro, premium, addon] }), + ], + actions: [ + s.billing.attach({ productId: premium.id }), + s.billing.attach({ productId: addon.id }), + s.billing.attach({ productId: pro.id }), + s.updateSubscription({ + productId: addon.id, + cancelAction: "cancel_end_of_cycle", + }), + ], + }); + + const before = await autumnV1.customers.get(customerId); + await expectProductCanceling({ customer: before, productId: premium.id }); + await expectProductScheduled({ customer: before, productId: pro.id }); + await expectProductCanceling({ customer: before, productId: addon.id }); + const scheduledIdsBefore = await getScheduledIds({ + ctx, + customerId, + productId: pro.id, + }); + expect(scheduledIdsBefore.length).toBeGreaterThan(0); + + await runUpdatePlanMigration({ + ctx, + migrationClient: autumnV2_2, + migrationId: `${customerId}-mig`, + customerId, + runOnServer: false, + filter: { + customer: { + plan: { $or: [{ plan_id: premium.id }, { plan_id: addon.id }] }, + }, + }, + operations: { + customer: [ + { + type: "update_plan", + plan_filter: { plan_id: premium.id }, + customize: { + price: itemsV2.monthlyPrice({ amount: 100 }), + }, + }, + { + type: "update_plan", + plan_filter: { plan_id: addon.id }, + customize: { + price: itemsV2.monthlyPrice({ amount: 40 }), + }, + }, + ], + }, + }); + + const after = await autumnV1.customers.get(customerId); + await expectProductCanceling({ customer: after, productId: premium.id }); + await expectProductScheduled({ customer: after, productId: pro.id }); + await expectProductCanceling({ customer: after, productId: addon.id }); + expect( + await getCustomerProductPriceAmounts({ + ctx, + customerId, + productId: premium.id, + }), + ).toEqual([100]); + expect( + await getCustomerProductPriceAmounts({ + ctx, + customerId, + productId: addon.id, + }), + ).toEqual([40]); + expect( + await getScheduledIds({ + ctx, + customerId, + productId: pro.id, + }), + ).toEqual(scheduledIdsBefore); + await expectNoExpiredCustomerProducts({ + ctx, + customerId, + productId: premium.id, + }); + await expectNoExpiredCustomerProducts({ ctx, customerId, productId: addon.id }); + await expectStripeSubscriptionCorrect({ ctx, customerId }); +}); diff --git a/server/tests/integration/billing/migrations-v2/utils/migrationTestUtils.ts b/server/tests/integration/billing/migrations-v2/utils/migrationTestUtils.ts new file mode 100644 index 000000000..610688eea --- /dev/null +++ b/server/tests/integration/billing/migrations-v2/utils/migrationTestUtils.ts @@ -0,0 +1,81 @@ +import type { Migration } from "@autumn/shared"; +import type { MigrationFilter } from "@autumn/shared/api/migrations/filters/migrationFilter.js"; +import type { + MigrationUpdatePlanCustomize, + UpdatePlanOp, +} from "@autumn/shared/api/migrations/operations/customer/updatePlan/index.js"; +import type { Operations } from "@autumn/shared/api/migrations/operations/operations.js"; + +export type MigrationClient = { + migrationsV2: { + deleteAndCreate: (params: { + id: string; + filter?: MigrationFilter | null; + operations?: Operations | null; + }) => Promise; + update: (params: { + id: string; + updates: { operations?: Operations | null }; + }) => Promise; + }; +}; + +export const createMigration = async ({ + migrationClient, + id, + filter = { customer: { plan: { plan_id: "pro" } } }, + operations, +}: { + migrationClient: MigrationClient; + id: string; + filter?: MigrationFilter | null; + operations: Operations; +}) => + migrationClient.migrationsV2.deleteAndCreate({ + id, + filter, + operations, + }); + +export const updateMigrationOperations = ({ + migrationClient, + id, + operations, +}: { + migrationClient: MigrationClient; + id: string; + operations: Operations; +}) => + migrationClient.migrationsV2.update({ + id, + updates: { operations }, + }); + +export const buildUpdatePlanOperations = ({ + customize, + planId = "pro", + secondCustomize, + secondPlanId = "premium", +}: { + customize: MigrationUpdatePlanCustomize; + planId?: string; + secondCustomize?: MigrationUpdatePlanCustomize; + secondPlanId?: string; +}): Operations => ({ + customer: [ + { + type: "update_plan", + plan_filter: { plan_id: planId }, + customize, + }, + ...(secondCustomize + ? [ + { + type: "update_plan", + plan_filter: { plan_id: secondPlanId }, + customize: secondCustomize, + } satisfies UpdatePlanOp, + ] + : []), + ], +}); diff --git a/server/tests/integration/billing/migrations-v2/utils/runUpdatePlanMigration.ts b/server/tests/integration/billing/migrations-v2/utils/runUpdatePlanMigration.ts index 7c6e911aa..0522640e2 100644 --- a/server/tests/integration/billing/migrations-v2/utils/runUpdatePlanMigration.ts +++ b/server/tests/integration/billing/migrations-v2/utils/runUpdatePlanMigration.ts @@ -2,7 +2,7 @@ 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"; import type { AutumnContext } from "@/honoUtils/HonoEnv.js"; -import { runPrepare } from "@/internal/migrations/v2/prepare/runPrepare.js"; +import { prepare } from "@/internal/migrations/v2/prepare/prepare.js"; import { migrateCustomer } from "@/internal/migrations/v2/run/migrateCustomer/index.js"; type MigrationClient = { @@ -23,7 +23,7 @@ type MigrationClient = { const timeout = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); -const waitForMigrationResult = async ({ +export const waitForMigrationResult = async ({ waitFor, timeoutMs, pollIntervalMs, @@ -59,7 +59,7 @@ export const runUpdatePlanMigration = async ({ customerId, filter, operations, - runOnServer = false, + runOnServer = true, waitFor, timeoutMs = 30_000, pollIntervalMs = 1_000, @@ -96,13 +96,13 @@ export const runUpdatePlanMigration = async ({ return migration; } - const { prepared_state } = await runPrepare({ + const { preparedState } = await prepare({ ctx, migration, - dry_run: false, + dryRun: false, }); - const preparedMigration = { ...migration, prepared_state }; + const preparedMigration = { ...migration, prepared_state: preparedState }; await migrateCustomer({ ctx, diff --git a/shared/api/migrations/compiler/filterToIr/index.ts b/shared/api/migrations/compiler/filterToIr/index.ts deleted file mode 100644 index 60c928d87..000000000 --- a/shared/api/migrations/compiler/filterToIr/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from "./filterToIr.js"; -export * from "./resolutionContext.js"; diff --git a/shared/api/migrations/compiler/index.ts b/shared/api/migrations/compiler/index.ts deleted file mode 100644 index aa8055d00..000000000 --- a/shared/api/migrations/compiler/index.ts +++ /dev/null @@ -1,6 +0,0 @@ -export * from "./compileFilter.js"; -export * from "./compilePlanFilter.js"; -export * from "./filterToIr/index.js"; -export * from "./ir/index.js"; -export * from "./irToSql/index.js"; -export * from "./registry/index.js"; diff --git a/shared/api/migrations/compiler/ir/index.ts b/shared/api/migrations/compiler/ir/index.ts deleted file mode 100644 index da63ed22f..000000000 --- a/shared/api/migrations/compiler/ir/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./irTypes.js"; diff --git a/shared/api/migrations/compiler/irToSql/index.ts b/shared/api/migrations/compiler/irToSql/index.ts deleted file mode 100644 index 024e6e1ea..000000000 --- a/shared/api/migrations/compiler/irToSql/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./irToSql.js"; diff --git a/shared/api/migrations/compiler/registry/index.ts b/shared/api/migrations/compiler/registry/index.ts deleted file mode 100644 index 2475fca80..000000000 --- a/shared/api/migrations/compiler/registry/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export * from "./customerRegistry.js"; -export * from "./planRegistry.js"; -export * from "./registryTypes.js"; diff --git a/shared/api/products/items/mappers/planItemV1ToPriceAndEnt.ts b/shared/api/products/items/mappers/planItemV1ToPriceAndEnt.ts index 7ca4b9323..bc706429c 100644 --- a/shared/api/products/items/mappers/planItemV1ToPriceAndEnt.ts +++ b/shared/api/products/items/mappers/planItemV1ToPriceAndEnt.ts @@ -14,7 +14,7 @@ export const planItemV1ToPriceAndEnt = ({ ctx: SharedContext; item: CreatePlanItemParamsV1; orgId: string; - internalProductId: string; + internalProductId?: string; isCustom: boolean; }) => { const planItemV0 = planItemV1ToV0({ ctx, item }); diff --git a/shared/api/products/utils/index.ts b/shared/api/products/utils/index.ts deleted file mode 100644 index f7bb15cdf..000000000 --- a/shared/api/products/utils/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./match/index.js"; diff --git a/shared/api/products/utils/match/index.ts b/shared/api/products/utils/match/index.ts index b1c4595c0..cf16756d2 100644 --- a/shared/api/products/utils/match/index.ts +++ b/shared/api/products/utils/match/index.ts @@ -1 +1,2 @@ export * from "./planFilterMatchesCustomerProduct.js"; +export * from "./planFilterMatchesProduct.js"; diff --git a/shared/api/products/utils/match/planFilterMatchesProduct.ts b/shared/api/products/utils/match/planFilterMatchesProduct.ts new file mode 100644 index 000000000..3f0fabc03 --- /dev/null +++ b/shared/api/products/utils/match/planFilterMatchesProduct.ts @@ -0,0 +1,43 @@ +import type { FullProduct } from "../../../../models/productModels/productModels.js"; +import { stringMatcherMatches } from "../../../migrations/filters/match/index.js"; +import type { PlanFilter } from "../../../migrations/filters/planFilter.js"; + +export const planFilterMatchesProduct = ({ + filter, + product, +}: { + filter: PlanFilter; + product: FullProduct; +}): boolean => { + if (filter.$or !== undefined) { + if ( + !filter.$or.some((subFilter) => + planFilterMatchesProduct({ filter: subFilter, product }), + ) + ) { + return false; + } + } + + if (filter.plan_id !== undefined) { + if ( + !stringMatcherMatches({ + matcher: filter.plan_id, + value: product.id, + }) + ) { + return false; + } + } + + const unsupported = ["price", "paid", "recurring", "item"] as const; + for (const key of unsupported) { + if ((filter as Record)[key] !== undefined) { + throw new Error( + `planFilterMatchesProduct: filter.${key} not supported in JS matcher yet`, + ); + } + } + + return true; +}; diff --git a/shared/models/productModels/priceModels/priceModels.ts b/shared/models/productModels/priceModels/priceModels.ts index 1b5facca1..8163e196b 100644 --- a/shared/models/productModels/priceModels/priceModels.ts +++ b/shared/models/productModels/priceModels/priceModels.ts @@ -17,7 +17,7 @@ const ProrationConfigSchema = z.object({ export const PriceSchema = z.object({ id: z.string(), - internal_product_id: z.string(), + internal_product_id: z.string().nullable(), org_id: z.string().optional(), created_at: z.number().optional(), diff --git a/shared/models/productModels/priceModels/priceTable.ts b/shared/models/productModels/priceModels/priceTable.ts index a8dffcd35..37b7be4c6 100644 --- a/shared/models/productModels/priceModels/priceTable.ts +++ b/shared/models/productModels/priceModels/priceTable.ts @@ -24,7 +24,7 @@ export const prices = pgTable( { id: text().primaryKey().notNull(), org_id: text("org_id").notNull(), - internal_product_id: text("internal_product_id").notNull(), + internal_product_id: text("internal_product_id"), config: jsonb().$type(), created_at: numeric({ mode: "number" }).notNull(), billing_type: text("billing_type"), diff --git a/shared/utils/productV2Utils/productItemUtils/mappers/itemToPriceAndEnt.ts b/shared/utils/productV2Utils/productItemUtils/mappers/itemToPriceAndEnt.ts index 9cb66087b..aea21aa28 100644 --- a/shared/utils/productV2Utils/productItemUtils/mappers/itemToPriceAndEnt.ts +++ b/shared/utils/productV2Utils/productItemUtils/mappers/itemToPriceAndEnt.ts @@ -72,7 +72,7 @@ const toPrice = ({ }: { item: ProductItem; orgId: string; - internalProductId: string; + internalProductId?: string; isCustom: boolean; newVersion?: boolean; curPrice?: Price; @@ -91,13 +91,13 @@ const toPrice = ({ id: item.price_id || curPrice?.id || priceId(), created_at: item.created_at || Date.now(), org_id: orgId, - internal_product_id: internalProductId, + internal_product_id: internalProductId ?? null, is_custom: isCustom, config, proration_config: null, }; - if (isCustom || newVersion) { + if ((isCustom || newVersion) && !item.price_id) { price = { ...price, id: priceId(), @@ -158,7 +158,7 @@ export const toFeature = ({ rollover: item.config?.rollover, }; - if (isCustom || newVersion) { + if ((isCustom || newVersion) && !item.entitlement_id) { ent = { ...ent, id: entitlementId(), @@ -182,7 +182,7 @@ const toFeatureAndPrice = ({ item: ProductItem; orgId: string; internalFeatureId: string; - internalProductId: string; + internalProductId?: string; isCustom: boolean; curPrice?: Price; curEnt?: Entitlement; @@ -199,7 +199,7 @@ const toFeatureAndPrice = ({ org_id: orgId, created_at: item.created_at || Date.now(), is_custom: isCustom, - internal_product_id: internalProductId, + internal_product_id: internalProductId ?? null, internal_feature_id: internalFeatureId, feature_id: item.feature_id!, @@ -218,7 +218,7 @@ const toFeatureAndPrice = ({ // Will only create new ent id if const newEnt = !curEnt || (isCustom && !entsAreSame(curEnt, ent)); - if (newEnt || newVersion) { + if ((newEnt || newVersion) && !item.entitlement_id) { ent = { ...ent, id: entitlementId(), @@ -284,7 +284,7 @@ const toFeatureAndPrice = ({ id: item.price_id || curPrice?.id || priceId(), created_at: item.created_at || Date.now(), org_id: orgId, - internal_product_id: internalProductId, + internal_product_id: internalProductId ?? null, is_custom: isCustom, config, entitlement_id: ent.id, @@ -317,7 +317,7 @@ const toFeatureAndPrice = ({ price.config = newConfig; } - if (isCustom || newVersion) { + if ((isCustom || newVersion) && !item.price_id) { price = { ...price, id: priceId(), @@ -341,7 +341,7 @@ export const itemToPriceAndEnt = ({ }: { item: ProductItem; orgId: string; - internalProductId: string; + internalProductId?: string; feature?: Feature; curPrice?: Price; curEnt?: Entitlement;