diff --git a/scripts/tinybird/index.ts b/scripts/tinybird/index.ts index 8a967e6f5..c0738184f 100644 --- a/scripts/tinybird/index.ts +++ b/scripts/tinybird/index.ts @@ -1,4 +1,5 @@ import path from "node:path"; +import { createTinybirdApi } from "@tinybirdco/sdk"; type ProfileName = "dev" | "prod" | "prod-legacy"; type TinybirdTarget = "new" | "legacy"; @@ -35,6 +36,7 @@ const usage = `Usage: bun tb info bun tb deploy:check bun tb deploy + bun tb token:read bun tb:prod bun tb:prod-legacy @@ -70,15 +72,58 @@ const requireEnv = (name: string) => { return value; }; +const requireEnvValue = (env: NodeJS.ProcessEnv, name: string) => { + const value = env[name]; + if (!value) { + console.error(`${name} is not set`); + process.exit(1); + } + return value; +}; + const resolveTinybirdArgs = (args: string[]) => { if (args.length === 0 || args[0] === "--help" || args[0] === "-h") { console.log(usage); process.exit(args.length === 0 ? 1 : 0); } + if (args[0] === "token:read") { + const tokenName = args[1]; + if (!tokenName || args.length > 2) { + console.error("Usage: bun tb token:read "); + process.exit(1); + } + + return args; + } + return commandAliases[args[0]] ?? args; }; +const createReadToken = async (tokenName: string, env: NodeJS.ProcessEnv) => { + const baseUrl = requireEnvValue(env, "TINYBIRD_API_URL"); + const api = createTinybirdApi({ + baseUrl, + token: requireEnvValue(env, "TINYBIRD_TOKEN"), + }); + + const url = new URL("/v0/tokens/", `${baseUrl}/`); + url.searchParams.set("name", tokenName); + url.searchParams.set("scope", "WORKSPACE:READ_ALL"); + + const response = await api.request(url.toString(), { + method: "POST", + }); + + if (!response.ok) { + const body = await response.text(); + throw new Error(`Failed to create Tinybird read token: ${body}`); + } + + const result = (await response.json()) as { token?: string }; + console.log(result.token ?? JSON.stringify(result)); +}; + const executeTinybird = async () => { const target = requireEnv("AUTUMN_TINYBIRD_TARGET") as TinybirdTarget; const env = { ...process.env }; @@ -92,6 +137,11 @@ const executeTinybird = async () => { } const args = resolveTinybirdArgs(Bun.argv.slice(2)); + if (args[0] === "token:read") { + await createReadToken(args[1], env); + return; + } + const exitCode = await run(["bunx", "tinybird", ...args], { cwd: serverDir, env, diff --git a/server/experiments/explainMigrationFilterPreview.ts b/server/experiments/explainMigrationFilterPreview.ts new file mode 100644 index 000000000..ebca22682 --- /dev/null +++ b/server/experiments/explainMigrationFilterPreview.ts @@ -0,0 +1,245 @@ +import { AppEnv, type CustomerFilter } from "@autumn/shared"; +import { sql, type SQL } from "drizzle-orm"; +import { PgDialect } from "drizzle-orm/pg-core"; +import { + buildProcessedPreviewCount, + buildProcessedPreviewSelect, + type CustomerExecutionStatus, + type IncludeProcessed, +} from "@/internal/migrations/v2/filters/customers/buildCustomerSelect.js"; +import { initDrizzle } from "../src/db/initDrizzle"; +import { FeatureService } from "../src/internal/features/FeatureService.js"; + +const ORG_ID = process.env.MIGRATION_PREVIEW_ORG_ID; +const MIGRATION_ID = process.env.MIGRATION_PREVIEW_MIGRATION_ID; +const ENV = (process.env.MIGRATION_PREVIEW_ENV ?? AppEnv.Live) as AppEnv; +const PAGE_SIZE = Number(process.env.MIGRATION_PREVIEW_PAGE_SIZE ?? 50); +const EXPLAIN_MAX_LINES = Number(process.env.EXPLAIN_MAX_LINES ?? 80); + +if (!ORG_ID) throw new Error("MIGRATION_PREVIEW_ORG_ID is required"); +if (!MIGRATION_ID) throw new Error("MIGRATION_PREVIEW_MIGRATION_ID is required"); + +const dbUrl = process.env.DATABASE_URL ?? ""; +console.log( + "DATABASE URL host:", + dbUrl.replace(/:\/\/[^@]+@/, "://***:***@") || "(empty)", +); + +const dialect = new PgDialect(); + +const inlineParams = (text: string, params: readonly unknown[]): string => + text.replace(/\$(\d+)/g, (_, n) => { + const value = params[Number(n) - 1]; + if (value === null || value === undefined) return "NULL"; + if (typeof value === "number" || typeof value === "boolean") + return String(value); + return `'${String(value).replace(/'/g, "''")}'`; + }); + +const truncateExplainText = (text: string, maxLines: number): string => { + const lines = text.split("\n"); + if (lines.length <= maxLines) return text; + return [ + ...lines.slice(0, maxLines), + `... (${lines.length - maxLines} more lines truncated)`, + ].join("\n"); +}; + +const printSql = ({ label, query }: { label: string; query: SQL }) => { + const { sql: text, params } = dialect.sqlToQuery(query); + console.log(`\n--- SQL: ${label} ---`); + console.log(inlineParams(text, params)); +}; + +const explain = async ({ + db, + label, + query, +}: { + db: ReturnType["db"]; + label: string; + query: SQL; +}) => { + console.log(`\n=== ${label} ===`); + printSql({ label, query }); + const startedAt = performance.now(); + const result = await db.execute(sql`EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT) ${query}`); + const elapsedMs = performance.now() - startedAt; + const lines = result.map((row) => + String((row as Record)["QUERY PLAN"]), + ); + console.log(`EXPLAIN wall-clock: ${elapsedMs.toFixed(2)}ms`); + console.log(truncateExplainText(lines.join("\n"), EXPLAIN_MAX_LINES)); +}; + +const runScalar = async ({ + db, + label, + query, +}: { + db: ReturnType["db"]; + label: string; + query: SQL; +}) => { + const startedAt = performance.now(); + const result = await db.execute(query); + console.log( + `${label}: ${JSON.stringify(result)} (${(performance.now() - startedAt).toFixed(2)}ms)`, + ); +}; + +const makeIncludeProcessed = ({ + migrationInternalId, + statuses, +}: { + migrationInternalId: string; + statuses?: CustomerExecutionStatus[]; +}): IncludeProcessed => ({ + migrationInternalId, + executionFilter: statuses ? { statuses } : undefined, +}); + +const buildEnrichQuery = (internalIds: string[]): SQL => sql` + SELECT c.internal_id, c.id, c.name, c.email, cp.id AS customer_product_id, p.id AS product_id + FROM customers c + LEFT JOIN customer_products cp ON c.internal_id = cp.internal_customer_id + LEFT JOIN products p ON cp.internal_product_id = p.internal_id + WHERE c.internal_id IN (${sql.join( + internalIds.map((id) => sql`${id}`), + sql`, `, + )}) +`; + +const main = async () => { + const usingReplica = Boolean(process.env.DATABASE_REPLICA_URL); + const { db } = initDrizzle({ replica: usingReplica }); + console.log( + `=== MIGRATION FILTER PREVIEW (${usingReplica ? "REPLICA" : "PRIMARY"}) ===`, + ); + console.log(JSON.stringify({ ORG_ID, MIGRATION_ID, ENV, PAGE_SIZE }, null, 2)); + + await db.execute(sql`SET statement_timeout = '15000ms'`); + await db.execute(sql`SET lock_timeout = '100ms'`); + await db.execute(sql`SET default_transaction_read_only = on`); + + const [migration] = (await db.execute(sql` + SELECT internal_id, id, filter + FROM migrations + WHERE org_id = ${ORG_ID} AND env = ${ENV} AND id = ${MIGRATION_ID} + LIMIT 1 + `)) as Array<{ + internal_id: string; + id: string; + filter: { customer?: CustomerFilter } | null; + }>; + + if (!migration) { + throw new Error( + `Migration ${MIGRATION_ID} not found for org ${ORG_ID} in env ${ENV}`, + ); + } + + const filter = migration.filter?.customer ?? {}; + console.log(`Resolved migration_internal_id: ${migration.internal_id}`); + console.log(`Customer filter: ${JSON.stringify(filter, null, 2)}`); + + await runScalar({ + db, + label: "migration_item_runs by dry_run/status", + query: sql` + SELECT dry_run, status, COUNT(*)::bigint AS count + FROM migration_item_runs + WHERE migration_internal_id = ${migration.internal_id} + AND item_kind = 'customer' + GROUP BY dry_run, status + ORDER BY dry_run, status + `, + }); + + const features = await FeatureService.list({ db, orgId: ORG_ID, env: ENV }); + const ctx = { features }; + console.log(`Loaded ${features.length} features for filter resolution.`); + + const baseIncludeProcessed = makeIncludeProcessed({ + migrationInternalId: migration.internal_id, + }); + const succeededIncludeProcessed = makeIncludeProcessed({ + migrationInternalId: migration.internal_id, + statuses: ["succeeded"], + }); + const notRunIncludeProcessed = makeIncludeProcessed({ + migrationInternalId: migration.internal_id, + statuses: ["not_run"], + }); + + const selectQuery = buildProcessedPreviewSelect({ + orgId: ORG_ID, + env: ENV, + filter, + ctx, + includeProcessed: baseIncludeProcessed, + limit: PAGE_SIZE, + }); + + await explain({ + db, + label: "COUNT no execution status", + query: buildProcessedPreviewCount({ + orgId: ORG_ID, + env: ENV, + filter, + ctx, + includeProcessed: baseIncludeProcessed, + }), + }); + await explain({ db, label: `SELECT first page limit ${PAGE_SIZE}`, query: selectQuery }); + + const selectedRows = (await db.execute(selectQuery)) as Array<{ internal_id: string }>; + if (selectedRows.length > 0) { + await explain({ + db, + label: "ENRICH selected page", + query: buildEnrichQuery(selectedRows.map((row) => row.internal_id)), + }); + } + + await explain({ + db, + label: "COUNT status=succeeded", + query: buildProcessedPreviewCount({ + orgId: ORG_ID, + env: ENV, + filter, + ctx, + includeProcessed: succeededIncludeProcessed, + }), + }); + await explain({ + db, + label: "SELECT status=succeeded first page", + query: buildProcessedPreviewSelect({ + orgId: ORG_ID, + env: ENV, + filter, + ctx, + includeProcessed: succeededIncludeProcessed, + limit: PAGE_SIZE, + }), + }); + + await explain({ + db, + label: "COUNT status=not_run", + query: buildProcessedPreviewCount({ + orgId: ORG_ID, + env: ENV, + filter, + ctx, + includeProcessed: notRunIncludeProcessed, + }), + }); + + process.exit(0); +}; + +await main(); diff --git a/server/src/internal/admin/adminRouter.ts b/server/src/internal/admin/adminRouter.ts index 0bae2e7b6..556f75b0f 100644 --- a/server/src/internal/admin/adminRouter.ts +++ b/server/src/internal/admin/adminRouter.ts @@ -32,6 +32,7 @@ import { handleGetOrgMember } from "./handleGetOrgMember"; import { handleListAdminOrgs } from "./handleListAdminOrgs"; import { handleListAdminUsers } from "./handleListAdminUsers"; import { handleListOAuthClients } from "./handleListOAuthClients"; +import { handleSendCustomerProductUpdatedWebhook } from "./handleSendCustomerProductUpdatedWebhook"; import { handleUpsertAdminCustomerBlockConfig } from "./handleUpsertAdminCustomerBlockConfig"; import { handleUpsertAdminFeatureFlagsConfig } from "./handleUpsertAdminFeatureFlagsConfig"; import { handleUpsertAdminFullSubjectGateConfig } from "./handleUpsertAdminFullSubjectGateConfig"; @@ -160,6 +161,10 @@ honoAdminRouter.get("/org-member", ...handleGetOrgMember); honoAdminRouter.get("/master-stripe-account", ...handleGetMasterStripeAccount); honoAdminRouter.get("/oauth-clients", ...handleListOAuthClients); honoAdminRouter.post("/invoice-line-items", ...handleGetInvoiceLineItems); +honoAdminRouter.post( + "/customer-products/:customer_product_id/send-updated-webhook", + ...handleSendCustomerProductUpdatedWebhook, +); honoAdminRouter.get("/rollouts", ...handleGetRollouts); honoAdminRouter.put("/rollouts/:rollout_id", ...handleUpdateRollout); diff --git a/server/src/internal/admin/handleSendCustomerProductUpdatedWebhook.ts b/server/src/internal/admin/handleSendCustomerProductUpdatedWebhook.ts new file mode 100644 index 000000000..f593b2a91 --- /dev/null +++ b/server/src/internal/admin/handleSendCustomerProductUpdatedWebhook.ts @@ -0,0 +1,67 @@ +import { + AttachScenario, + CusProductStatus, + ErrCode, + RecaseError, + Scopes, +} from "@autumn/shared"; +import { z } from "zod/v4"; +import { createRoute } from "@/honoMiddlewares/routeHandler.js"; +import { sendProductsUpdated } from "@/internal/billing/v2/workflows/sendProductsUpdated/sendProductsUpdated.js"; +import { CusProductService } from "@/internal/customers/cusProducts/CusProductService.js"; + +const statusToScenario = (status: CusProductStatus): AttachScenario => { + switch (status) { + case CusProductStatus.Scheduled: + return AttachScenario.Scheduled; + case CusProductStatus.PastDue: + return AttachScenario.PastDue; + case CusProductStatus.Expired: + return AttachScenario.Expired; + default: + return AttachScenario.Active; + } +}; + +export const handleSendCustomerProductUpdatedWebhook = createRoute({ + scopes: [Scopes.Superuser], + params: z.object({ + customer_product_id: z.string().min(1), + }), + handler: async (c) => { + const ctx = c.get("ctx"); + const { customer_product_id: customerProductId } = c.req.param(); + const customerProduct = await CusProductService.getFull({ + db: ctx.db, + id: customerProductId, + }); + + if ( + !customerProduct || + customerProduct.product.org_id !== ctx.org.id || + customerProduct.product.env !== ctx.env + ) { + throw new RecaseError({ + message: `Customer product not found: ${customerProductId}`, + code: ErrCode.CusProductNotFound, + statusCode: 404, + }); + } + + const customerId = + customerProduct.customer_id ?? customerProduct.internal_customer_id; + + await sendProductsUpdated({ + ctx, + payload: { + orgId: ctx.org.id, + env: ctx.env, + customerId, + customerProductId, + scenario: statusToScenario(customerProduct.status), + }, + }); + + return c.json({ success: true }); + }, +}); diff --git a/server/src/internal/billing/v2/actions/createSchedule/compute/computeScheduledCustomerProducts.ts b/server/src/internal/billing/v2/actions/createSchedule/compute/computeScheduledCustomerProducts.ts index 469c42f2d..06699d4c7 100644 --- a/server/src/internal/billing/v2/actions/createSchedule/compute/computeScheduledCustomerProducts.ts +++ b/server/src/internal/billing/v2/actions/createSchedule/compute/computeScheduledCustomerProducts.ts @@ -34,6 +34,9 @@ export const computeScheduledCustomerProducts = ({ endsAt: phaseContext.endsAt, currentEpochMs: billingContext.currentEpochMs, externalId: productContext.externalId, + isCustom: + productContext.customPrices.length > 0 || + productContext.customEntitlements.length > 0, }); insertCustomerProducts.push(customerProduct); phaseCustomerProductIds.push(customerProduct.id); diff --git a/server/src/internal/billing/v2/actions/updateSubscription/compute/customPlan/computeCustomPlan.ts b/server/src/internal/billing/v2/actions/updateSubscription/compute/customPlan/computeCustomPlan.ts index ea5bb39c5..9333efb4e 100644 --- a/server/src/internal/billing/v2/actions/updateSubscription/compute/customPlan/computeCustomPlan.ts +++ b/server/src/internal/billing/v2/actions/updateSubscription/compute/customPlan/computeCustomPlan.ts @@ -9,6 +9,7 @@ import { computeDeleteCustomerProduct } from "@/internal/billing/v2/actions/upda import { computeCustomPlanNewCustomerProduct } from "@/internal/billing/v2/actions/updateSubscription/compute/customPlan/computeCustomPlanNewCustomerProduct"; import { buildAutumnLineItems } from "@/internal/billing/v2/compute/computeAutumnUtils/buildAutumnLineItems"; import { computePatchCustomerProductPlan } from "@/internal/billing/v2/compute/computePatchPlan"; +import { computeSchedulePhaseReplacements } from "@/internal/billing/v2/compute/computeSchedulePhaseReplacements"; import { applyOneOffPrepaidCarryOvers } from "@/internal/billing/v2/utils/handleOneOffPrepaidCarryOvers/applyOneOffPrepaidCarryOvers"; export const computeCustomPlan = async ({ @@ -57,6 +58,8 @@ export const computeCustomPlan = async ({ newCustomerProduct: newFullCustomerProduct, fullCustomer, }); + const isUpdatingScheduledProduct = + customerProduct.status === CusProductStatus.Scheduled; const { allLineItems } = buildAutumnLineItems({ ctx, @@ -77,13 +80,21 @@ export const computeCustomPlan = async ({ return { customerId: fullCustomer?.id ?? "", insertCustomerProducts: [newFullCustomerProduct], - updateCustomerProduct: { - customerProduct, - updates: { - status: CusProductStatus.Expired, - }, - }, - deleteCustomerProduct, + updateCustomerProduct: isUpdatingScheduledProduct + ? undefined + : { + customerProduct, + updates: { + status: CusProductStatus.Expired, + }, + }, + deleteCustomerProduct: isUpdatingScheduledProduct + ? customerProduct + : deleteCustomerProduct, + schedulePhaseCustomerProductReplacements: computeSchedulePhaseReplacements({ + oldCustomerProduct: customerProduct, + newCustomerProduct: newFullCustomerProduct, + }), customPrices, customEntitlements: [ ...(customEnts ?? []), diff --git a/server/src/internal/billing/v2/actions/updateSubscription/compute/customPlan/computeCustomPlanNewCustomerProduct.ts b/server/src/internal/billing/v2/actions/updateSubscription/compute/customPlan/computeCustomPlanNewCustomerProduct.ts index 745cfb392..3f71a7b45 100644 --- a/server/src/internal/billing/v2/actions/updateSubscription/compute/customPlan/computeCustomPlanNewCustomerProduct.ts +++ b/server/src/internal/billing/v2/actions/updateSubscription/compute/customPlan/computeCustomPlanNewCustomerProduct.ts @@ -81,7 +81,8 @@ export const computeCustomPlanNewCustomerProduct = ({ initOptions: { isCustom: updateSubscriptionContext.isCustom, subscriptionId: stripeSubscription?.id, // don't populate if it's starting in the future. - subscriptionScheduleId: stripeSubscriptionSchedule?.id, + subscriptionScheduleId: + stripeSubscriptionSchedule?.id ?? currentCustomerProduct.scheduled_ids?.[0], externalId: currentCustomerProduct.external_id ?? undefined, startsAt: currentCustomerProduct.starts_at ?? undefined, ...cancelFields, diff --git a/server/src/internal/billing/v2/compute/computePatchPlan/computePatchCustomerProductPlan.ts b/server/src/internal/billing/v2/compute/computePatchPlan/computePatchCustomerProductPlan.ts index 128a40206..d5d18e2fd 100644 --- a/server/src/internal/billing/v2/compute/computePatchPlan/computePatchCustomerProductPlan.ts +++ b/server/src/internal/billing/v2/compute/computePatchPlan/computePatchCustomerProductPlan.ts @@ -8,6 +8,7 @@ import { } from "@autumn/shared"; import type { AutumnContext } from "@/honoUtils/HonoEnv"; import { buildAutumnLineItems } from "@/internal/billing/v2/compute/computeAutumnUtils/buildAutumnLineItems"; +import { computeSchedulePhaseReplacements } from "@/internal/billing/v2/compute/computeSchedulePhaseReplacements"; import { initPatchCustomerProduct } from "@/internal/billing/v2/utils/initFullCustomerProduct/initPatchedCustomerProduct"; export const computePatchCustomerProductPlan = ({ @@ -58,18 +59,31 @@ export const computePatchCustomerProductPlan = ({ } satisfies Partial; if (patchContext.mode === "new") { + const isUpdatingScheduledProduct = + patchContext.originalCustomerProduct.status === CusProductStatus.Scheduled; + return { ...basePlan, insertCustomerProducts: [finalCustomerProduct], - updateCustomerProduct: { - customerProduct: patchContext.originalCustomerProduct, - updates: { - status: CusProductStatus.Expired, - ended_at: Date.now(), - canceled: true, - canceled_at: Date.now(), - }, - }, + updateCustomerProduct: isUpdatingScheduledProduct + ? undefined + : { + customerProduct: patchContext.originalCustomerProduct, + updates: { + status: CusProductStatus.Expired, + ended_at: Date.now(), + canceled: true, + canceled_at: Date.now(), + }, + }, + deleteCustomerProduct: isUpdatingScheduledProduct + ? patchContext.originalCustomerProduct + : undefined, + schedulePhaseCustomerProductReplacements: + computeSchedulePhaseReplacements({ + oldCustomerProduct: patchContext.originalCustomerProduct, + newCustomerProduct: finalCustomerProduct, + }), } satisfies AutumnBillingPlan; } diff --git a/server/src/internal/billing/v2/compute/computeSchedulePhaseReplacements.ts b/server/src/internal/billing/v2/compute/computeSchedulePhaseReplacements.ts new file mode 100644 index 000000000..933175255 --- /dev/null +++ b/server/src/internal/billing/v2/compute/computeSchedulePhaseReplacements.ts @@ -0,0 +1,24 @@ +import { + type AutumnBillingPlan, + CusProductStatus, + type FullCusProduct, +} from "@autumn/shared"; + +export const computeSchedulePhaseReplacements = ({ + oldCustomerProduct, + newCustomerProduct, +}: { + oldCustomerProduct: FullCusProduct; + newCustomerProduct: FullCusProduct; +}): AutumnBillingPlan["schedulePhaseCustomerProductReplacements"] => { + if (oldCustomerProduct.status !== CusProductStatus.Scheduled) return undefined; + + return [ + { + oldCustomerProductId: oldCustomerProduct.id, + newCustomerProductId: newCustomerProduct.id, + internalCustomerId: oldCustomerProduct.internal_customer_id, + internalEntityId: oldCustomerProduct.internal_entity_id, + }, + ]; +}; diff --git a/server/src/internal/billing/v2/execute/executeAutumnBillingPlan.ts b/server/src/internal/billing/v2/execute/executeAutumnBillingPlan.ts index 780890629..3cf1073eb 100644 --- a/server/src/internal/billing/v2/execute/executeAutumnBillingPlan.ts +++ b/server/src/internal/billing/v2/execute/executeAutumnBillingPlan.ts @@ -11,6 +11,7 @@ import { } from "@/internal/billing/v2/utils/billingPlan/customerProductPlanMutations"; import { CusProductService } from "@/internal/customers/cusProducts/CusProductService"; import { CusEntService } from "@/internal/customers/cusProducts/cusEnts/CusEntitlementService"; +import { replaceScheduledPhaseCustomerProductIds } from "@/internal/customers/schedules/repos/replaceScheduledPhaseCustomerProductIds"; import { invoiceActions } from "@/internal/invoices/actions"; import { EntitlementService } from "@/internal/products/entitlements/EntitlementService"; import { FreeTrialService } from "@/internal/products/free-trials/FreeTrialService"; @@ -92,6 +93,11 @@ export const executeAutumnBillingPlan = async ({ newCusProducts: insertCustomerProducts, }); + await replaceScheduledPhaseCustomerProductIds({ + ctx, + replacements: autumnBillingPlan.schedulePhaseCustomerProductReplacements, + }); + // 3. Update customer product (DB only) for (const { customerProduct, updates } of updateCustomerProducts) { // Skip empty updates — drizzle throws "No values to set" on empty SET. diff --git a/server/src/internal/billing/v2/utils/billingChangeResponse/buildPlanChanges.ts b/server/src/internal/billing/v2/utils/billingChangeResponse/buildPlanChanges.ts index 5cc5c3d7f..f424ab80d 100644 --- a/server/src/internal/billing/v2/utils/billingChangeResponse/buildPlanChanges.ts +++ b/server/src/internal/billing/v2/utils/billingChangeResponse/buildPlanChanges.ts @@ -11,6 +11,33 @@ import { toCustomerPlanSnapshot } from "./toCustomerPlanSnapshot"; const getChangePlanId = (change: CustomerPlanChange): string | undefined => change.subscription?.plan_id ?? change.purchase?.plan_id; +const getUpdatedChangeMergeKey = ( + change: CustomerPlanChange, +): string | undefined => { + if (change.subscription) { + const subscription = change.subscription; + return [ + "subscription", + subscription.plan_id, + subscription.status, + subscription.started_at, + subscription.expires_at, + subscription.canceled_at, + subscription.trial_ends_at, + ].join(":"); + } + + if (change.purchase) { + const purchase = change.purchase; + return [ + "purchase", + purchase.plan_id, + purchase.status, + purchase.expires_at, + ].join(":"); + } +}; + /** * When a billing action updates a plan in-place, Autumn often creates a new * customer product (insertCustomerProducts) and expires the old one @@ -79,15 +106,15 @@ const mergeUpdatedPlanChanges = ( const result: CustomerPlanChange[] = []; for (const change of changes) { - const planId = getChangePlanId(change); - if (change.action !== "updated" || !planId) { + const mergeKey = getUpdatedChangeMergeKey(change); + if (change.action !== "updated" || !mergeKey) { result.push(change); continue; } - const existing = merged.get(planId); + const existing = merged.get(mergeKey); if (!existing) { - merged.set(planId, change); + merged.set(mergeKey, change); result.push(change); continue; } diff --git a/server/src/internal/billing/v2/utils/initFullCustomerProduct/initScheduledCustomerProduct.ts b/server/src/internal/billing/v2/utils/initFullCustomerProduct/initScheduledCustomerProduct.ts index a46ea9c8a..ac972f792 100644 --- a/server/src/internal/billing/v2/utils/initFullCustomerProduct/initScheduledCustomerProduct.ts +++ b/server/src/internal/billing/v2/utils/initFullCustomerProduct/initScheduledCustomerProduct.ts @@ -29,6 +29,7 @@ export const initScheduledCustomerProduct = ({ currentEpochMs, accessStartsAt, externalId, + isCustom, subscriptionId, subscriptionScheduleId, internalEntityId, @@ -44,6 +45,7 @@ export const initScheduledCustomerProduct = ({ accessStartsAt?: number; /** Customer-facing Autumn subscription API id, stored on customer_products.external_id. */ externalId?: string; + isCustom?: boolean; /** When syncing from an existing Stripe sub/schedule, link the resulting * scheduled cusProduct back to it so the customer-products view shows the * Stripe linkage and downstream actions (cancel, restore) can find it. */ @@ -75,6 +77,7 @@ export const initScheduledCustomerProduct = ({ status: accessStartsAt === undefined ? CusProductStatus.Scheduled : undefined, accessStartsAt, externalId, + isCustom, subscriptionId, subscriptionScheduleId, internalEntityId, diff --git a/server/src/internal/customers/schedules/repos/replaceScheduledPhaseCustomerProductIds.ts b/server/src/internal/customers/schedules/repos/replaceScheduledPhaseCustomerProductIds.ts new file mode 100644 index 000000000..9f1d4ca2e --- /dev/null +++ b/server/src/internal/customers/schedules/repos/replaceScheduledPhaseCustomerProductIds.ts @@ -0,0 +1,54 @@ +import { + type AutumnBillingPlan, + schedulePhases, + schedules, +} from "@autumn/shared"; +import { and, eq, isNull } from "drizzle-orm"; +import type { RepoContext } from "@/db/repoContext.js"; + +export const replaceScheduledPhaseCustomerProductIds = async ({ + ctx, + replacements, +}: { + ctx: RepoContext; + replacements?: AutumnBillingPlan["schedulePhaseCustomerProductReplacements"]; +}) => { + await Promise.all((replacements ?? []).map(async (replacement) => { + const phases = await ctx.db + .select({ + id: schedulePhases.id, + customerProductIds: schedulePhases.customer_product_ids, + }) + .from(schedulePhases) + .innerJoin(schedules, eq(schedulePhases.schedule_id, schedules.id)) + .where( + and( + eq(schedules.org_id, ctx.org.id), + eq(schedules.env, ctx.env), + eq(schedules.internal_customer_id, replacement.internalCustomerId), + replacement.internalEntityId + ? eq(schedules.internal_entity_id, replacement.internalEntityId) + : isNull(schedules.internal_entity_id), + ), + ); + + await Promise.all( + phases + .filter((phase) => + phase.customerProductIds.includes(replacement.oldCustomerProductId), + ) + .map((phase) => + ctx.db + .update(schedulePhases) + .set({ + customer_product_ids: phase.customerProductIds.map((id) => + id === replacement.oldCustomerProductId + ? replacement.newCustomerProductId + : id, + ), + }) + .where(eq(schedulePhases.id, phase.id)), + ), + ); + })); +}; diff --git a/server/src/internal/migrations/v2/filters/customers/buildCustomerSelect.ts b/server/src/internal/migrations/v2/filters/customers/buildCustomerSelect.ts index d466f196a..ab7fdaabb 100644 --- a/server/src/internal/migrations/v2/filters/customers/buildCustomerSelect.ts +++ b/server/src/internal/migrations/v2/filters/customers/buildCustomerSelect.ts @@ -6,6 +6,15 @@ import { rawWithParamsToDrizzle } from "../rawWithParamsToDrizzle.js"; export type IncludeProcessed = { migrationInternalId: string; + executionFilter?: CustomerExecutionStatusFilter; +}; + +export type CustomerExecutionStatus = MigrationItemRunStatus | "not_run"; + +export type CustomerExecutionStatusFilter = { + statuses: CustomerExecutionStatus[]; + migrationRunId?: string; + dryRun?: boolean; }; export type CustomerQueryArgs = { @@ -75,21 +84,103 @@ const buildProcessedIn = (includeProcessed: IncludeProcessed): SQL => sql` AND mir.dry_run = false )`; +const buildExecutionScope = ( + migrationInternalId: string, + filter: CustomerExecutionStatusFilter | undefined, +): SQL => { + const dryRunScope = + filter?.dryRun !== undefined + ? sql`AND mir.dry_run = ${filter.dryRun}` + : sql`AND mir.dry_run = false`; + const runScope = filter?.migrationRunId + ? sql`AND mir.migration_run_id = ${filter.migrationRunId}` + : sql``; + + return sql` + mir.migration_internal_id = ${migrationInternalId} + AND mir.item_kind = 'customer' + ${dryRunScope} + ${runScope} + `; +}; + +const buildExecutionStatusWhere = ( + includeProcessed: IncludeProcessed | undefined, + { includeNotRun = true }: { includeNotRun?: boolean } = {}, +): SQL => { + const filter = includeProcessed?.executionFilter; + if (!includeProcessed || !filter || filter.statuses.length === 0) return sql``; + + const explicitStatuses = filter.statuses.filter( + (status): status is MigrationItemRunStatus => status !== "not_run", + ); + const clauses: SQL[] = []; + + if (explicitStatuses.length > 0) { + const statuses = sql.join( + explicitStatuses.map((status) => sql`${status}`), + sql`, `, + ); + clauses.push(sql` + EXISTS ( + SELECT 1 + FROM migration_item_runs mir + WHERE ${buildExecutionScope(includeProcessed.migrationInternalId, filter)} + AND mir.item_id = c.internal_id + AND mir.status IN (${statuses}) + ) + `); + } + + if (includeNotRun && filter.statuses.includes("not_run")) { + clauses.push(sql` + NOT EXISTS ( + SELECT 1 + FROM migration_item_runs mir + WHERE ${buildExecutionScope(includeProcessed.migrationInternalId, filter)} + AND mir.item_id = c.internal_id + ) + `); + } + + if (clauses.length === 0) return sql`AND false`; + return clauses.length === 1 + ? sql`AND ${clauses[0]}` + : sql`AND (${sql.join(clauses, sql` OR `)})`; +}; + +const getExecutionFilterMode = ( + includeProcessed: IncludeProcessed, +): "all" | "explicit_only" | "not_run_only" | "mixed" => { + const statuses = includeProcessed.executionFilter?.statuses; + if (!statuses || statuses.length === 0) return "all"; + + const hasNotRun = statuses.includes("not_run"); + const hasExplicit = statuses.some((status) => status !== "not_run"); + if (hasExplicit && hasNotRun) return "mixed"; + if (hasExplicit) return "explicit_only"; + return "not_run_only"; +}; + // Predicates shared by both UNION branches (and the single-branch query). // Rebuilt per call so a branch never reuses another's SQL chunk instance. const buildCommonWhere = ({ checkpoint, search, afterInternalId, + includeProcessed, + includeNotRun, }: { checkpoint?: CustomerCheckpointExclusion; search?: string; afterInternalId?: string; + includeProcessed?: IncludeProcessed; + includeNotRun?: boolean; }): SQL => { const cursor = afterInternalId ? sql`AND c.internal_id < ${afterInternalId}` : sql``; - return sql`${buildCheckpointWhere(checkpoint)} ${buildSearchWhere(search)} ${cursor}`; + return sql`${buildCheckpointWhere(checkpoint)} ${buildSearchWhere(search)} ${buildExecutionStatusWhere(includeProcessed, { includeNotRun })} ${cursor}`; }; /** @@ -170,16 +261,38 @@ export const buildProcessedPreviewSelect = ({ const where = compileWhere({ orgId, env, filter, ctx }); const processed = buildProcessedIn(includeProcessed); const limitClause = limit !== undefined ? sql`LIMIT ${limit}` : sql``; + const mode = getExecutionFilterMode(includeProcessed); + + if (mode === "explicit_only") { + return sql` + SELECT c.internal_id, c.id, c.name, c.email + FROM customers c + WHERE (${processed}) ${buildCommonWhere({ checkpoint, search, afterInternalId, includeProcessed, includeNotRun: false })} + ORDER BY c.internal_id DESC + ${limitClause} + `; + } + + if (mode === "not_run_only") { + return sql` + SELECT c.internal_id, c.id, c.name, c.email + FROM customers c + WHERE (${where}) ${buildCommonWhere({ checkpoint, search, afterInternalId, includeProcessed })} + ORDER BY c.internal_id DESC + ${limitClause} + `; + } + return sql` SELECT u.internal_id, u.id, u.name, u.email FROM ( SELECT c.internal_id, c.id, c.name, c.email FROM customers c - WHERE (${where}) ${buildCommonWhere({ checkpoint, search, afterInternalId })} + WHERE (${where}) ${buildCommonWhere({ checkpoint, search, afterInternalId, includeProcessed })} UNION SELECT c.internal_id, c.id, c.name, c.email FROM customers c - WHERE (${processed}) ${buildCommonWhere({ checkpoint, search, afterInternalId })} + WHERE (${processed}) ${buildCommonWhere({ checkpoint, search, afterInternalId, includeProcessed, includeNotRun: false })} ) u ORDER BY u.internal_id DESC ${limitClause} @@ -197,16 +310,34 @@ export const buildProcessedPreviewCount = ({ }: ProcessedPreviewArgs): SQL => { const where = compileWhere({ orgId, env, filter, ctx }); const processed = buildProcessedIn(includeProcessed); + const mode = getExecutionFilterMode(includeProcessed); + + if (mode === "explicit_only") { + return sql` + SELECT COUNT(*)::bigint AS count + FROM customers c + WHERE (${processed}) ${buildCommonWhere({ checkpoint, search, includeProcessed, includeNotRun: false })} + `; + } + + if (mode === "not_run_only") { + return sql` + SELECT COUNT(*)::bigint AS count + FROM customers c + WHERE (${where}) ${buildCommonWhere({ checkpoint, search, includeProcessed })} + `; + } + return sql` SELECT COUNT(*)::bigint AS count FROM ( SELECT c.internal_id FROM customers c - WHERE (${where}) ${buildCommonWhere({ checkpoint, search })} + WHERE (${where}) ${buildCommonWhere({ checkpoint, search, includeProcessed })} UNION SELECT c.internal_id FROM customers c - WHERE (${processed}) ${buildCommonWhere({ checkpoint, search })} + WHERE (${processed}) ${buildCommonWhere({ checkpoint, search, includeProcessed, includeNotRun: false })} ) u `; }; diff --git a/server/src/internal/migrations/v2/handlers/handleCancelMigrationRun.ts b/server/src/internal/migrations/v2/handlers/handleCancelMigrationRun.ts index acd6b5e43..acae2b66b 100644 --- a/server/src/internal/migrations/v2/handlers/handleCancelMigrationRun.ts +++ b/server/src/internal/migrations/v2/handlers/handleCancelMigrationRun.ts @@ -11,6 +11,7 @@ import { migrationRepo, migrationRunRepo, } from "@/internal/migrations/v2/repos/index.js"; +import { clearOrgCache } from "@/internal/orgs/orgUtils/clearOrgCache.js"; const CancelMigrationRunBody = z.object({ id: z.string(), @@ -70,6 +71,15 @@ export const handleCancelMigrationRun = createRoute({ }, }); + if (activeRun.lazy_run) { + await clearOrgCache({ + db: ctx.db, + orgId: ctx.org.id, + env: ctx.env, + logger: ctx.logger, + }); + } + return c.json({ migration_id: id, run_id: activeRun.internal_id, diff --git a/server/src/internal/migrations/v2/handlers/handlePreviewMigrationFilter.ts b/server/src/internal/migrations/v2/handlers/handlePreviewMigrationFilter.ts index 8fa61c414..33fb46412 100644 --- a/server/src/internal/migrations/v2/handlers/handlePreviewMigrationFilter.ts +++ b/server/src/internal/migrations/v2/handlers/handlePreviewMigrationFilter.ts @@ -24,6 +24,12 @@ const PreviewFilterBody = z.object({ page: z.number().int().min(0).optional().default(0), pageSize: z.number().int().min(1).max(500).optional().default(DEFAULT_PAGE_SIZE), migrationId: z.string().optional(), + executionStatuses: z + .array(z.enum(["succeeded", "skipped", "failed", "not_run"])) + .optional() + .default([]), + migrationRunId: z.string().optional(), + migrationRunDryRun: z.boolean().optional(), }); /** POST /migrations.filter.preview — count + enriched paginated customers. */ @@ -32,14 +38,33 @@ export const handlePreviewMigrationFilter = createRoute({ body: PreviewFilterBody, handler: async (c) => { const ctx = c.get("ctx"); - const { filter, search, page, pageSize, migrationId } = c.req.valid("json"); + const { + filter, + search, + page, + pageSize, + migrationId, + executionStatuses, + migrationRunId, + migrationRunDryRun, + } = c.req.valid("json"); const searchTerm = search || undefined; let includeProcessed: IncludeProcessed | undefined; if (migrationId) { const migration = await migrationRepo.find({ ctx, id: migrationId }); - includeProcessed = { migrationInternalId: migration.internal_id }; + includeProcessed = { + migrationInternalId: migration.internal_id, + executionFilter: + executionStatuses.length > 0 + ? { + statuses: executionStatuses, + migrationRunId, + dryRun: migrationRunDryRun, + } + : undefined, + }; } const [count, pageRows] = await Promise.all([ diff --git a/server/src/internal/migrations/v2/handlers/handleRunMigration.ts b/server/src/internal/migrations/v2/handlers/handleRunMigration.ts index ce9f14f17..cc67b30ff 100644 --- a/server/src/internal/migrations/v2/handlers/handleRunMigration.ts +++ b/server/src/internal/migrations/v2/handlers/handleRunMigration.ts @@ -84,6 +84,7 @@ export const handleRunMigration = createRoute({ migrationId: id, migrationRunId, dryRun, + lazyRun, controls: { limit, only, concurrency, retryFailed }, }, getRunMigrationTriggerOptions({ diff --git a/server/src/internal/migrations/v2/operations/utils/mergeAutumnBillingPlans.ts b/server/src/internal/migrations/v2/operations/utils/mergeAutumnBillingPlans.ts index bb6786dc6..774623956 100644 --- a/server/src/internal/migrations/v2/operations/utils/mergeAutumnBillingPlans.ts +++ b/server/src/internal/migrations/v2/operations/utils/mergeAutumnBillingPlans.ts @@ -39,6 +39,11 @@ export const mergeAutumnBillingPlans = ({ ...(incoming.deleteCustomerProducts ?? []), ], }), + schedulePhaseCustomerProductReplacements: mergeByKey({ + base: base.schedulePhaseCustomerProductReplacements, + incoming: incoming.schedulePhaseCustomerProductReplacements, + getKey: (replacement) => replacement.oldCustomerProductId, + }), customPrices: mergeById({ base: base.customPrices, incoming: incoming.customPrices, diff --git a/server/src/internal/migrations/v2/run/migrateCustomer/executeMigrateCustomerPlan.ts b/server/src/internal/migrations/v2/run/migrateCustomer/executeMigrateCustomerPlan.ts index ba111469d..ab920c0b5 100644 --- a/server/src/internal/migrations/v2/run/migrateCustomer/executeMigrateCustomerPlan.ts +++ b/server/src/internal/migrations/v2/run/migrateCustomer/executeMigrateCustomerPlan.ts @@ -1,7 +1,10 @@ +import type { UpdateSubscriptionBillingContext } from "@autumn/shared"; import type { AutumnContext } from "@/honoUtils/HonoEnv.js"; import { executeAutumnBillingPlan } from "@/internal/billing/v2/execute/executeAutumnBillingPlan.js"; import { executeStripeBillingPlan } from "@/internal/billing/v2/providers/stripe/execute/executeStripeBillingPlan.js"; import { logStripeBillingResult } from "@/internal/billing/v2/providers/stripe/logs/logStripeBillingResult.js"; +import { sendBillingUpdatedWebhook } from "@/internal/billing/v2/workflows/sendBillingUpdatedWebhook/sendBillingUpdatedWebhook.js"; +import { billingPlanToSendProductsUpdated } from "@/internal/billing/v2/workflows/sendProductsUpdated/billingPlanToSendProductsUpdated.js"; import { deleteCachedFullCustomer } from "@/internal/customers/cusUtils/fullCustomerCacheUtils/deleteCachedFullCustomer.js"; import type { MigrateCustomerContext } from "@/internal/migrations/v2/operations/types/index.js"; import { appendMigrationBillingLog } from "@/internal/migrations/v2/operations/utils/index.js"; @@ -11,10 +14,12 @@ export const executeMigrateCustomerPlan = async ({ ctx, context, billingPlan, + billingContexts, }: { ctx: AutumnContext; context: MigrateCustomerContext; billingPlan: MigrateCustomerBillingPlan; + billingContexts: UpdateSubscriptionBillingContext[]; }): Promise => { for (const stripeBillingPlan of billingPlan.stripeBillingPlans) { const stripeResult = await executeStripeBillingPlan({ @@ -38,6 +43,21 @@ export const executeMigrateCustomerPlan = async ({ autumnBillingPlan: billingPlan.autumn, }); + const primaryBillingContext = billingContexts[0]; + if (primaryBillingContext) { + await billingPlanToSendProductsUpdated({ + ctx, + autumnBillingPlan: billingPlan.autumn, + billingContext: primaryBillingContext, + }); + } + + await sendBillingUpdatedWebhook({ + ctx, + autumnBillingPlan: billingPlan.autumn, + originalFullCustomer: context.fullCustomer, + }); + const customerId = context.fullCustomer.id ?? context.fullCustomer.internal_id; await deleteCachedFullCustomer({ diff --git a/server/src/internal/migrations/v2/run/migrateCustomer/migrateCustomer.ts b/server/src/internal/migrations/v2/run/migrateCustomer/migrateCustomer.ts index 76e43d3bb..7f9267cd2 100644 --- a/server/src/internal/migrations/v2/run/migrateCustomer/migrateCustomer.ts +++ b/server/src/internal/migrations/v2/run/migrateCustomer/migrateCustomer.ts @@ -83,6 +83,7 @@ export const migrateCustomer = async ({ ctx: migrationCtx, context, billingPlan, + billingContexts, }); } diff --git a/server/src/trigger/migrations/runMigrationTask.ts b/server/src/trigger/migrations/runMigrationTask.ts index 53ad83861..9b0ce9561 100644 --- a/server/src/trigger/migrations/runMigrationTask.ts +++ b/server/src/trigger/migrations/runMigrationTask.ts @@ -5,6 +5,7 @@ import { warmupRegionalRedis } from "@/external/redis/initUtils/redisWarmup.js"; import { withMigrationRunTracking } from "@/internal/migrations/v2/actions/migrationRun/index.js"; import { migrationRepo } from "@/internal/migrations/v2/repos/index.js"; import { runMigration } from "@/internal/migrations/v2/run/runMigration.js"; +import { clearOrgCache } from "@/internal/orgs/orgUtils/clearOrgCache.js"; import { createTriggerContext } from "@/trigger/utils/createTriggerContext.js"; const ControlsSchema = z.object({ @@ -20,6 +21,7 @@ const PayloadSchema = z.object({ migrationId: z.string(), migrationRunId: z.string(), dryRun: z.boolean().default(false), + lazyRun: z.boolean().default(false), controls: ControlsSchema, }); @@ -35,7 +37,7 @@ export const runMigrationTask = task({ machine: "medium-1x", maxDuration: 3600, run: async (rawPayload: unknown, { ctx: triggerCtx }) => { - const { orgId, env, migrationId, migrationRunId, dryRun, controls } = + const { orgId, env, migrationId, migrationRunId, dryRun, lazyRun, controls } = PayloadSchema.parse(rawPayload); const { ctx, logger } = await createTriggerContext({ @@ -69,40 +71,51 @@ export const runMigrationTask = task({ }, }); - await withMigrationRunTracking({ - ctx, - migrationRunId, - run: async () => { - const migration = await migrationRepo.find({ ctx, id: migrationId }); + try { + await withMigrationRunTracking({ + ctx, + migrationRunId, + run: async () => { + const migration = await migrationRepo.find({ ctx, id: migrationId }); - // Default concurrency: 10 normally, 25 when no_billing_changes - // because we're not hitting Stripe per customer. Caller can still - // override via controls.concurrency. - const defaultConcurrency = - migration.no_billing_changes === true ? 25 : 10; - const effectiveControls = { - ...(controls ?? {}), - concurrency: controls?.concurrency ?? defaultConcurrency, - }; + // Default concurrency: 10 normally, 25 when no_billing_changes + // because we're not hitting Stripe per customer. Caller can still + // override via controls.concurrency. + const defaultConcurrency = + migration.no_billing_changes === true ? 25 : 10; + const effectiveControls = { + ...(controls ?? {}), + concurrency: controls?.concurrency ?? defaultConcurrency, + }; - logger.info("run-migration: resolved controls", { - data: { + logger.info("run-migration: resolved controls", { + data: { + migrationRunId, + noBillingChanges: migration.no_billing_changes === true, + concurrency: effectiveControls.concurrency, + concurrencyExplicit: controls?.concurrency !== undefined, + }, + }); + + await runMigration({ + ctx, + migration, + dryRun, migrationRunId, - noBillingChanges: migration.no_billing_changes === true, - concurrency: effectiveControls.concurrency, - concurrencyExplicit: controls?.concurrency !== undefined, - }, + controls: effectiveControls, + }); + }, + }); + } finally { + if (lazyRun && !dryRun) { + await clearOrgCache({ + db: ctx.db, + orgId, + env, + logger, }); - - await runMigration({ - ctx, - migration, - dryRun, - migrationRunId, - controls: effectiveControls, - }); - }, - }); + } + } logger.info("run-migration: done", { data: { diff --git a/server/tests/integration/billing/autumn-webhooks/billing-updated/billing-updated-migration.test.ts b/server/tests/integration/billing/autumn-webhooks/billing-updated/billing-updated-migration.test.ts new file mode 100644 index 000000000..40b997236 --- /dev/null +++ b/server/tests/integration/billing/autumn-webhooks/billing-updated/billing-updated-migration.test.ts @@ -0,0 +1,132 @@ +/** + * Migration execution should emit billing.updated like normal billing actions. + * Red: server-run migrations mutate Autumn but never send the webhook. + */ + +import { afterAll, beforeAll, expect, test } from "bun:test"; +import type { + BillingChangeResponse, + CustomerPlanChange, + PlanChangeAction, +} from "@autumn/shared"; +import { + getTestSvixAppId, + setupWebhookTest, + type WebhookTestSetup, + waitForWebhook, +} from "@tests/integration/utils/svixWebhookTestUtils.js"; +import { runUpdatePlanMigration } from "@tests/integration/billing/migrations-v2/utils/runUpdatePlanMigration.js"; +import { TestFeature } from "@tests/setup/v2Features.js"; +import { items } from "@tests/utils/fixtures/items.js"; +import { itemsV2 } from "@tests/utils/fixtures/itemsV2.js"; +import { products } from "@tests/utils/fixtures/products.js"; +import ctx from "@tests/utils/testInitUtils/createTestContext.js"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js"; +import chalk from "chalk"; + +type BillingUpdatedPayload = { + type: string; + data: BillingChangeResponse; +}; + +const findChange = ( + planChanges: CustomerPlanChange[] | undefined, + { action, planId }: { action: PlanChangeAction; planId: string }, +): CustomerPlanChange | undefined => + planChanges?.find( + (change) => + change.action === action && + (change.subscription?.plan_id ?? change.purchase?.plan_id) === planId, + ); + +let webhook: WebhookTestSetup; +let playToken: string; + +beforeAll(async () => { + const appId = getTestSvixAppId({ svixConfig: ctx.org.svix_config }); + webhook = await setupWebhookTest({ + appId, + filterTypes: ["billing.updated"], + }); + playToken = webhook.playToken; +}); + +afterAll(async () => { + await webhook?.cleanup(); +}); + +test(`${chalk.yellowBright("billing.updated: migration update_plan emits webhook")}`, async () => { + const suffix = Date.now(); + const customerId = `billing-updated-migration-${suffix}`; + const enterprise = products.base({ + id: `enterprise-migration-${suffix}`, + items: [items.monthlyMessages({ includedUsage: 100 })], + }); + + const { autumnV2_2, ctx: scenarioCtx } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success", skipWebhooks: true }), + s.products({ list: [enterprise] }), + ], + actions: [s.billing.attach({ productId: enterprise.id })], + }); + + let webhookResult: + | Awaited>> + | undefined; + + await runUpdatePlanMigration({ + ctx: scenarioCtx, + migrationClient: autumnV2_2, + migrationId: `${customerId}-mig`, + customerId, + filter: { customer: { plan: { plan_id: enterprise.id } } }, + operations: { + customer: [ + { + type: "update_plan", + plan_filter: { plan_id: enterprise.id }, + customize: { + add_items: [itemsV2.dashboard()], + }, + }, + ], + }, + noBillingChanges: true, + runOnServer: true, + waitFor: async () => { + webhookResult = await waitForWebhook({ + token: playToken, + predicate: (payload) => + payload.type === "billing.updated" && + payload.data?.customer_id === customerId && + findChange(payload.data.plan_changes, { + action: "updated", + planId: enterprise.id, + }) !== undefined, + timeoutMs: 5_000, + logWebhook: false, + }); + expect(webhookResult).not.toBeNull(); + }, + timeoutMs: 20_000, + pollIntervalMs: 500, + }); + + expect(webhookResult).toBeDefined(); + const { data } = webhookResult!.payload; + const updated = findChange(data.plan_changes, { + action: "updated", + planId: enterprise.id, + }); + expect(updated).toBeDefined(); + expect(updated?.item_changes).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + action: "created", + feature_id: TestFeature.Dashboard, + }), + ]), + ); +}); diff --git a/server/tests/integration/billing/migrations-v2/update-plan-operation/create-schedule/update-plan-op-scheduled-create-schedule.test.ts b/server/tests/integration/billing/migrations-v2/update-plan-operation/create-schedule/update-plan-op-scheduled-create-schedule.test.ts new file mode 100644 index 000000000..ffc4dac2f --- /dev/null +++ b/server/tests/integration/billing/migrations-v2/update-plan-operation/create-schedule/update-plan-op-scheduled-create-schedule.test.ts @@ -0,0 +1,303 @@ +/** + * TDD coverage for update_plan migrations over createSchedule-managed scheduled rows. + * + * Contract under test: + * New behaviors: + * - Future scheduled rows created by createSchedule can be version-migrated. + * - Replacing one product in a multi-plan future phase rewires only that ID. + * - Feature quantities/options on scheduled rows survive replacement. + * Side effects: + * - `no_billing_changes: true` updates Autumn only and leaves the Stripe schedule unchanged. + * - Schedule phases never point at deleted customer product IDs. + */ + +import { expect, test } from "bun:test"; +import type { ApiCustomerV3 } from "@autumn/shared"; +import { CusProductStatus, ms } from "@autumn/shared"; +import { expectCustomerInvoiceCorrect } from "@tests/integration/billing/utils/expectCustomerInvoiceCorrect"; +import { expectNoExpiredCustomerProducts } from "@tests/integration/billing/utils/expectNoExpiredCustomerProducts"; +import { TestFeature } from "@tests/setup/v2Features"; +import { items } from "@tests/utils/fixtures/items"; +import { products } from "@tests/utils/fixtures/products"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario"; +import chalk from "chalk"; +import type Stripe from "stripe"; +import { runUpdatePlanMigration } from "../../utils/runUpdatePlanMigration"; +import { + expectNoCustomerProductRow, + getCustomerProductBalances, + getCustomerProductFeatureIds, + getCustomerProductPriceAmounts, + getPhaseCustomerProductIds, + getRequiredStripeScheduleId, + getScheduledCustomerProductRow, +} from "../utils/scheduledCustomerProductTestUtils"; + +const stripeScheduleSignature = (schedule: Stripe.SubscriptionSchedule) => ({ + status: schedule.status, + currentPhase: schedule.current_phase, + phases: schedule.phases.map((phase) => ({ + startDate: phase.start_date, + endDate: phase.end_date, + items: phase.items.map((item) => ({ + price: typeof item.price === "string" ? item.price : item.price.id, + quantity: item.quantity, + })), + })), +}); + +test(`${chalk.yellowBright("migrations update_plan scheduled createSchedule: future row replacement rewires one multi-plan phase ID")}`, async () => { + const customerId = "migration-update-scheduled-create-schedule"; + const activePlan = products.pro({ + id: "scheduled-create-schedule-active", + items: [items.monthlyWords({ includedUsage: 100 })], + }); + const futurePlan = products.base({ + id: "scheduled-create-schedule-base", + items: [ + items.monthlyMessages({ includedUsage: 100 }), + items.monthlyPrice({ price: 20 }), + ], + }); + const untouchedFuturePlan = products.base({ + id: "scheduled-create-schedule-untouched", + group: "backup", + items: [ + items.monthlyPrice({ price: 40 }), + items.monthlyWords({ includedUsage: 50 }), + ], + }); + + const { autumnV1, autumnV2_2, ctx } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [activePlan, futurePlan, untouchedFuturePlan] }), + ], + actions: [], + }); + + const now = Date.now(); + const response = await autumnV1.billing.createSchedule({ + customer_id: customerId, + phases: [ + { + starts_at: now, + plans: [{ plan_id: activePlan.id }], + }, + { + starts_at: now + ms.days(30), + plans: [ + { plan_id: futurePlan.id }, + { plan_id: untouchedFuturePlan.id }, + ], + }, + ], + }); + const scheduledBefore = await getScheduledCustomerProductRow({ + ctx, + customerId, + productId: futurePlan.id, + }); + const untouchedScheduledBefore = await getScheduledCustomerProductRow({ + ctx, + customerId, + productId: untouchedFuturePlan.id, + }); + expect(response.phases[1]?.customer_product_ids).toEqual([ + scheduledBefore.id, + untouchedScheduledBefore.id, + ]); + + const stripeScheduleId = getRequiredStripeScheduleId({ + scheduledIds: scheduledBefore.scheduledIds, + }); + const stripeScheduleBefore = + await ctx.stripeCli.subscriptionSchedules.retrieve(stripeScheduleId); + const stripeSignatureBefore = stripeScheduleSignature( + stripeScheduleBefore as Stripe.SubscriptionSchedule, + ); + + await autumnV1.products.update(futurePlan.id, { + items: [ + items.monthlyPrice({ price: 30 }), + items.monthlyMessages({ includedUsage: 250 }), + ], + }); + + await runUpdatePlanMigration({ + ctx, + migrationClient: autumnV2_2, + migrationId: `${customerId}-mig-${Date.now()}`, + customerId, + noBillingChanges: true, + filter: { customer: { plan: { plan_id: futurePlan.id } } }, + operations: { + customer: [ + { + type: "update_plan", + plan_filter: { plan_id: futurePlan.id, version: 1 }, + version: 2, + }, + ], + }, + runOnServer: false, + }); + + await expectNoCustomerProductRow({ + ctx, + customerProductId: scheduledBefore.id, + }); + const scheduledAfter = await getScheduledCustomerProductRow({ + ctx, + customerId, + productId: futurePlan.id, + }); + expect(scheduledAfter.id).not.toBe(scheduledBefore.id); + expect(scheduledAfter.version).toBe(2); + expect(scheduledAfter.startsAt).toBe(scheduledBefore.startsAt); + expect(scheduledAfter.scheduledIds).toEqual(scheduledBefore.scheduledIds); + expect( + await getPhaseCustomerProductIds({ + ctx, + customerProductId: scheduledAfter.id, + }), + ).toEqual([scheduledAfter.id, untouchedScheduledBefore.id]); + expect( + await getCustomerProductPriceAmounts({ + ctx, + customerProductId: scheduledAfter.id, + }), + ).toEqual([30]); + expect( + await getCustomerProductFeatureIds({ + ctx, + customerProductId: scheduledAfter.id, + }), + ).toEqual([TestFeature.Messages]); + const stripeScheduleAfter = + await ctx.stripeCli.subscriptionSchedules.retrieve(stripeScheduleId); + expect(stripeScheduleSignature(stripeScheduleAfter as Stripe.SubscriptionSchedule)).toEqual( + stripeSignatureBefore, + ); + await expectNoExpiredCustomerProducts({ ctx, customerId, productId: futurePlan.id }); + await expectCustomerInvoiceCorrect({ + customer: await autumnV1.customers.get(customerId), + count: 1, + latestTotal: 20, + }); +}); + +test(`${chalk.yellowBright("migrations update_plan scheduled createSchedule: feature quantities survive replacement")}`, async () => { + const customerId = "migration-update-scheduled-quantity"; + const activePlan = products.pro({ + id: "scheduled-quantity-active", + items: [items.monthlyWords({ includedUsage: 100 })], + }); + const futurePlan = products.base({ + id: "scheduled-quantity-future", + items: [ + items.monthlyPrice({ price: 20 }), + items.prepaidMessages(), + ], + }); + + const { autumnV1, autumnV2_2, ctx } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [activePlan, futurePlan] }), + ], + actions: [], + }); + + const now = Date.now(); + await autumnV1.billing.createSchedule({ + customer_id: customerId, + phases: [ + { + starts_at: now, + plans: [{ plan_id: activePlan.id }], + }, + { + starts_at: now + ms.days(30), + plans: [ + { + plan_id: futurePlan.id, + feature_quantities: [ + { + feature_id: TestFeature.Messages, + quantity: 400, + }, + ], + }, + ], + }, + ], + }); + const scheduledBefore = await getScheduledCustomerProductRow({ + ctx, + customerId, + productId: futurePlan.id, + }); + expect(scheduledBefore.options).toEqual([ + expect.objectContaining({ + feature_id: TestFeature.Messages, + quantity: 4, + }), + ]); + + await autumnV1.products.update(futurePlan.id, { + items: [ + items.monthlyPrice({ price: 25 }), + items.prepaidMessages(), + ], + }); + + await runUpdatePlanMigration({ + ctx, + migrationClient: autumnV2_2, + migrationId: `${customerId}-mig-${Date.now()}`, + customerId, + noBillingChanges: true, + filter: { customer: { plan: { plan_id: futurePlan.id } } }, + operations: { + customer: [ + { + type: "update_plan", + plan_filter: { plan_id: futurePlan.id, version: 1 }, + version: 2, + }, + ], + }, + runOnServer: false, + }); + + await expectNoCustomerProductRow({ + ctx, + customerProductId: scheduledBefore.id, + }); + const scheduledAfter = await getScheduledCustomerProductRow({ + ctx, + customerId, + productId: futurePlan.id, + }); + expect(scheduledAfter.version).toBe(2); + expect(scheduledAfter.options).toEqual([ + expect.objectContaining({ + feature_id: TestFeature.Messages, + quantity: 4, + }), + ]); + expect( + await getCustomerProductBalances({ + ctx, + customerProductId: scheduledAfter.id, + }), + ).toEqual([ + expect.objectContaining({ + featureId: TestFeature.Messages, + balance: 400, + }), + ]); +}); diff --git a/server/tests/integration/billing/migrations-v2/update-plan-operation/customize/update-plan-op-scheduled-patch.test.ts b/server/tests/integration/billing/migrations-v2/update-plan-operation/customize/update-plan-op-scheduled-patch.test.ts new file mode 100644 index 000000000..1a7c63c88 --- /dev/null +++ b/server/tests/integration/billing/migrations-v2/update-plan-operation/customize/update-plan-op-scheduled-patch.test.ts @@ -0,0 +1,108 @@ +/** + * TDD coverage for scheduled `update_plan` patch/customize migrations. + * + * Contract under test: + * New behaviors: + * - Scheduled customer products are selected by update_plan customize operations. + * - Customize patches mutate the scheduled row in place instead of delete+insert. + * Side effects: + * - No expired scheduled rows are created. + * - Coupled migrations keep Stripe subscription schedules consistent with Autumn. + */ + +import { expect, test } from "bun:test"; +import type { ApiCustomerV3 } 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"; +import { TestFeature } from "@tests/setup/v2Features"; +import { items } from "@tests/utils/fixtures/items"; +import { itemsV2 } from "@tests/utils/fixtures/itemsV2"; +import { products } from "@tests/utils/fixtures/products"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario"; +import chalk from "chalk"; +import { runUpdatePlanMigration } from "../../utils/runUpdatePlanMigration"; +import { + getCustomerProductFeatureIds, + getCustomerProductPriceAmounts, + getScheduledCustomerProductRow, +} from "../utils/scheduledCustomerProductTestUtils"; + +test(`${chalk.yellowBright("migrations update_plan scheduled patch: customize updates scheduled row in place")}`, async () => { + const customerId = "migration-update-scheduled-patch"; + const pro = products.pro({ + id: "scheduled-patch-pro", + items: [items.monthlyMessages({ includedUsage: 500 })], + }); + const premium = products.premium({ + id: "scheduled-patch-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 beforeCustomer = await autumnV1.customers.get(customerId); + const invoiceCountBefore = beforeCustomer.invoices?.length ?? 0; + const scheduledBefore = await getScheduledCustomerProductRow({ + ctx, + customerId, + productId: pro.id, + }); + + await runUpdatePlanMigration({ + ctx, + migrationClient: autumnV2_2, + migrationId: `${customerId}-mig-${Date.now()}`, + customerId, + filter: { customer: { plan: { plan_id: pro.id } } }, + operations: { + customer: [ + { + type: "update_plan", + plan_filter: { plan_id: pro.id }, + customize: { + price: itemsV2.monthlyPrice({ amount: 24 }), + add_items: [itemsV2.dashboard()], + }, + }, + ], + }, + runOnServer: false, + }); + + const scheduledAfter = await getScheduledCustomerProductRow({ + ctx, + customerId, + productId: pro.id, + }); + expect(scheduledAfter.id).toBe(scheduledBefore.id); + expect(scheduledAfter.version).toBe(1); + expect( + await getCustomerProductPriceAmounts({ + ctx, + customerProductId: scheduledAfter.id, + }), + ).toEqual([24]); + expect( + await getCustomerProductFeatureIds({ + ctx, + customerProductId: scheduledAfter.id, + }), + ).toEqual([TestFeature.Dashboard, TestFeature.Messages]); + await expectNoExpiredCustomerProducts({ ctx, customerId, productId: pro.id }); + await expectCustomerInvoiceCorrect({ + customer: await autumnV1.customers.get(customerId), + count: invoiceCountBefore, + }); + await expectStripeSubscriptionCorrect({ ctx, customerId }); +}); diff --git a/server/tests/integration/billing/migrations-v2/update-plan-operation/previews/scheduled-duplicate-items-preview.test.ts b/server/tests/integration/billing/migrations-v2/update-plan-operation/previews/scheduled-duplicate-items-preview.test.ts new file mode 100644 index 000000000..092fd3d86 --- /dev/null +++ b/server/tests/integration/billing/migrations-v2/update-plan-operation/previews/scheduled-duplicate-items-preview.test.ts @@ -0,0 +1,108 @@ +/** + * Active and scheduled rows for the same plan must stay separate in previews. + * Merging them duplicates boolean item_changes and hides the scheduled scope. + */ + +import { expect, test } from "bun:test"; +import { ms } from "@autumn/shared"; +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 { expectMigrationPreviewCorrect } from "./expectMigrationPreviewCorrect"; +import type { PreviewMigrateCustomer, PreviewPlanChange } from "./previewTestUtils"; +import { runUpdatePlanPreview } from "./previewTestUtils"; + +const getPreviewPlanId = (change: PreviewPlanChange): string | undefined => + change.subscription?.plan_id ?? change.purchase?.plan_id; + +const getUpdatedPlanChanges = ({ + preview, + planId, +}: { + preview: PreviewMigrateCustomer; + planId: string; +}) => + preview.plan_changes.filter( + (change) => change.action === "updated" && getPreviewPlanId(change) === planId, + ); + +const getCreatedFeatureIds = (change: PreviewPlanChange) => + change.item_changes + .filter((itemChange) => itemChange.action === "created") + .map((itemChange) => itemChange.feature_id) + .sort(); + +test(`${chalk.yellowBright("migrations preview scheduled: same-plan active and scheduled updates do not duplicate item changes")}`, async () => { + const suffix = Date.now(); + const customerId = `migration-preview-scheduled-duplicate-${suffix}`; + const plan = products.base({ + id: `migration-preview-scheduled-duplicate-plan-${suffix}`, + items: [items.monthlyMessages({ includedUsage: 100 })], + }); + + const { autumnV1, autumnV2_2 } = await initScenario({ + customerId, + setup: [s.customer(), s.products({ list: [plan] })], + actions: [], + }); + + const now = Date.now(); + await autumnV1.billing.createSchedule({ + customer_id: customerId, + phases: [ + { + starts_at: now, + plans: [{ plan_id: plan.id }], + }, + { + starts_at: now + ms.days(30), + plans: [{ plan_id: plan.id }], + }, + ], + }); + + const preview = await runUpdatePlanPreview({ + autumn: autumnV2_2, + migrationId: `${customerId}-mig`, + filter: { customer: { plan: { plan_id: plan.id } } }, + operations: { + customer: [ + { + type: "update_plan", + plan_filter: { plan_id: plan.id }, + customize: { + add_items: [ + itemsV2.dashboard(), + { feature_id: TestFeature.AdminRights }, + ], + }, + }, + ], + }, + log: false, + }); + + expectMigrationPreviewCorrect({ preview, customerId, log: false }); + expect( + preview.flag_changes.filter( + (change) => change.feature_id === TestFeature.AdminRights, + ), + ).toHaveLength(1); + expect( + preview.flag_changes.filter( + (change) => change.feature_id === TestFeature.Dashboard, + ), + ).toHaveLength(1); + + const planChanges = getUpdatedPlanChanges({ preview, planId: plan.id }); + expect(planChanges).toHaveLength(2); + for (const planChange of planChanges) { + expect(getCreatedFeatureIds(planChange)).toEqual([ + TestFeature.AdminRights, + TestFeature.Dashboard, + ]); + } +}); diff --git a/server/tests/integration/billing/migrations-v2/update-plan-operation/selection/update-plan-op-scheduled-dangling.test.ts b/server/tests/integration/billing/migrations-v2/update-plan-operation/selection/update-plan-op-scheduled-dangling.test.ts new file mode 100644 index 000000000..a9ec50fcb --- /dev/null +++ b/server/tests/integration/billing/migrations-v2/update-plan-operation/selection/update-plan-op-scheduled-dangling.test.ts @@ -0,0 +1,163 @@ +/** + * TDD coverage for server-run migrations when Autumn scheduled rows are missing. + * + * Contract under test: + * New behaviors: + * - A server-run migration can still update selected non-scheduled rows when + * a Stripe schedule exists but its Autumn scheduled customer product was deleted. + * Side effects: + * - `no_billing_changes: true` must not mutate the existing Stripe subscription schedule. + */ + +import { expect, test } from "bun:test"; +import type { ApiCustomerV3 } from "@autumn/shared"; +import { CusProductStatus, ms } from "@autumn/shared"; +import { expectCustomerInvoiceCorrect } from "@tests/integration/billing/utils/expectCustomerInvoiceCorrect"; +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 } from "@tests/utils/testInitUtils/initScenario"; +import { s } from "@tests/utils/testInitUtils/initScenario"; +import chalk from "chalk"; +import type Stripe from "stripe"; +import { runUpdatePlanMigration } from "../../utils/runUpdatePlanMigration"; +import { + deleteCustomerProductRows, + expectNoCustomerProductRow, + getCustomerProductFeatureIds, + getCustomerProductRows, + getRequiredStripeScheduleId, + getScheduledCustomerProductRow, +} from "../utils/scheduledCustomerProductTestUtils"; + +const stripeScheduleSignature = (schedule: Stripe.SubscriptionSchedule) => ({ + status: schedule.status, + currentPhase: schedule.current_phase, + phases: schedule.phases.map((phase) => ({ + startDate: phase.start_date, + endDate: phase.end_date, + items: phase.items.map((item) => ({ + price: typeof item.price === "string" ? item.price : item.price.id, + quantity: item.quantity, + })), + })), +}); + +test(`${chalk.yellowBright("migrations update_plan scheduled dangling: server-run no billing does not touch Stripe schedule")}`, async () => { + const customerId = "migration-update-scheduled-dangling"; + const pro = products.pro({ + id: "scheduled-dangling-pro", + items: [items.monthlyMessages({ includedUsage: 100 })], + }); + const premium = products.premium({ + id: "scheduled-dangling-premium", + items: [items.monthlyMessages({ includedUsage: 500 })], + }); + + const { autumnV1, autumnV2_2, ctx } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [pro, premium] }), + ], + actions: [], + }); + + const now = Date.now(); + await autumnV1.billing.createSchedule({ + customer_id: customerId, + phases: [ + { + starts_at: now, + plans: [{ plan_id: pro.id }], + }, + { + starts_at: now + ms.days(30), + plans: [{ plan_id: premium.id }], + }, + ], + }); + + const scheduledPremium = await getScheduledCustomerProductRow({ + ctx, + customerId, + productId: premium.id, + }); + const stripeScheduleId = getRequiredStripeScheduleId({ + scheduledIds: scheduledPremium.scheduledIds, + }); + const stripeScheduleBefore = + await ctx.stripeCli.subscriptionSchedules.retrieve(stripeScheduleId); + const stripeSignatureBefore = stripeScheduleSignature( + stripeScheduleBefore as Stripe.SubscriptionSchedule, + ); + + await deleteCustomerProductRows({ + ctx, + customerProductIds: [scheduledPremium.id], + }); + await expectNoCustomerProductRow({ + ctx, + customerProductId: scheduledPremium.id, + }); + + const expectActivePlanUpdated = async () => { + const activeRows = await getCustomerProductRows({ + ctx, + customerId, + productId: pro.id, + status: CusProductStatus.Active, + }); + expect(activeRows).toHaveLength(1); + expect( + await getCustomerProductFeatureIds({ + ctx, + customerProductId: activeRows[0]!.id, + }), + ).toEqual([TestFeature.Dashboard, TestFeature.Messages]); + }; + + await runUpdatePlanMigration({ + ctx, + migrationClient: autumnV2_2, + migrationId: `${customerId}-mig-${Date.now()}`, + customerId, + noBillingChanges: true, + runOnServer: true, + filter: { customer: { plan: { plan_id: pro.id } } }, + operations: { + customer: [ + { + type: "update_plan", + plan_filter: { plan_id: pro.id }, + customize: { + add_items: [itemsV2.dashboard()], + }, + }, + ], + }, + waitFor: expectActivePlanUpdated, + timeoutMs: 60_000, + }); + await expectActivePlanUpdated(); + + const stripeScheduleAfter = + await ctx.stripeCli.subscriptionSchedules.retrieve(stripeScheduleId); + expect(stripeScheduleSignature(stripeScheduleAfter as Stripe.SubscriptionSchedule)).toEqual( + stripeSignatureBefore, + ); + expect( + await getCustomerProductRows({ + ctx, + customerId, + productId: premium.id, + status: CusProductStatus.Scheduled, + }), + ).toEqual([]); + await expectCustomerInvoiceCorrect({ + customer: await autumnV1.customers.get(customerId), + count: 1, + latestTotal: 20, + }); +}); diff --git a/server/tests/integration/billing/migrations-v2/update-plan-operation/utils/scheduledCustomerProductTestUtils.ts b/server/tests/integration/billing/migrations-v2/update-plan-operation/utils/scheduledCustomerProductTestUtils.ts new file mode 100644 index 000000000..054fc772d --- /dev/null +++ b/server/tests/integration/billing/migrations-v2/update-plan-operation/utils/scheduledCustomerProductTestUtils.ts @@ -0,0 +1,215 @@ +import { + CusProductStatus, + customerEntitlements, + customerPrices, + customerProducts, + customers, + prices, + products as productsTable, + schedulePhases, +} from "@autumn/shared"; +import type { initScenario } from "@tests/utils/testInitUtils/initScenario"; +import { and, eq, inArray, isNull } from "drizzle-orm"; + +export type MigrationTestCtx = Awaited>["ctx"]; + +export const getCustomerProductRows = async ({ + ctx, + customerId, + productId, + status, + entityId, +}: { + ctx: MigrationTestCtx; + customerId: string; + productId: string; + status?: CusProductStatus; + entityId?: string | null; +}) => + await ctx.db + .select({ + id: customerProducts.id, + status: customerProducts.status, + startsAt: customerProducts.starts_at, + scheduledIds: customerProducts.scheduled_ids, + isCustom: customerProducts.is_custom, + entityId: customerProducts.entity_id, + options: customerProducts.options, + version: productsTable.version, + }) + .from(customerProducts) + .innerJoin( + customers, + eq(customerProducts.internal_customer_id, customers.internal_id), + ) + .innerJoin( + productsTable, + eq(customerProducts.internal_product_id, productsTable.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), + status ? eq(customerProducts.status, status) : undefined, + entityId === undefined + ? undefined + : entityId === null + ? isNull(customerProducts.entity_id) + : eq(customerProducts.entity_id, entityId), + ), + ); + +export const getScheduledCustomerProductRow = async ({ + ctx, + customerId, + productId, + entityId, +}: { + ctx: MigrationTestCtx; + customerId: string; + productId: string; + entityId?: string | null; +}) => { + const rows = await getCustomerProductRows({ + ctx, + customerId, + productId, + status: CusProductStatus.Scheduled, + entityId, + }); + if (rows.length !== 1) { + throw new Error( + `Expected exactly one scheduled customer product for ${customerId}/${productId}, got ${rows.length}`, + ); + } + return rows[0]!; +}; + +export const getScheduledCustomerProductRows = async ({ + ctx, + customerId, + productId, +}: { + ctx: MigrationTestCtx; + customerId: string; + productId: string; +}) => + await getCustomerProductRows({ + ctx, + customerId, + productId, + status: CusProductStatus.Scheduled, + }); + +export const getCustomerProductFeatureIds = async ({ + ctx, + customerProductId, +}: { + ctx: MigrationTestCtx; + customerProductId: string; +}) => + ( + await ctx.db + .select({ featureId: customerEntitlements.feature_id }) + .from(customerEntitlements) + .where(eq(customerEntitlements.customer_product_id, customerProductId)) + ) + .map((row) => row.featureId) + .sort(); + +export const getCustomerProductBalances = async ({ + ctx, + customerProductId, +}: { + ctx: MigrationTestCtx; + customerProductId: string; +}) => + ( + await ctx.db + .select({ + featureId: customerEntitlements.feature_id, + balance: customerEntitlements.balance, + }) + .from(customerEntitlements) + .where(eq(customerEntitlements.customer_product_id, customerProductId)) + ).sort((a, b) => (a.featureId ?? "").localeCompare(b.featureId ?? "")); + +export const getCustomerProductPriceAmounts = async ({ + ctx, + customerProductId, +}: { + ctx: MigrationTestCtx; + customerProductId: string; +}) => + ( + await ctx.db + .select({ config: prices.config }) + .from(customerPrices) + .innerJoin(prices, eq(customerPrices.price_id, prices.id)) + .where(eq(customerPrices.customer_product_id, customerProductId)) + ) + .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); + +export const getPhaseCustomerProductIds = async ({ + ctx, + customerProductId, +}: { + ctx: MigrationTestCtx; + customerProductId: string; +}) => + ( + await ctx.db + .select({ customerProductIds: schedulePhases.customer_product_ids }) + .from(schedulePhases) + ) + .map((phase) => phase.customerProductIds) + .find((customerProductIds) => + customerProductIds.includes(customerProductId), + ); + +export const getRequiredStripeScheduleId = ({ + scheduledIds, +}: { + scheduledIds: string[] | null; +}) => { + const scheduleId = scheduledIds?.[0]; + if (!scheduleId) { + throw new Error("Expected customer product to have a Stripe schedule ID"); + } + return scheduleId; +}; + +export const deleteCustomerProductRows = async ({ + ctx, + customerProductIds, +}: { + ctx: MigrationTestCtx; + customerProductIds: string[]; +}) => { + if (customerProductIds.length === 0) return; + await ctx.db + .delete(customerProducts) + .where(inArray(customerProducts.id, customerProductIds)); +}; + +export const expectNoCustomerProductRow = async ({ + ctx, + customerProductId, +}: { + ctx: MigrationTestCtx; + customerProductId: string; +}) => { + const rows = await ctx.db + .select({ id: customerProducts.id }) + .from(customerProducts) + .where(eq(customerProducts.id, customerProductId)); + if (rows.length !== 0) { + throw new Error(`Expected customer product ${customerProductId} to be deleted`); + } +}; diff --git a/server/tests/integration/billing/migrations-v2/update-plan-operation/versioning/update-plan-op-scheduled-version.test.ts b/server/tests/integration/billing/migrations-v2/update-plan-operation/versioning/update-plan-op-scheduled-version.test.ts new file mode 100644 index 000000000..65477c7d9 --- /dev/null +++ b/server/tests/integration/billing/migrations-v2/update-plan-operation/versioning/update-plan-op-scheduled-version.test.ts @@ -0,0 +1,513 @@ +/** + * TDD coverage for `update_plan` version migrations targeting scheduled customer products. + * + * Contract under test: + * New behaviors: + * - Scheduled customer products are selected by customer and operation plan filters. + * - Scheduled version updates delete the old scheduled row and insert a replacement. + * - Entity-scoped scheduled rows are selected and replaced independently. + * - Explicit `plan_filter.custom: true` opts custom scheduled rows into version updates. + * - Active and scheduled rows for the same plan can be migrated together. + * Side effects: + * - Scheduled replacements do not leave expired scheduled rows. + * - Coupled migrations keep Stripe subscriptions/schedules consistent with Autumn. + */ + +import { expect, test } from "bun:test"; +import type { ApiCustomerV3 } from "@autumn/shared"; +import { CusProductStatus, ms } from "@autumn/shared"; +import { expectCustomerInvoiceCorrect } from "@tests/integration/billing/utils/expectCustomerInvoiceCorrect"; +import { + expectProductCanceling, + 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 type Stripe from "stripe"; +import { runUpdatePlanMigration } from "../../utils/runUpdatePlanMigration"; +import { + expectNoCustomerProductRow, + getCustomerProductFeatureIds, + getCustomerProductRows, + getPhaseCustomerProductIds, + getRequiredStripeScheduleId, + getScheduledCustomerProductRow, + getScheduledCustomerProductRows, +} from "../utils/scheduledCustomerProductTestUtils"; + +const stripeScheduleSignature = (schedule: Stripe.SubscriptionSchedule) => ({ + status: schedule.status, + currentPhase: schedule.current_phase, + phases: schedule.phases.map((phase) => ({ + startDate: phase.start_date, + endDate: phase.end_date, + items: phase.items.map((item) => ({ + price: typeof item.price === "string" ? item.price : item.price.id, + quantity: item.quantity, + })), + })), +}); + +test(`${chalk.yellowBright("migrations update_plan scheduled version: scheduled downgrade is selected and replaced")}`, async () => { + const customerId = "migration-update-scheduled-version"; + const pro = products.pro({ + id: "scheduled-version-pro", + items: [items.monthlyMessages({ includedUsage: 500 })], + }); + const premium = products.premium({ + id: "scheduled-version-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 beforeCustomer = await autumnV1.customers.get(customerId); + await expectProductCanceling({ customer: beforeCustomer, productId: premium.id }); + await expectProductScheduled({ customer: beforeCustomer, productId: pro.id }); + const invoiceCountBefore = beforeCustomer.invoices?.length ?? 0; + const scheduledBefore = await getScheduledCustomerProductRow({ + ctx, + customerId, + productId: pro.id, + }); + + await autumnV1.products.update(pro.id, { + items: [ + items.monthlyPrice({ price: 20 }), + items.monthlyMessages({ includedUsage: 600 }), + ], + }); + + const expectScheduledReplacement = async () => { + await expectNoCustomerProductRow({ + ctx, + customerProductId: scheduledBefore.id, + }); + const scheduledAfter = await getScheduledCustomerProductRow({ + ctx, + customerId, + productId: pro.id, + }); + expect(scheduledAfter.id).not.toBe(scheduledBefore.id); + expect(scheduledAfter.version).toBe(2); + expect(scheduledAfter.startsAt).toBe(scheduledBefore.startsAt); + expect(scheduledAfter.scheduledIds ?? []).toHaveLength(1); + }; + + await runUpdatePlanMigration({ + ctx, + migrationClient: autumnV2_2, + migrationId: `${customerId}-mig-${Date.now()}`, + customerId, + filter: { customer: { plan: { plan_id: pro.id } } }, + operations: { + customer: [ + { + type: "update_plan", + plan_filter: { plan_id: pro.id }, + version: 2, + }, + ], + }, + waitFor: expectScheduledReplacement, + runOnServer: false, + timeoutMs: 60_000, + }); + await expectScheduledReplacement(); + + const afterCustomer = await autumnV1.customers.get(customerId); + await expectProductCanceling({ customer: afterCustomer, productId: premium.id }); + await expectProductScheduled({ customer: afterCustomer, productId: pro.id }); + await expectCustomerInvoiceCorrect({ customer: afterCustomer, count: invoiceCountBefore }); + await expectNoExpiredCustomerProducts({ ctx, customerId, productId: pro.id }); + await expectStripeSubscriptionCorrect({ ctx, customerId }); +}); + +test(`${chalk.yellowBright("migrations update_plan scheduled version: entity-scoped scheduled rows are replaced")}`, async () => { + const customerId = "migration-update-scheduled-entity-version"; + const pro = products.pro({ + id: "scheduled-entity-pro", + items: [items.monthlyMessages({ includedUsage: 500 })], + }); + const premium = products.premium({ + id: "scheduled-entity-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.billing.attach({ productId: pro.id, entityIndex: 1 }), + ], + }); + + const scheduledBefore = await getScheduledCustomerProductRows({ + ctx, + customerId, + productId: pro.id, + }); + expect(scheduledBefore.map((row) => row.entityId).sort()).toEqual( + entities.map((entity) => entity.id).sort(), + ); + + await autumnV1.products.update(pro.id, { + items: [ + items.monthlyPrice({ price: 20 }), + items.monthlyMessages({ includedUsage: 700 }), + ], + }); + + await runUpdatePlanMigration({ + ctx, + migrationClient: autumnV2_2, + migrationId: `${customerId}-mig-${Date.now()}`, + customerId, + runOnServer: false, + filter: { customer: { plan: { plan_id: pro.id } } }, + operations: { + customer: [ + { + type: "update_plan", + plan_filter: { plan_id: pro.id }, + version: 2, + }, + ], + }, + }); + + for (const row of scheduledBefore) { + await expectNoCustomerProductRow({ ctx, customerProductId: row.id }); + const scheduledAfter = await getScheduledCustomerProductRow({ + ctx, + customerId, + productId: pro.id, + entityId: row.entityId, + }); + expect(scheduledAfter.id).not.toBe(row.id); + expect(scheduledAfter.version).toBe(2); + expect(await getCustomerProductFeatureIds({ ctx, customerProductId: scheduledAfter.id })).toEqual([ + TestFeature.Messages, + ]); + } + await expectNoExpiredCustomerProducts({ ctx, customerId, productId: pro.id }); + await expectStripeSubscriptionCorrect({ ctx, customerId }); +}); + +test(`${chalk.yellowBright("migrations update_plan scheduled version: custom scheduled plan can be explicitly updated")}`, async () => { + const customerId = "migration-update-scheduled-custom-override"; + const regular = products.base({ + id: "scheduled-custom-override-regular", + items: [ + items.monthlyPrice({ price: 10 }), + items.monthlyMessages({ includedUsage: 100 }), + ], + }); + const customFuture = products.base({ + id: "scheduled-custom-override-future", + items: [ + items.monthlyPrice({ price: 20 }), + items.monthlyMessages({ includedUsage: 100 }), + ], + }); + + const { autumnV1, autumnV2_2, ctx } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [regular, customFuture] }), + ], + actions: [], + }); + + const now = Date.now(); + await autumnV1.billing.createSchedule({ + customer_id: customerId, + phases: [ + { + starts_at: now, + plans: [{ plan_id: regular.id }], + }, + { + starts_at: now + ms.days(30), + plans: [ + { + plan_id: customFuture.id, + customize: { + price: itemsV2.monthlyPrice({ amount: 25 }), + items: [itemsV2.monthlyWords({ included: 250 })], + }, + }, + ], + }, + ], + }); + const scheduledBefore = await getScheduledCustomerProductRow({ + ctx, + customerId, + productId: customFuture.id, + }); + expect(scheduledBefore.isCustom).toBe(true); + expect(await getCustomerProductFeatureIds({ ctx, customerProductId: scheduledBefore.id })).toEqual([ + TestFeature.Words, + ]); + + await autumnV1.products.update(customFuture.id, { + items: [ + items.monthlyPrice({ price: 30 }), + items.monthlyMessages({ includedUsage: 500 }), + ], + }); + + await runUpdatePlanMigration({ + ctx, + migrationClient: autumnV2_2, + migrationId: `${customerId}-mig-${Date.now()}`, + customerId, + filter: { customer: { plan: { plan_id: customFuture.id } } }, + operations: { + customer: [ + { + type: "update_plan", + plan_filter: { plan_id: customFuture.id, custom: true }, + version: 2, + }, + ], + }, + runOnServer: false, + }); + + await expectNoCustomerProductRow({ ctx, customerProductId: scheduledBefore.id }); + const scheduledAfter = await getScheduledCustomerProductRow({ + ctx, + customerId, + productId: customFuture.id, + }); + expect(scheduledAfter.id).not.toBe(scheduledBefore.id); + expect(scheduledAfter.version).toBe(2); + expect(await getCustomerProductFeatureIds({ ctx, customerProductId: scheduledAfter.id })).toEqual([ + TestFeature.Messages, + ]); + await expectNoExpiredCustomerProducts({ ctx, customerId, productId: customFuture.id }); + await expectStripeSubscriptionCorrect({ ctx, customerId }); +}); + +test(`${chalk.yellowBright("migrations update_plan scheduled version: custom scheduled plan is skipped by default")}`, async () => { + const customerId = "migration-update-scheduled-custom-skip"; + const regular = products.base({ + id: "scheduled-custom-skip-regular", + items: [ + items.monthlyPrice({ price: 10 }), + items.monthlyMessages({ includedUsage: 100 }), + ], + }); + const customFuture = products.base({ + id: "scheduled-custom-skip-future", + items: [ + items.monthlyPrice({ price: 20 }), + items.monthlyMessages({ includedUsage: 100 }), + ], + }); + + const { autumnV1, autumnV2_2, ctx } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [regular, customFuture] }), + ], + actions: [], + }); + + const now = Date.now(); + await autumnV1.billing.createSchedule({ + customer_id: customerId, + phases: [ + { + starts_at: now, + plans: [{ plan_id: regular.id }], + }, + { + starts_at: now + ms.days(30), + plans: [ + { + plan_id: customFuture.id, + customize: { + items: [itemsV2.monthlyWords({ included: 250 })], + }, + }, + ], + }, + ], + }); + const scheduledBefore = await getScheduledCustomerProductRow({ + ctx, + customerId, + productId: customFuture.id, + }); + expect(scheduledBefore.isCustom).toBe(true); + expect(await getCustomerProductFeatureIds({ ctx, customerProductId: scheduledBefore.id })).toEqual([ + TestFeature.Words, + ]); + + await autumnV1.products.update(customFuture.id, { + items: [items.monthlyMessages({ includedUsage: 500 })], + }); + + await runUpdatePlanMigration({ + ctx, + migrationClient: autumnV2_2, + migrationId: `${customerId}-mig-${Date.now()}`, + customerId, + filter: { customer: { plan: { plan_id: customFuture.id } } }, + operations: { + customer: [ + { + type: "update_plan", + plan_filter: { plan_id: customFuture.id }, + version: 2, + }, + ], + }, + runOnServer: false, + }); + + const scheduledAfter = await getScheduledCustomerProductRow({ + ctx, + customerId, + productId: customFuture.id, + }); + expect(scheduledAfter.id).toBe(scheduledBefore.id); + expect(scheduledAfter.version).toBe(1); + expect(scheduledAfter.isCustom).toBe(true); + expect(await getCustomerProductFeatureIds({ ctx, customerProductId: scheduledAfter.id })).toEqual([ + TestFeature.Words, + ]); + await expectNoExpiredCustomerProducts({ + ctx, + customerId, + productId: customFuture.id, + }); + await expectStripeSubscriptionCorrect({ ctx, customerId }); +}); + +test(`${chalk.yellowBright("migrations update_plan scheduled version: mixed active and scheduled rows for same plan update together")}`, async () => { + const customerId = "migration-update-scheduled-mixed-same-plan"; + const plan = products.pro({ + id: "scheduled-mixed-pro", + items: [items.monthlyMessages({ includedUsage: 100 })], + }); + + const { autumnV1, autumnV2_2, ctx } = await initScenario({ + customerId, + setup: [s.customer({ paymentMethod: "success" }), s.products({ list: [plan] })], + actions: [], + }); + + const now = Date.now(); + await autumnV1.billing.createSchedule({ + customer_id: customerId, + phases: [ + { + starts_at: now, + plans: [{ plan_id: plan.id }], + }, + { + starts_at: now + ms.days(30), + plans: [{ plan_id: plan.id }], + }, + ], + }); + const activeBefore = await getCustomerProductRows({ + ctx, + customerId, + productId: plan.id, + status: CusProductStatus.Active, + }); + const scheduledBefore = await getScheduledCustomerProductRow({ + ctx, + customerId, + productId: plan.id, + }); + expect(activeBefore).toHaveLength(1); + const stripeScheduleId = getRequiredStripeScheduleId({ + scheduledIds: scheduledBefore.scheduledIds, + }); + const stripeScheduleBefore = + await ctx.stripeCli.subscriptionSchedules.retrieve(stripeScheduleId); + const stripeSignatureBefore = stripeScheduleSignature( + stripeScheduleBefore as Stripe.SubscriptionSchedule, + ); + + await autumnV1.products.update(plan.id, { + items: [items.monthlyMessages({ includedUsage: 250 })], + }); + + await runUpdatePlanMigration({ + ctx, + migrationClient: autumnV2_2, + migrationId: `${customerId}-mig-${Date.now()}`, + customerId, + noBillingChanges: true, + filter: { customer: { plan: { plan_id: plan.id, version: 1 } } }, + operations: { + customer: [ + { + type: "update_plan", + plan_filter: { plan_id: plan.id, version: 1 }, + version: 2, + }, + ], + }, + runOnServer: false, + }); + + await expectNoCustomerProductRow({ ctx, customerProductId: scheduledBefore.id }); + const activeAfter = await getCustomerProductRows({ + ctx, + customerId, + productId: plan.id, + status: CusProductStatus.Active, + }); + const scheduledAfter = await getScheduledCustomerProductRow({ + ctx, + customerId, + productId: plan.id, + }); + expect(activeAfter).toHaveLength(1); + expect(activeAfter[0]!.version).toBe(2); + expect(scheduledAfter.version).toBe(2); + expect( + await getPhaseCustomerProductIds({ + ctx, + customerProductId: scheduledAfter.id, + }), + ).toEqual([scheduledAfter.id]); + const stripeScheduleAfter = + await ctx.stripeCli.subscriptionSchedules.retrieve(stripeScheduleId); + expect(stripeScheduleSignature(stripeScheduleAfter as Stripe.SubscriptionSchedule)).toEqual( + stripeSignatureBefore, + ); +}); diff --git a/server/tests/unit/shared/pricesAreSame.test.ts b/server/tests/unit/shared/pricesAreSame.test.ts index e81a24323..60f9da23b 100644 --- a/server/tests/unit/shared/pricesAreSame.test.ts +++ b/server/tests/unit/shared/pricesAreSame.test.ts @@ -5,6 +5,7 @@ import { BillingInterval, BillWhen, Infinite, + PriceSchema, } from "@autumn/shared"; import { pricesAreSame } from "@shared/utils/productUtils/priceUtils/comparePrice/pricesAreSame"; @@ -17,15 +18,15 @@ const fixedPrice = { is_custom: false, entitlement_id: null, proration_config: null, - config: { - type: PriceType.Fixed, - amount: 10, - interval: BillingInterval.Month, - stripe_product_id: null, - feature_id: null, - internal_feature_id: null, - }, - } satisfies Price; + config: { + type: PriceType.Fixed, + amount: 10, + interval: BillingInterval.Month, + stripe_product_id: null, + feature_id: null, + internal_feature_id: null, + }, +} satisfies Price; const usagePrice = { id: "price_usage", @@ -48,6 +49,22 @@ const usagePrice = { } satisfies Price; describe("pricesAreSame", () => { + test("normalizes ignored fixed price metadata", () => { + const parsed = PriceSchema.parse({ + ...fixedPrice, + config: { + ...fixedPrice.config, + stripe_product_id: "prod_fixed", + feature_id: "base", + internal_feature_id: "internal_base", + }, + }); + + expect(parsed.config.stripe_product_id).toBeNull(); + expect(parsed.config.feature_id).toBeNull(); + expect(parsed.config.internal_feature_id).toBeNull(); + }); + test("returns false instead of throwing for fixed vs usage prices", () => { expect(pricesAreSame(fixedPrice, usagePrice)).toBe(false); }); diff --git a/shared/api/customers/utils/match/customerFilterMatchesFullCustomer.ts b/shared/api/customers/utils/match/customerFilterMatchesFullCustomer.ts index 7d4f36961..0914821e8 100644 --- a/shared/api/customers/utils/match/customerFilterMatchesFullCustomer.ts +++ b/shared/api/customers/utils/match/customerFilterMatchesFullCustomer.ts @@ -1,5 +1,5 @@ import type { FullCustomer } from "../../../../models/cusModels/fullCusModel.js"; -import { customerProductHasActiveStatus } from "../../../../utils/index.js"; +import { customerProductHasRelevantStatus } from "../../../../utils/index.js"; import type { CustomerFilter } from "../../../migrations/filters/customerFilter.js"; import { arrayFilterMatches, @@ -12,8 +12,8 @@ import { planFilterMatchesCustomerProduct } from "../../../products/utils/match/ * * JS-side mirror of the SQL compiler's `customerRegistry`. Used by the lazy * migration helper to skip non-matching customers without queueing work. - * Mirrors the `cp.status IN ACTIVE_STATUSES` ambient predicate baked into - * the SQL plan scope — non-active cusProducts are ignored. + * Mirrors the `cp.status IN RELEVANT_STATUSES` ambient predicate baked into + * the SQL plan scope — expired/paused cusProducts are ignored. * * Supports `customer_id` and `plan` (`$some` / `$every` / `$none` and the * implicit-`$some` bare form). `item` sugar throws to make the gap explicit, @@ -37,14 +37,14 @@ export const customerFilterMatchesFullCustomer = ({ } if (filter.plan !== undefined) { - const activeProducts = fullCustomer.customer_products.filter( - customerProductHasActiveStatus, + const relevantProducts = fullCustomer.customer_products.filter( + customerProductHasRelevantStatus, ); const planFilter = filter.plan === "$none" ? { $none: {} } : filter.plan; if ( !arrayFilterMatches({ filter: planFilter, - items: activeProducts, + items: relevantProducts, matchesElement: ({ filter: planFilter, item: customerProduct }) => planFilterMatchesCustomerProduct({ filter: planFilter, diff --git a/shared/api/migrations/compiler/registry/customerRegistry.ts b/shared/api/migrations/compiler/registry/customerRegistry.ts index f78f94e97..e7e2b52e1 100644 --- a/shared/api/migrations/compiler/registry/customerRegistry.ts +++ b/shared/api/migrations/compiler/registry/customerRegistry.ts @@ -1,4 +1,4 @@ -import { ACTIVE_STATUSES } from "../../../../utils/cusProductUtils/cusProductConstants.js"; +import { RELEVANT_STATUSES } from "../../../../utils/cusProductUtils/cusProductConstants.js"; import type { NavScope, RootScope } from "./registryTypes.js"; /** @@ -27,8 +27,8 @@ import type { NavScope, RootScope } from "./registryTypes.js"; * Ambient predicates push `org_id` / `env` down into every scope whose * table has those columns. Without this, multi-tenant scans bloat 10x+. * - * `cp.status IN ACTIVE_STATUSES` is also baked in — customer-rooted - * filters always operate on active plan instances. + * `cp.status IN RELEVANT_STATUSES` is also baked in — customer-rooted + * filters operate on active and scheduled plan instances. */ /** @@ -81,7 +81,7 @@ const planScope: NavScope = { ambient: [ { column: "cp.status", - source: { kind: "values", values: ACTIVE_STATUSES }, + source: { kind: "values", values: RELEVANT_STATUSES }, }, ], fields: { diff --git a/shared/api/migrations/filters/planFilter.ts b/shared/api/migrations/filters/planFilter.ts index 73bec80a5..30b48e56e 100644 --- a/shared/api/migrations/filters/planFilter.ts +++ b/shared/api/migrations/filters/planFilter.ts @@ -12,8 +12,8 @@ import { PlanItemFilterSchema } from "./planItemFilter.js"; * Filter over a plan. Migration-scoped: stable contract decoupled from * `ApiPlanV1`. * - * Customer-rooted filters automatically scope to active customer-product - * status (`cp.status IN ACTIVE_STATUSES`). + * Customer-rooted filters automatically scope to relevant customer-product + * status (`cp.status IN RELEVANT_STATUSES`). * * `price` is the plan's BASE price (customer_price linked to a price with * `entitlement_id IS NULL`). Use `price: null` for free plans, diff --git a/shared/models/billingModels/plan/autumnBillingPlan.ts b/shared/models/billingModels/plan/autumnBillingPlan.ts index 349d4230b..9e8415523 100644 --- a/shared/models/billingModels/plan/autumnBillingPlan.ts +++ b/shared/models/billingModels/plan/autumnBillingPlan.ts @@ -83,6 +83,17 @@ export const AutumnBillingPlanSchema = z.object({ }) .optional(), + schedulePhaseCustomerProductReplacements: z + .array( + z.object({ + oldCustomerProductId: z.string(), + newCustomerProductId: z.string(), + internalCustomerId: z.string(), + internalEntityId: z.string().nullish(), + }), + ) + .optional(), + deleteCustomerProduct: FullCusProductSchema.optional(), // Scheduled product to delete (e.g., when updating while canceling) deleteCustomerProducts: z.array(FullCusProductSchema).optional(), diff --git a/shared/models/productModels/priceModels/priceConfig/fixedPriceConfig.ts b/shared/models/productModels/priceModels/priceConfig/fixedPriceConfig.ts index 8a8e8236e..1c72fc7c7 100644 --- a/shared/models/productModels/priceModels/priceConfig/fixedPriceConfig.ts +++ b/shared/models/productModels/priceModels/priceConfig/fixedPriceConfig.ts @@ -2,6 +2,12 @@ import { z } from "zod/v4"; import { BillingInterval } from "../../intervals/billingInterval"; import { UsageTierSchema } from "./usagePriceConfig"; +/** Imported fixed prices may carry usage metadata; fixed configs ignore it. */ +const IgnoredFixedPriceMetadataSchema = z.preprocess( + (value) => (typeof value === "string" ? null : value), + z.null().or(z.undefined()), +); + export const FixedPriceConfigSchema = z.object({ type: z.string(), amount: z.number().min(0), @@ -13,9 +19,9 @@ export const FixedPriceConfigSchema = z.object({ usage_tiers: z.array(UsageTierSchema).nullish(), stripe_price_id: z.string().nullish(), stripe_empty_price_id: z.string().nullish(), - stripe_product_id: z.null().or(z.undefined()), - feature_id: z.null().or(z.undefined()), - internal_feature_id: z.null().or(z.undefined()), + stripe_product_id: IgnoredFixedPriceMetadataSchema, + feature_id: IgnoredFixedPriceMetadataSchema, + internal_feature_id: IgnoredFixedPriceMetadataSchema, }); export type FixedPriceConfig = z.infer; diff --git a/vite/src/hooks/queries/useMigrationFilterPreview.ts b/vite/src/hooks/queries/useMigrationFilterPreview.ts index 5d945cbdc..850e433ee 100644 --- a/vite/src/hooks/queries/useMigrationFilterPreview.ts +++ b/vite/src/hooks/queries/useMigrationFilterPreview.ts @@ -1,6 +1,7 @@ import type { CustomerFilter, CustomerWithProducts } from "@autumn/shared"; import { keepPreviousData, useQuery } from "@tanstack/react-query"; import { useMemo } from "react"; +import type { ExecutionStatus } from "@/views/migrations/migration/live/ExecutionStatusSubMenu"; import { useQueryKeyFactory } from "@/hooks/common/useQueryKeyFactory"; import { useAxiosInstance } from "@/services/useAxiosInstance"; @@ -19,24 +20,53 @@ export const useMigrationFilterPreview = ({ page = 0, pageSize = DEFAULT_PAGE_SIZE, migrationId, + executionStatuses = [], + migrationRunId, + migrationRunDryRun, }: { filter: CustomerFilter; search?: string; page?: number; pageSize?: number; migrationId?: string; + executionStatuses?: ExecutionStatus[]; + migrationRunId?: string; + migrationRunDryRun?: boolean; }) => { const axiosInstance = useAxiosInstance(); const buildKey = useQueryKeyFactory(); const filterKey = useMemo(() => JSON.stringify(filter), [filter]); - const queryKey = buildKey(["migration-filter-preview", filterKey, search, page, pageSize, migrationId]); + const executionKey = useMemo( + () => executionStatuses.slice().sort().join(","), + [executionStatuses], + ); + const queryKey = buildKey([ + "migration-filter-preview", + filterKey, + search, + page, + pageSize, + migrationId, + executionKey, + migrationRunId, + migrationRunDryRun, + ]); const query = useQuery({ queryKey, queryFn: async () => { const { data } = await axiosInstance.post( "/migrations.filter.preview", - { filter, search, page, pageSize, migrationId }, + { + filter, + search, + page, + pageSize, + migrationId, + executionStatuses, + migrationRunId, + migrationRunDryRun, + }, ); return data; }, diff --git a/vite/src/services/customers/CusService.tsx b/vite/src/services/customers/CusService.tsx index 075c459a8..2f13b709f 100644 --- a/vite/src/services/customers/CusService.tsx +++ b/vite/src/services/customers/CusService.tsx @@ -120,7 +120,7 @@ export class CusService { axios: AxiosInstance; customer_id: string; }): Promise<{ success: boolean }> { - const { data } = await axios.post(`/customers/clear_cache`, { + const { data } = await axios.post(`/v1/customers/clear_cache`, { customer_id, }); return data; diff --git a/vite/src/views/customers2/components/table/customer-products/CustomerProductsColumns.tsx b/vite/src/views/customers2/components/table/customer-products/CustomerProductsColumns.tsx index ef8586d75..7bf9dc970 100644 --- a/vite/src/views/customers2/components/table/customer-products/CustomerProductsColumns.tsx +++ b/vite/src/views/customers2/components/table/customer-products/CustomerProductsColumns.tsx @@ -1,7 +1,7 @@ import { type FullCusProduct, isCustomerProductTrialing } from "@autumn/shared"; import { FlaskIcon, PencilIcon } from "@phosphor-icons/react"; import type { Row, Table } from "@tanstack/react-table"; -import { ArrowRightLeft, Delete, RotateCcw } from "lucide-react"; +import { ArrowRightLeft, Delete, RotateCcw, Send } from "lucide-react"; import { TableDropdownMenuCell } from "@/components/general/table/table-dropdown-menu-cell"; import { dateSkeleton, @@ -9,12 +9,110 @@ import { statusSkeleton, } from "@/components/general/table/table-skeleton-presets"; import { DropdownMenuItem } from "@/components/v2/dropdowns/DropdownMenu"; +import { useAdmin } from "@/views/admin/hooks/useAdmin"; import { createDateTimeColumn } from "@/views/customers2/utils/ColumnHelpers"; import { AdminHover } from "../../../../../components/general/AdminHover"; import { getCusProductHoverTexts } from "../../../../admin/adminUtils"; import { CustomerProductPrice } from "./CustomerProductPrice"; import { CustomerProductsStatus } from "./CustomerProductsStatus"; +function CustomerProductActionsCell({ + row, + table, +}: { + row: Row; + table: Table; +}) { + const { isAdmin } = useAdmin(); + const meta = table.options.meta as { + onCancelClick?: (product: FullCusProduct) => void; + onUpdateClick?: (product: FullCusProduct) => void; + onUncancelClick?: (product: FullCusProduct) => void; + onTransferClick?: (product: FullCusProduct) => void; + onTestSheetClick?: (product: FullCusProduct) => void; + onSendWebhookClick?: (product: FullCusProduct) => void; + hasEntities?: boolean; + sendingWebhookProductId?: string | null; + }; + + if (!meta?.onCancelClick) return null; + + const isCanceling = row.original.canceled; + + return ( +
+ + {meta.onTestSheetClick && ( + { + e.stopPropagation(); + meta.onTestSheetClick?.(row.original); + }} + > + Test Sheet + + )} + {meta.hasEntities && meta.onTransferClick && ( + { + e.stopPropagation(); + meta.onTransferClick?.(row.original); + }} + > + Transfer + + )} + {meta.onUpdateClick && ( + { + e.stopPropagation(); + meta.onUpdateClick?.(row.original); + }} + > + Update + + )} + {isAdmin && meta.onSendWebhookClick && ( + { + e.stopPropagation(); + meta.onSendWebhookClick?.(row.original); + }} + > + Send CP updated webhook + + )} + {isCanceling ? ( + { + e.stopPropagation(); + meta.onUncancelClick?.(row.original); + }} + > + Uncancel + + ) : ( + { + e.stopPropagation(); + meta.onCancelClick?.(row.original); + }} + > + Cancel + + )} + +
+ ); +} + export const CustomerProductsColumns = [ { header: "Name", @@ -82,86 +180,6 @@ export const CustomerProductsColumns = [ header: "", size: 40, meta: { skeleton: hiddenSkeleton }, - cell: ({ - row, - table, - }: { - row: Row; - table: Table; - }) => { - const meta = table.options.meta as { - onCancelClick?: (product: FullCusProduct) => void; - onUpdateClick?: (product: FullCusProduct) => void; - onUncancelClick?: (product: FullCusProduct) => void; - onTransferClick?: (product: FullCusProduct) => void; - onTestSheetClick?: (product: FullCusProduct) => void; - hasEntities?: boolean; - }; - - if (!meta?.onCancelClick) return null; - - const isCanceling = row.original.canceled; - - return ( -
- - {meta.onTestSheetClick && ( - { - e.stopPropagation(); - meta.onTestSheetClick?.(row.original); - }} - > - Test Sheet - - )} - {meta.hasEntities && meta.onTransferClick && ( - { - e.stopPropagation(); - meta.onTransferClick?.(row.original); - }} - > - Transfer - - )} - {meta.onUpdateClick && ( - { - e.stopPropagation(); - meta.onUpdateClick?.(row.original); - }} - > - Update - - )} - {isCanceling ? ( - { - e.stopPropagation(); - meta.onUncancelClick?.(row.original); - }} - > - Uncancel - - ) : ( - { - e.stopPropagation(); - meta.onCancelClick?.(row.original); - }} - > - Cancel - - )} - -
- ); - }, + cell: CustomerProductActionsCell, }, ]; diff --git a/vite/src/views/customers2/components/table/customer-products/CustomerProductsTable.tsx b/vite/src/views/customers2/components/table/customer-products/CustomerProductsTable.tsx index bb3b9c4e4..d647ab207 100644 --- a/vite/src/views/customers2/components/table/customer-products/CustomerProductsTable.tsx +++ b/vite/src/views/customers2/components/table/customer-products/CustomerProductsTable.tsx @@ -2,6 +2,7 @@ import { AppEnv, type Entity, type FullCusProduct } from "@autumn/shared"; import { ArrowSquareOutIcon, PackageIcon } from "@phosphor-icons/react"; import type { Row } from "@tanstack/react-table"; import { useMemo, useState } from "react"; +import { toast } from "sonner"; import { Table } from "@/components/general/table"; import { SectionTag } from "@/components/v2/badges/SectionTag"; @@ -9,7 +10,10 @@ import { Button } from "@/components/v2/buttons/Button"; import { IconButton } from "@/components/v2/buttons/IconButton"; import { useSheetStore } from "@/hooks/stores/useSheetStore"; import { useEntity } from "@/hooks/stores/useSubscriptionStore"; +import { useAxiosInstance } from "@/services/useAxiosInstance"; import { useEnv } from "@/utils/envUtils"; +import { getBackendErr } from "@/utils/genUtils"; +import { useAdmin } from "@/views/admin/hooks/useAdmin"; import { useFullCusSearchQuery } from "@/views/customers/hooks/useFullCusSearchQuery"; import { useSavedViewsQuery } from "@/views/customers/hooks/useSavedViewsQuery"; import { useCustomerProductsData } from "@/views/customers2/hooks/useCustomerProductsData"; @@ -81,6 +85,11 @@ export function CustomerProductsTable() { ); const selectedItemId = useSheetStore((s) => s.itemId); const setSheet = useSheetStore((s) => s.setSheet); + const axiosInstance = useAxiosInstance(); + const { isAdmin } = useAdmin(); + const [sendingWebhookProductId, setSendingWebhookProductId] = useState< + string | null + >(null); useSavedViewsQuery(); useFullCusSearchQuery(); @@ -124,6 +133,21 @@ export function CustomerProductsTable() { setSheet({ type: "subscription-update", itemId: product.id }); }; + const handleSendWebhookClick = async (product: FullCusProduct) => { + if (!isAdmin) return; + setSendingWebhookProductId(product.id); + try { + await axiosInstance.post( + `/admin/customer-products/${product.id}/send-updated-webhook`, + ); + toast.success("Customer product webhook sent"); + } catch (error) { + toast.error(getBackendErr(error, "Failed to send webhook")); + } finally { + setSendingWebhookProductId(null); + } + }; + const handleRowClick = (cusProduct: FullCusProduct) => { setSheet({ type: "subscription-detail", @@ -136,7 +160,9 @@ export function CustomerProductsTable() { onUncancelClick: handleUncancelClick, onTransferClick: handleTransferClick, onUpdateClick: handleUpdateClick, + onSendWebhookClick: handleSendWebhookClick, hasEntities, + sendingWebhookProductId, nowMs: testClockFrozenTimeMs, }; diff --git a/vite/src/views/migrations/migration/live/EventResultDetail.tsx b/vite/src/views/migrations/migration/live/EventResultDetail.tsx index df36334c1..e3b6c0881 100644 --- a/vite/src/views/migrations/migration/live/EventResultDetail.tsx +++ b/vite/src/views/migrations/migration/live/EventResultDetail.tsx @@ -35,6 +35,12 @@ type MigrationPreview = { balance_changes?: (string | BalanceChange)[]; flag_changes?: (string | FlagChange)[]; }; +type ErrorPayload = { + message?: unknown; + error?: unknown; + code?: unknown; + path?: unknown; +}; function parseJson(raw: string | T): T | null { if (typeof raw !== "string") return raw; @@ -51,6 +57,30 @@ function parseList(raw: (string | T)[] | undefined): T[] { .filter((c): c is T => c !== null); } +function formatUnknownError(value: unknown): string | null { + if (value === null || value === undefined) return null; + if (typeof value === "string") return value; + if (typeof value === "number" || typeof value === "boolean") + return String(value); + if (Array.isArray(value)) + return value.map(formatUnknownError).filter(Boolean).join("\n"); + + if (typeof value === "object") { + const payload = value as ErrorPayload; + const message = formatUnknownError(payload.message ?? payload.error); + const prefix = [payload.code, payload.path].filter(Boolean).join(" "); + if (message) return prefix ? `${prefix}: ${message}` : message; + + try { + return JSON.stringify(value, null, 2); + } catch { + return "Unknown error"; + } + } + + return "Unknown error"; +} + const DOT_COLORS: Record = { activated: "bg-green-500", scheduled: "bg-blue-500", @@ -308,13 +338,16 @@ export function EventResultDetail({ event }: { event: MigrationItemEvent }) { if (!response) return null; if (event.status === "failed") { - const error = response.error as { message?: string } | undefined; - if (!error?.message) return null; + const error = response.error as ErrorPayload | undefined; + const message = formatUnknownError(error?.message ?? error); + if (!message) return null; return ( -
- - {error.message} -
+
+ + + {message} + +
); } @@ -322,9 +355,9 @@ export function EventResultDetail({ event }: { event: MigrationItemEvent }) { if (preview) return ; if (event.status === "skipped") { - const skipped = response.skipped as { reason?: string } | undefined; - const guard = response.guard as { reason?: string } | undefined; - const reason = skipped?.reason ?? guard?.reason; + const skipped = response.skipped as { reason?: unknown } | undefined; + const guard = response.guard as { reason?: unknown } | undefined; + const reason = formatUnknownError(skipped?.reason ?? guard?.reason); if (reason) return {reason}; } diff --git a/vite/src/views/migrations/migration/live/MigrationLiveView.tsx b/vite/src/views/migrations/migration/live/MigrationLiveView.tsx index 16438647a..7887a0eea 100644 --- a/vite/src/views/migrations/migration/live/MigrationLiveView.tsx +++ b/vite/src/views/migrations/migration/live/MigrationLiveView.tsx @@ -123,6 +123,7 @@ const statusColumn: ColumnDef = { status={event.status} dryRun={event.dry_run} response={event.response} + timestamp={event.timestamp} /> ); @@ -223,6 +224,22 @@ export function MigrationLiveView({ [debouncedSetSearch], ); + const handleExecutionStatusesChange = useCallback( + (statuses: ExecutionStatus[]) => { + setExecutionStatuses(statuses); + setPagination((p) => ({ ...p, pageIndex: 0 })); + }, + [], + ); + + const { + itemEvents, + runs, + invalidate: invalidateRuns, + } = useMigrationRunsQuery({ migrationId }); + + const latestRun = runs[0]; + const { customers, count, @@ -233,14 +250,9 @@ export function MigrationLiveView({ page: pagination.pageIndex, pageSize: pagination.pageSize, migrationId, + executionStatuses, }); - const { - itemEvents, - runs, - invalidate: invalidateRuns, - } = useMigrationRunsQuery({ migrationId }); - const { subscriptions: realtimeSubscriptions, hasActive: hasRealtimeActive, @@ -254,7 +266,7 @@ export function MigrationLiveView({ ); const eventsByCustomer = useMemo( - () => buildEventsByCustomer(itemEvents), + () => buildEventsByCustomer(itemEvents.filter((event) => !event.dry_run)), [itemEvents], ); @@ -305,20 +317,13 @@ export function MigrationLiveView({ ); const filteredCustomers = useMemo(() => { - const hasExecution = executionStatuses.length > 0; const hasStatus = customerFilters.status.length > 0; const hasVersion = customerFilters.version.length > 0; const hasProcessor = customerFilters.processor.length > 0; const hasNone = customerFilters.none; - if (!hasExecution && !hasStatus && !hasVersion && !hasProcessor && !hasNone) + if (!hasStatus && !hasVersion && !hasProcessor && !hasNone) return enrichedCustomers; return enrichedCustomers.filter((c) => { - if (hasExecution) { - const status = c._event?.status; - if (!status && !executionStatuses.includes("not_run")) return false; - if (status && !executionStatuses.includes(status as ExecutionStatus)) - return false; - } const cusProducts = c.customer_products ?? []; if (hasNone && cusProducts.length === 0) return true; if (hasStatus) { @@ -348,7 +353,7 @@ export function MigrationLiveView({ } return true; }); - }, [enrichedCustomers, executionStatuses, customerFilters]); + }, [enrichedCustomers, customerFilters]); const pageCount = count !== null ? Math.max(Math.ceil(count / pagination.pageSize), 1) : 1; @@ -368,7 +373,6 @@ export function MigrationLiveView({ const canPrev = pagination.pageIndex > 0; const canNext = count !== null && currentPage < pageCount; - const latestRun = runs[0]; const latestFailedRun = latestRun?.status === "failed" && latestRun.error_message ? latestRun @@ -701,11 +705,11 @@ export function MigrationLiveView({ extraMenuItems={ } hasActiveExtraFilters={hasActiveExecutionFilters(executionStatuses)} - onClearExtra={() => setExecutionStatuses([])} + onClearExtra={() => handleExecutionStatusesChange([])} hideSavedViews />
@@ -846,23 +850,56 @@ function SampleCustomerPicker({ c.email?.toLowerCase().includes(q), ); }, [customers, search]); + const selectedIdSet = new Set(selectedIds); + const filteredIds = filtered.map((c) => c.id ?? c.internal_id); + const allFilteredSelected = + filteredIds.length > 0 && filteredIds.every((id) => selectedIdSet.has(id)); const toggle = (id: string) => { - onChange( - selectedIds.includes(id) - ? selectedIds.filter((v) => v !== id) - : [...selectedIds, id], - ); + const nextIds = new Set(selectedIds); + if (nextIds.has(id)) { + nextIds.delete(id); + } else { + nextIds.add(id); + } + onChange(Array.from(nextIds)); + }; + + const toggleFiltered = () => { + const filteredIdSet = new Set(filteredIds); + if (allFilteredSelected) { + onChange(selectedIds.filter((id) => !filteredIdSet.has(id))); + return; + } + + const nextIds = new Set(selectedIds); + for (const id of filteredIds) nextIds.add(id); + onChange(Array.from(nextIds)); }; return (
- setSearch(e.target.value)} - placeholder="Search customers..." - className="text-sm" - /> +
+ setSearch(e.target.value)} + placeholder="Search customers..." + className="text-sm" + /> + +
{filtered.length === 0 ? (
@@ -870,7 +907,7 @@ function SampleCustomerPicker({
) : ( filtered.map((c) => { - const isSelected = selectedIds.includes(c.id ?? c.internal_id); + const isSelected = selectedIdSet.has(c.id ?? c.internal_id); return (