affect scheduled customer products

This commit is contained in:
johnyeo
2026-05-29 14:40:20 +01:00
committed by Charlie Lamb
parent a8e22d05a9
commit 2542147add
41 changed files with 2653 additions and 209 deletions

View File

@@ -1,4 +1,5 @@
import path from "node:path"; import path from "node:path";
import { createTinybirdApi } from "@tinybirdco/sdk";
type ProfileName = "dev" | "prod" | "prod-legacy"; type ProfileName = "dev" | "prod" | "prod-legacy";
type TinybirdTarget = "new" | "legacy"; type TinybirdTarget = "new" | "legacy";
@@ -35,6 +36,7 @@ const usage = `Usage:
bun tb info bun tb info
bun tb deploy:check bun tb deploy:check
bun tb deploy bun tb deploy
bun tb token:read <token_name>
bun tb:prod <tinybird command...> bun tb:prod <tinybird command...>
bun tb:prod-legacy <tinybird command...> bun tb:prod-legacy <tinybird command...>
@@ -70,15 +72,58 @@ const requireEnv = (name: string) => {
return value; 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[]) => { const resolveTinybirdArgs = (args: string[]) => {
if (args.length === 0 || args[0] === "--help" || args[0] === "-h") { if (args.length === 0 || args[0] === "--help" || args[0] === "-h") {
console.log(usage); console.log(usage);
process.exit(args.length === 0 ? 1 : 0); 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 <token_name>");
process.exit(1);
}
return args;
}
return commandAliases[args[0]] ?? 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 executeTinybird = async () => {
const target = requireEnv("AUTUMN_TINYBIRD_TARGET") as TinybirdTarget; const target = requireEnv("AUTUMN_TINYBIRD_TARGET") as TinybirdTarget;
const env = { ...process.env }; const env = { ...process.env };
@@ -92,6 +137,11 @@ const executeTinybird = async () => {
} }
const args = resolveTinybirdArgs(Bun.argv.slice(2)); 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], { const exitCode = await run(["bunx", "tinybird", ...args], {
cwd: serverDir, cwd: serverDir,
env, env,

View File

@@ -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<typeof initDrizzle>["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<string, unknown>)["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<typeof initDrizzle>["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();

View File

@@ -32,6 +32,7 @@ import { handleGetOrgMember } from "./handleGetOrgMember";
import { handleListAdminOrgs } from "./handleListAdminOrgs"; import { handleListAdminOrgs } from "./handleListAdminOrgs";
import { handleListAdminUsers } from "./handleListAdminUsers"; import { handleListAdminUsers } from "./handleListAdminUsers";
import { handleListOAuthClients } from "./handleListOAuthClients"; import { handleListOAuthClients } from "./handleListOAuthClients";
import { handleSendCustomerProductUpdatedWebhook } from "./handleSendCustomerProductUpdatedWebhook";
import { handleUpsertAdminCustomerBlockConfig } from "./handleUpsertAdminCustomerBlockConfig"; import { handleUpsertAdminCustomerBlockConfig } from "./handleUpsertAdminCustomerBlockConfig";
import { handleUpsertAdminFeatureFlagsConfig } from "./handleUpsertAdminFeatureFlagsConfig"; import { handleUpsertAdminFeatureFlagsConfig } from "./handleUpsertAdminFeatureFlagsConfig";
import { handleUpsertAdminFullSubjectGateConfig } from "./handleUpsertAdminFullSubjectGateConfig"; import { handleUpsertAdminFullSubjectGateConfig } from "./handleUpsertAdminFullSubjectGateConfig";
@@ -160,6 +161,10 @@ honoAdminRouter.get("/org-member", ...handleGetOrgMember);
honoAdminRouter.get("/master-stripe-account", ...handleGetMasterStripeAccount); honoAdminRouter.get("/master-stripe-account", ...handleGetMasterStripeAccount);
honoAdminRouter.get("/oauth-clients", ...handleListOAuthClients); honoAdminRouter.get("/oauth-clients", ...handleListOAuthClients);
honoAdminRouter.post("/invoice-line-items", ...handleGetInvoiceLineItems); honoAdminRouter.post("/invoice-line-items", ...handleGetInvoiceLineItems);
honoAdminRouter.post(
"/customer-products/:customer_product_id/send-updated-webhook",
...handleSendCustomerProductUpdatedWebhook,
);
honoAdminRouter.get("/rollouts", ...handleGetRollouts); honoAdminRouter.get("/rollouts", ...handleGetRollouts);
honoAdminRouter.put("/rollouts/:rollout_id", ...handleUpdateRollout); honoAdminRouter.put("/rollouts/:rollout_id", ...handleUpdateRollout);

View File

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

View File

@@ -34,6 +34,9 @@ export const computeScheduledCustomerProducts = ({
endsAt: phaseContext.endsAt, endsAt: phaseContext.endsAt,
currentEpochMs: billingContext.currentEpochMs, currentEpochMs: billingContext.currentEpochMs,
externalId: productContext.externalId, externalId: productContext.externalId,
isCustom:
productContext.customPrices.length > 0 ||
productContext.customEntitlements.length > 0,
}); });
insertCustomerProducts.push(customerProduct); insertCustomerProducts.push(customerProduct);
phaseCustomerProductIds.push(customerProduct.id); phaseCustomerProductIds.push(customerProduct.id);

View File

@@ -9,6 +9,7 @@ import { computeDeleteCustomerProduct } from "@/internal/billing/v2/actions/upda
import { computeCustomPlanNewCustomerProduct } from "@/internal/billing/v2/actions/updateSubscription/compute/customPlan/computeCustomPlanNewCustomerProduct"; import { computeCustomPlanNewCustomerProduct } from "@/internal/billing/v2/actions/updateSubscription/compute/customPlan/computeCustomPlanNewCustomerProduct";
import { buildAutumnLineItems } from "@/internal/billing/v2/compute/computeAutumnUtils/buildAutumnLineItems"; import { buildAutumnLineItems } from "@/internal/billing/v2/compute/computeAutumnUtils/buildAutumnLineItems";
import { computePatchCustomerProductPlan } from "@/internal/billing/v2/compute/computePatchPlan"; 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"; import { applyOneOffPrepaidCarryOvers } from "@/internal/billing/v2/utils/handleOneOffPrepaidCarryOvers/applyOneOffPrepaidCarryOvers";
export const computeCustomPlan = async ({ export const computeCustomPlan = async ({
@@ -57,6 +58,8 @@ export const computeCustomPlan = async ({
newCustomerProduct: newFullCustomerProduct, newCustomerProduct: newFullCustomerProduct,
fullCustomer, fullCustomer,
}); });
const isUpdatingScheduledProduct =
customerProduct.status === CusProductStatus.Scheduled;
const { allLineItems } = buildAutumnLineItems({ const { allLineItems } = buildAutumnLineItems({
ctx, ctx,
@@ -77,13 +80,21 @@ export const computeCustomPlan = async ({
return { return {
customerId: fullCustomer?.id ?? "", customerId: fullCustomer?.id ?? "",
insertCustomerProducts: [newFullCustomerProduct], insertCustomerProducts: [newFullCustomerProduct],
updateCustomerProduct: { updateCustomerProduct: isUpdatingScheduledProduct
? undefined
: {
customerProduct, customerProduct,
updates: { updates: {
status: CusProductStatus.Expired, status: CusProductStatus.Expired,
}, },
}, },
deleteCustomerProduct, deleteCustomerProduct: isUpdatingScheduledProduct
? customerProduct
: deleteCustomerProduct,
schedulePhaseCustomerProductReplacements: computeSchedulePhaseReplacements({
oldCustomerProduct: customerProduct,
newCustomerProduct: newFullCustomerProduct,
}),
customPrices, customPrices,
customEntitlements: [ customEntitlements: [
...(customEnts ?? []), ...(customEnts ?? []),

View File

@@ -81,7 +81,8 @@ export const computeCustomPlanNewCustomerProduct = ({
initOptions: { initOptions: {
isCustom: updateSubscriptionContext.isCustom, isCustom: updateSubscriptionContext.isCustom,
subscriptionId: stripeSubscription?.id, // don't populate if it's starting in the future. 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, externalId: currentCustomerProduct.external_id ?? undefined,
startsAt: currentCustomerProduct.starts_at ?? undefined, startsAt: currentCustomerProduct.starts_at ?? undefined,
...cancelFields, ...cancelFields,

View File

@@ -8,6 +8,7 @@ import {
} from "@autumn/shared"; } from "@autumn/shared";
import type { AutumnContext } from "@/honoUtils/HonoEnv"; import type { AutumnContext } from "@/honoUtils/HonoEnv";
import { buildAutumnLineItems } from "@/internal/billing/v2/compute/computeAutumnUtils/buildAutumnLineItems"; 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"; import { initPatchCustomerProduct } from "@/internal/billing/v2/utils/initFullCustomerProduct/initPatchedCustomerProduct";
export const computePatchCustomerProductPlan = ({ export const computePatchCustomerProductPlan = ({
@@ -58,10 +59,15 @@ export const computePatchCustomerProductPlan = ({
} satisfies Partial<AutumnBillingPlan>; } satisfies Partial<AutumnBillingPlan>;
if (patchContext.mode === "new") { if (patchContext.mode === "new") {
const isUpdatingScheduledProduct =
patchContext.originalCustomerProduct.status === CusProductStatus.Scheduled;
return { return {
...basePlan, ...basePlan,
insertCustomerProducts: [finalCustomerProduct], insertCustomerProducts: [finalCustomerProduct],
updateCustomerProduct: { updateCustomerProduct: isUpdatingScheduledProduct
? undefined
: {
customerProduct: patchContext.originalCustomerProduct, customerProduct: patchContext.originalCustomerProduct,
updates: { updates: {
status: CusProductStatus.Expired, status: CusProductStatus.Expired,
@@ -70,6 +76,14 @@ export const computePatchCustomerProductPlan = ({
canceled_at: Date.now(), canceled_at: Date.now(),
}, },
}, },
deleteCustomerProduct: isUpdatingScheduledProduct
? patchContext.originalCustomerProduct
: undefined,
schedulePhaseCustomerProductReplacements:
computeSchedulePhaseReplacements({
oldCustomerProduct: patchContext.originalCustomerProduct,
newCustomerProduct: finalCustomerProduct,
}),
} satisfies AutumnBillingPlan; } satisfies AutumnBillingPlan;
} }

View File

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

View File

@@ -11,6 +11,7 @@ import {
} from "@/internal/billing/v2/utils/billingPlan/customerProductPlanMutations"; } from "@/internal/billing/v2/utils/billingPlan/customerProductPlanMutations";
import { CusProductService } from "@/internal/customers/cusProducts/CusProductService"; import { CusProductService } from "@/internal/customers/cusProducts/CusProductService";
import { CusEntService } from "@/internal/customers/cusProducts/cusEnts/CusEntitlementService"; import { CusEntService } from "@/internal/customers/cusProducts/cusEnts/CusEntitlementService";
import { replaceScheduledPhaseCustomerProductIds } from "@/internal/customers/schedules/repos/replaceScheduledPhaseCustomerProductIds";
import { invoiceActions } from "@/internal/invoices/actions"; import { invoiceActions } from "@/internal/invoices/actions";
import { EntitlementService } from "@/internal/products/entitlements/EntitlementService"; import { EntitlementService } from "@/internal/products/entitlements/EntitlementService";
import { FreeTrialService } from "@/internal/products/free-trials/FreeTrialService"; import { FreeTrialService } from "@/internal/products/free-trials/FreeTrialService";
@@ -92,6 +93,11 @@ export const executeAutumnBillingPlan = async ({
newCusProducts: insertCustomerProducts, newCusProducts: insertCustomerProducts,
}); });
await replaceScheduledPhaseCustomerProductIds({
ctx,
replacements: autumnBillingPlan.schedulePhaseCustomerProductReplacements,
});
// 3. Update customer product (DB only) // 3. Update customer product (DB only)
for (const { customerProduct, updates } of updateCustomerProducts) { for (const { customerProduct, updates } of updateCustomerProducts) {
// Skip empty updates — drizzle throws "No values to set" on empty SET. // Skip empty updates — drizzle throws "No values to set" on empty SET.

View File

@@ -11,6 +11,33 @@ import { toCustomerPlanSnapshot } from "./toCustomerPlanSnapshot";
const getChangePlanId = (change: CustomerPlanChange): string | undefined => const getChangePlanId = (change: CustomerPlanChange): string | undefined =>
change.subscription?.plan_id ?? change.purchase?.plan_id; 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 * When a billing action updates a plan in-place, Autumn often creates a new
* customer product (insertCustomerProducts) and expires the old one * customer product (insertCustomerProducts) and expires the old one
@@ -79,15 +106,15 @@ const mergeUpdatedPlanChanges = (
const result: CustomerPlanChange[] = []; const result: CustomerPlanChange[] = [];
for (const change of changes) { for (const change of changes) {
const planId = getChangePlanId(change); const mergeKey = getUpdatedChangeMergeKey(change);
if (change.action !== "updated" || !planId) { if (change.action !== "updated" || !mergeKey) {
result.push(change); result.push(change);
continue; continue;
} }
const existing = merged.get(planId); const existing = merged.get(mergeKey);
if (!existing) { if (!existing) {
merged.set(planId, change); merged.set(mergeKey, change);
result.push(change); result.push(change);
continue; continue;
} }

View File

@@ -29,6 +29,7 @@ export const initScheduledCustomerProduct = ({
currentEpochMs, currentEpochMs,
accessStartsAt, accessStartsAt,
externalId, externalId,
isCustom,
subscriptionId, subscriptionId,
subscriptionScheduleId, subscriptionScheduleId,
internalEntityId, internalEntityId,
@@ -44,6 +45,7 @@ export const initScheduledCustomerProduct = ({
accessStartsAt?: number; accessStartsAt?: number;
/** Customer-facing Autumn subscription API id, stored on customer_products.external_id. */ /** Customer-facing Autumn subscription API id, stored on customer_products.external_id. */
externalId?: string; externalId?: string;
isCustom?: boolean;
/** When syncing from an existing Stripe sub/schedule, link the resulting /** When syncing from an existing Stripe sub/schedule, link the resulting
* scheduled cusProduct back to it so the customer-products view shows the * scheduled cusProduct back to it so the customer-products view shows the
* Stripe linkage and downstream actions (cancel, restore) can find it. */ * Stripe linkage and downstream actions (cancel, restore) can find it. */
@@ -75,6 +77,7 @@ export const initScheduledCustomerProduct = ({
status: accessStartsAt === undefined ? CusProductStatus.Scheduled : undefined, status: accessStartsAt === undefined ? CusProductStatus.Scheduled : undefined,
accessStartsAt, accessStartsAt,
externalId, externalId,
isCustom,
subscriptionId, subscriptionId,
subscriptionScheduleId, subscriptionScheduleId,
internalEntityId, internalEntityId,

View File

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

View File

@@ -6,6 +6,15 @@ import { rawWithParamsToDrizzle } from "../rawWithParamsToDrizzle.js";
export type IncludeProcessed = { export type IncludeProcessed = {
migrationInternalId: string; migrationInternalId: string;
executionFilter?: CustomerExecutionStatusFilter;
};
export type CustomerExecutionStatus = MigrationItemRunStatus | "not_run";
export type CustomerExecutionStatusFilter = {
statuses: CustomerExecutionStatus[];
migrationRunId?: string;
dryRun?: boolean;
}; };
export type CustomerQueryArgs = { export type CustomerQueryArgs = {
@@ -75,21 +84,103 @@ const buildProcessedIn = (includeProcessed: IncludeProcessed): SQL => sql`
AND mir.dry_run = false 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). // Predicates shared by both UNION branches (and the single-branch query).
// Rebuilt per call so a branch never reuses another's SQL chunk instance. // Rebuilt per call so a branch never reuses another's SQL chunk instance.
const buildCommonWhere = ({ const buildCommonWhere = ({
checkpoint, checkpoint,
search, search,
afterInternalId, afterInternalId,
includeProcessed,
includeNotRun,
}: { }: {
checkpoint?: CustomerCheckpointExclusion; checkpoint?: CustomerCheckpointExclusion;
search?: string; search?: string;
afterInternalId?: string; afterInternalId?: string;
includeProcessed?: IncludeProcessed;
includeNotRun?: boolean;
}): SQL => { }): SQL => {
const cursor = afterInternalId const cursor = afterInternalId
? sql`AND c.internal_id < ${afterInternalId}` ? sql`AND c.internal_id < ${afterInternalId}`
: sql``; : 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 where = compileWhere({ orgId, env, filter, ctx });
const processed = buildProcessedIn(includeProcessed); const processed = buildProcessedIn(includeProcessed);
const limitClause = limit !== undefined ? sql`LIMIT ${limit}` : sql``; 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` return sql`
SELECT u.internal_id, u.id, u.name, u.email SELECT u.internal_id, u.id, u.name, u.email
FROM ( FROM (
SELECT c.internal_id, c.id, c.name, c.email SELECT c.internal_id, c.id, c.name, c.email
FROM customers c FROM customers c
WHERE (${where}) ${buildCommonWhere({ checkpoint, search, afterInternalId })} WHERE (${where}) ${buildCommonWhere({ checkpoint, search, afterInternalId, includeProcessed })}
UNION UNION
SELECT c.internal_id, c.id, c.name, c.email SELECT c.internal_id, c.id, c.name, c.email
FROM customers c FROM customers c
WHERE (${processed}) ${buildCommonWhere({ checkpoint, search, afterInternalId })} WHERE (${processed}) ${buildCommonWhere({ checkpoint, search, afterInternalId, includeProcessed, includeNotRun: false })}
) u ) u
ORDER BY u.internal_id DESC ORDER BY u.internal_id DESC
${limitClause} ${limitClause}
@@ -197,16 +310,34 @@ export const buildProcessedPreviewCount = ({
}: ProcessedPreviewArgs): SQL => { }: ProcessedPreviewArgs): SQL => {
const where = compileWhere({ orgId, env, filter, ctx }); const where = compileWhere({ orgId, env, filter, ctx });
const processed = buildProcessedIn(includeProcessed); 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` return sql`
SELECT COUNT(*)::bigint AS count SELECT COUNT(*)::bigint AS count
FROM ( FROM (
SELECT c.internal_id SELECT c.internal_id
FROM customers c FROM customers c
WHERE (${where}) ${buildCommonWhere({ checkpoint, search })} WHERE (${where}) ${buildCommonWhere({ checkpoint, search, includeProcessed })}
UNION UNION
SELECT c.internal_id SELECT c.internal_id
FROM customers c FROM customers c
WHERE (${processed}) ${buildCommonWhere({ checkpoint, search })} WHERE (${processed}) ${buildCommonWhere({ checkpoint, search, includeProcessed, includeNotRun: false })}
) u ) u
`; `;
}; };

View File

@@ -11,6 +11,7 @@ import {
migrationRepo, migrationRepo,
migrationRunRepo, migrationRunRepo,
} from "@/internal/migrations/v2/repos/index.js"; } from "@/internal/migrations/v2/repos/index.js";
import { clearOrgCache } from "@/internal/orgs/orgUtils/clearOrgCache.js";
const CancelMigrationRunBody = z.object({ const CancelMigrationRunBody = z.object({
id: z.string(), 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({ return c.json({
migration_id: id, migration_id: id,
run_id: activeRun.internal_id, run_id: activeRun.internal_id,

View File

@@ -24,6 +24,12 @@ const PreviewFilterBody = z.object({
page: z.number().int().min(0).optional().default(0), page: z.number().int().min(0).optional().default(0),
pageSize: z.number().int().min(1).max(500).optional().default(DEFAULT_PAGE_SIZE), pageSize: z.number().int().min(1).max(500).optional().default(DEFAULT_PAGE_SIZE),
migrationId: z.string().optional(), 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. */ /** POST /migrations.filter.preview — count + enriched paginated customers. */
@@ -32,14 +38,33 @@ export const handlePreviewMigrationFilter = createRoute({
body: PreviewFilterBody, body: PreviewFilterBody,
handler: async (c) => { handler: async (c) => {
const ctx = c.get("ctx"); 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; const searchTerm = search || undefined;
let includeProcessed: IncludeProcessed | undefined; let includeProcessed: IncludeProcessed | undefined;
if (migrationId) { if (migrationId) {
const migration = await migrationRepo.find({ ctx, id: 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([ const [count, pageRows] = await Promise.all([

View File

@@ -84,6 +84,7 @@ export const handleRunMigration = createRoute({
migrationId: id, migrationId: id,
migrationRunId, migrationRunId,
dryRun, dryRun,
lazyRun,
controls: { limit, only, concurrency, retryFailed }, controls: { limit, only, concurrency, retryFailed },
}, },
getRunMigrationTriggerOptions({ getRunMigrationTriggerOptions({

View File

@@ -39,6 +39,11 @@ export const mergeAutumnBillingPlans = ({
...(incoming.deleteCustomerProducts ?? []), ...(incoming.deleteCustomerProducts ?? []),
], ],
}), }),
schedulePhaseCustomerProductReplacements: mergeByKey({
base: base.schedulePhaseCustomerProductReplacements,
incoming: incoming.schedulePhaseCustomerProductReplacements,
getKey: (replacement) => replacement.oldCustomerProductId,
}),
customPrices: mergeById({ customPrices: mergeById({
base: base.customPrices, base: base.customPrices,
incoming: incoming.customPrices, incoming: incoming.customPrices,

View File

@@ -1,7 +1,10 @@
import type { UpdateSubscriptionBillingContext } from "@autumn/shared";
import type { AutumnContext } from "@/honoUtils/HonoEnv.js"; import type { AutumnContext } from "@/honoUtils/HonoEnv.js";
import { executeAutumnBillingPlan } from "@/internal/billing/v2/execute/executeAutumnBillingPlan.js"; import { executeAutumnBillingPlan } from "@/internal/billing/v2/execute/executeAutumnBillingPlan.js";
import { executeStripeBillingPlan } from "@/internal/billing/v2/providers/stripe/execute/executeStripeBillingPlan.js"; import { executeStripeBillingPlan } from "@/internal/billing/v2/providers/stripe/execute/executeStripeBillingPlan.js";
import { logStripeBillingResult } from "@/internal/billing/v2/providers/stripe/logs/logStripeBillingResult.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 { deleteCachedFullCustomer } from "@/internal/customers/cusUtils/fullCustomerCacheUtils/deleteCachedFullCustomer.js";
import type { MigrateCustomerContext } from "@/internal/migrations/v2/operations/types/index.js"; import type { MigrateCustomerContext } from "@/internal/migrations/v2/operations/types/index.js";
import { appendMigrationBillingLog } from "@/internal/migrations/v2/operations/utils/index.js"; import { appendMigrationBillingLog } from "@/internal/migrations/v2/operations/utils/index.js";
@@ -11,10 +14,12 @@ export const executeMigrateCustomerPlan = async ({
ctx, ctx,
context, context,
billingPlan, billingPlan,
billingContexts,
}: { }: {
ctx: AutumnContext; ctx: AutumnContext;
context: MigrateCustomerContext; context: MigrateCustomerContext;
billingPlan: MigrateCustomerBillingPlan; billingPlan: MigrateCustomerBillingPlan;
billingContexts: UpdateSubscriptionBillingContext[];
}): Promise<void> => { }): Promise<void> => {
for (const stripeBillingPlan of billingPlan.stripeBillingPlans) { for (const stripeBillingPlan of billingPlan.stripeBillingPlans) {
const stripeResult = await executeStripeBillingPlan({ const stripeResult = await executeStripeBillingPlan({
@@ -38,6 +43,21 @@ export const executeMigrateCustomerPlan = async ({
autumnBillingPlan: billingPlan.autumn, 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 = const customerId =
context.fullCustomer.id ?? context.fullCustomer.internal_id; context.fullCustomer.id ?? context.fullCustomer.internal_id;
await deleteCachedFullCustomer({ await deleteCachedFullCustomer({

View File

@@ -83,6 +83,7 @@ export const migrateCustomer = async ({
ctx: migrationCtx, ctx: migrationCtx,
context, context,
billingPlan, billingPlan,
billingContexts,
}); });
} }

View File

@@ -5,6 +5,7 @@ import { warmupRegionalRedis } from "@/external/redis/initUtils/redisWarmup.js";
import { withMigrationRunTracking } from "@/internal/migrations/v2/actions/migrationRun/index.js"; import { withMigrationRunTracking } from "@/internal/migrations/v2/actions/migrationRun/index.js";
import { migrationRepo } from "@/internal/migrations/v2/repos/index.js"; import { migrationRepo } from "@/internal/migrations/v2/repos/index.js";
import { runMigration } from "@/internal/migrations/v2/run/runMigration.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"; import { createTriggerContext } from "@/trigger/utils/createTriggerContext.js";
const ControlsSchema = z.object({ const ControlsSchema = z.object({
@@ -20,6 +21,7 @@ const PayloadSchema = z.object({
migrationId: z.string(), migrationId: z.string(),
migrationRunId: z.string(), migrationRunId: z.string(),
dryRun: z.boolean().default(false), dryRun: z.boolean().default(false),
lazyRun: z.boolean().default(false),
controls: ControlsSchema, controls: ControlsSchema,
}); });
@@ -35,7 +37,7 @@ export const runMigrationTask = task({
machine: "medium-1x", machine: "medium-1x",
maxDuration: 3600, maxDuration: 3600,
run: async (rawPayload: unknown, { ctx: triggerCtx }) => { run: async (rawPayload: unknown, { ctx: triggerCtx }) => {
const { orgId, env, migrationId, migrationRunId, dryRun, controls } = const { orgId, env, migrationId, migrationRunId, dryRun, lazyRun, controls } =
PayloadSchema.parse(rawPayload); PayloadSchema.parse(rawPayload);
const { ctx, logger } = await createTriggerContext({ const { ctx, logger } = await createTriggerContext({
@@ -69,6 +71,7 @@ export const runMigrationTask = task({
}, },
}); });
try {
await withMigrationRunTracking({ await withMigrationRunTracking({
ctx, ctx,
migrationRunId, migrationRunId,
@@ -103,6 +106,16 @@ export const runMigrationTask = task({
}); });
}, },
}); });
} finally {
if (lazyRun && !dryRun) {
await clearOrgCache({
db: ctx.db,
orgId,
env,
logger,
});
}
}
logger.info("run-migration: done", { logger.info("run-migration: done", {
data: { data: {

View File

@@ -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<ReturnType<typeof waitForWebhook<BillingUpdatedPayload>>>
| 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<BillingUpdatedPayload>({
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,
}),
]),
);
});

View File

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

View File

@@ -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<ApiCustomerV3>(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<ApiCustomerV3>(customerId),
count: invoiceCountBefore,
});
await expectStripeSubscriptionCorrect({ ctx, customerId });
});

View File

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

View File

@@ -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<ApiCustomerV3>(customerId),
count: 1,
latestTotal: 20,
});
});

View File

@@ -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<ReturnType<typeof initScenario>>["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`);
}
};

View File

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

View File

@@ -5,6 +5,7 @@ import {
BillingInterval, BillingInterval,
BillWhen, BillWhen,
Infinite, Infinite,
PriceSchema,
} from "@autumn/shared"; } from "@autumn/shared";
import { pricesAreSame } from "@shared/utils/productUtils/priceUtils/comparePrice/pricesAreSame"; import { pricesAreSame } from "@shared/utils/productUtils/priceUtils/comparePrice/pricesAreSame";
@@ -25,7 +26,7 @@ const fixedPrice = {
feature_id: null, feature_id: null,
internal_feature_id: null, internal_feature_id: null,
}, },
} satisfies Price; } satisfies Price;
const usagePrice = { const usagePrice = {
id: "price_usage", id: "price_usage",
@@ -48,6 +49,22 @@ const usagePrice = {
} satisfies Price; } satisfies Price;
describe("pricesAreSame", () => { 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", () => { test("returns false instead of throwing for fixed vs usage prices", () => {
expect(pricesAreSame(fixedPrice, usagePrice)).toBe(false); expect(pricesAreSame(fixedPrice, usagePrice)).toBe(false);
}); });

View File

@@ -1,5 +1,5 @@
import type { FullCustomer } from "../../../../models/cusModels/fullCusModel.js"; 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 type { CustomerFilter } from "../../../migrations/filters/customerFilter.js";
import { import {
arrayFilterMatches, arrayFilterMatches,
@@ -12,8 +12,8 @@ import { planFilterMatchesCustomerProduct } from "../../../products/utils/match/
* *
* JS-side mirror of the SQL compiler's `customerRegistry`. Used by the lazy * JS-side mirror of the SQL compiler's `customerRegistry`. Used by the lazy
* migration helper to skip non-matching customers without queueing work. * migration helper to skip non-matching customers without queueing work.
* Mirrors the `cp.status IN ACTIVE_STATUSES` ambient predicate baked into * Mirrors the `cp.status IN RELEVANT_STATUSES` ambient predicate baked into
* the SQL plan scope — non-active cusProducts are ignored. * the SQL plan scope — expired/paused cusProducts are ignored.
* *
* Supports `customer_id` and `plan` (`$some` / `$every` / `$none` and the * Supports `customer_id` and `plan` (`$some` / `$every` / `$none` and the
* implicit-`$some` bare form). `item` sugar throws to make the gap explicit, * implicit-`$some` bare form). `item` sugar throws to make the gap explicit,
@@ -37,14 +37,14 @@ export const customerFilterMatchesFullCustomer = ({
} }
if (filter.plan !== undefined) { if (filter.plan !== undefined) {
const activeProducts = fullCustomer.customer_products.filter( const relevantProducts = fullCustomer.customer_products.filter(
customerProductHasActiveStatus, customerProductHasRelevantStatus,
); );
const planFilter = filter.plan === "$none" ? { $none: {} } : filter.plan; const planFilter = filter.plan === "$none" ? { $none: {} } : filter.plan;
if ( if (
!arrayFilterMatches({ !arrayFilterMatches({
filter: planFilter, filter: planFilter,
items: activeProducts, items: relevantProducts,
matchesElement: ({ filter: planFilter, item: customerProduct }) => matchesElement: ({ filter: planFilter, item: customerProduct }) =>
planFilterMatchesCustomerProduct({ planFilterMatchesCustomerProduct({
filter: planFilter, filter: planFilter,

View File

@@ -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"; 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 * Ambient predicates push `org_id` / `env` down into every scope whose
* table has those columns. Without this, multi-tenant scans bloat 10x+. * table has those columns. Without this, multi-tenant scans bloat 10x+.
* *
* `cp.status IN ACTIVE_STATUSES` is also baked in — customer-rooted * `cp.status IN RELEVANT_STATUSES` is also baked in — customer-rooted
* filters always operate on active plan instances. * filters operate on active and scheduled plan instances.
*/ */
/** /**
@@ -81,7 +81,7 @@ const planScope: NavScope = {
ambient: [ ambient: [
{ {
column: "cp.status", column: "cp.status",
source: { kind: "values", values: ACTIVE_STATUSES }, source: { kind: "values", values: RELEVANT_STATUSES },
}, },
], ],
fields: { fields: {

View File

@@ -12,8 +12,8 @@ import { PlanItemFilterSchema } from "./planItemFilter.js";
* Filter over a plan. Migration-scoped: stable contract decoupled from * Filter over a plan. Migration-scoped: stable contract decoupled from
* `ApiPlanV1`. * `ApiPlanV1`.
* *
* Customer-rooted filters automatically scope to active customer-product * Customer-rooted filters automatically scope to relevant customer-product
* status (`cp.status IN ACTIVE_STATUSES`). * status (`cp.status IN RELEVANT_STATUSES`).
* *
* `price` is the plan's BASE price (customer_price linked to a price with * `price` is the plan's BASE price (customer_price linked to a price with
* `entitlement_id IS NULL`). Use `price: null` for free plans, * `entitlement_id IS NULL`). Use `price: null` for free plans,

View File

@@ -83,6 +83,17 @@ export const AutumnBillingPlanSchema = z.object({
}) })
.optional(), .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) deleteCustomerProduct: FullCusProductSchema.optional(), // Scheduled product to delete (e.g., when updating while canceling)
deleteCustomerProducts: z.array(FullCusProductSchema).optional(), deleteCustomerProducts: z.array(FullCusProductSchema).optional(),

View File

@@ -2,6 +2,12 @@ import { z } from "zod/v4";
import { BillingInterval } from "../../intervals/billingInterval"; import { BillingInterval } from "../../intervals/billingInterval";
import { UsageTierSchema } from "./usagePriceConfig"; 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({ export const FixedPriceConfigSchema = z.object({
type: z.string(), type: z.string(),
amount: z.number().min(0), amount: z.number().min(0),
@@ -13,9 +19,9 @@ export const FixedPriceConfigSchema = z.object({
usage_tiers: z.array(UsageTierSchema).nullish(), usage_tiers: z.array(UsageTierSchema).nullish(),
stripe_price_id: z.string().nullish(), stripe_price_id: z.string().nullish(),
stripe_empty_price_id: z.string().nullish(), stripe_empty_price_id: z.string().nullish(),
stripe_product_id: z.null().or(z.undefined()), stripe_product_id: IgnoredFixedPriceMetadataSchema,
feature_id: z.null().or(z.undefined()), feature_id: IgnoredFixedPriceMetadataSchema,
internal_feature_id: z.null().or(z.undefined()), internal_feature_id: IgnoredFixedPriceMetadataSchema,
}); });
export type FixedPriceConfig = z.infer<typeof FixedPriceConfigSchema>; export type FixedPriceConfig = z.infer<typeof FixedPriceConfigSchema>;

View File

@@ -1,6 +1,7 @@
import type { CustomerFilter, CustomerWithProducts } from "@autumn/shared"; import type { CustomerFilter, CustomerWithProducts } from "@autumn/shared";
import { keepPreviousData, useQuery } from "@tanstack/react-query"; import { keepPreviousData, useQuery } from "@tanstack/react-query";
import { useMemo } from "react"; import { useMemo } from "react";
import type { ExecutionStatus } from "@/views/migrations/migration/live/ExecutionStatusSubMenu";
import { useQueryKeyFactory } from "@/hooks/common/useQueryKeyFactory"; import { useQueryKeyFactory } from "@/hooks/common/useQueryKeyFactory";
import { useAxiosInstance } from "@/services/useAxiosInstance"; import { useAxiosInstance } from "@/services/useAxiosInstance";
@@ -19,24 +20,53 @@ export const useMigrationFilterPreview = ({
page = 0, page = 0,
pageSize = DEFAULT_PAGE_SIZE, pageSize = DEFAULT_PAGE_SIZE,
migrationId, migrationId,
executionStatuses = [],
migrationRunId,
migrationRunDryRun,
}: { }: {
filter: CustomerFilter; filter: CustomerFilter;
search?: string; search?: string;
page?: number; page?: number;
pageSize?: number; pageSize?: number;
migrationId?: string; migrationId?: string;
executionStatuses?: ExecutionStatus[];
migrationRunId?: string;
migrationRunDryRun?: boolean;
}) => { }) => {
const axiosInstance = useAxiosInstance(); const axiosInstance = useAxiosInstance();
const buildKey = useQueryKeyFactory(); const buildKey = useQueryKeyFactory();
const filterKey = useMemo(() => JSON.stringify(filter), [filter]); 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<FilterPreviewResponse>({ const query = useQuery<FilterPreviewResponse>({
queryKey, queryKey,
queryFn: async () => { queryFn: async () => {
const { data } = await axiosInstance.post<FilterPreviewResponse>( const { data } = await axiosInstance.post<FilterPreviewResponse>(
"/migrations.filter.preview", "/migrations.filter.preview",
{ filter, search, page, pageSize, migrationId }, {
filter,
search,
page,
pageSize,
migrationId,
executionStatuses,
migrationRunId,
migrationRunDryRun,
},
); );
return data; return data;
}, },

View File

@@ -120,7 +120,7 @@ export class CusService {
axios: AxiosInstance; axios: AxiosInstance;
customer_id: string; customer_id: string;
}): Promise<{ success: boolean }> { }): Promise<{ success: boolean }> {
const { data } = await axios.post(`/customers/clear_cache`, { const { data } = await axios.post(`/v1/customers/clear_cache`, {
customer_id, customer_id,
}); });
return data; return data;

View File

@@ -1,7 +1,7 @@
import { type FullCusProduct, isCustomerProductTrialing } from "@autumn/shared"; import { type FullCusProduct, isCustomerProductTrialing } from "@autumn/shared";
import { FlaskIcon, PencilIcon } from "@phosphor-icons/react"; import { FlaskIcon, PencilIcon } from "@phosphor-icons/react";
import type { Row, Table } from "@tanstack/react-table"; 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 { TableDropdownMenuCell } from "@/components/general/table/table-dropdown-menu-cell";
import { import {
dateSkeleton, dateSkeleton,
@@ -9,12 +9,110 @@ import {
statusSkeleton, statusSkeleton,
} from "@/components/general/table/table-skeleton-presets"; } from "@/components/general/table/table-skeleton-presets";
import { DropdownMenuItem } from "@/components/v2/dropdowns/DropdownMenu"; import { DropdownMenuItem } from "@/components/v2/dropdowns/DropdownMenu";
import { useAdmin } from "@/views/admin/hooks/useAdmin";
import { createDateTimeColumn } from "@/views/customers2/utils/ColumnHelpers"; import { createDateTimeColumn } from "@/views/customers2/utils/ColumnHelpers";
import { AdminHover } from "../../../../../components/general/AdminHover"; import { AdminHover } from "../../../../../components/general/AdminHover";
import { getCusProductHoverTexts } from "../../../../admin/adminUtils"; import { getCusProductHoverTexts } from "../../../../admin/adminUtils";
import { CustomerProductPrice } from "./CustomerProductPrice"; import { CustomerProductPrice } from "./CustomerProductPrice";
import { CustomerProductsStatus } from "./CustomerProductsStatus"; import { CustomerProductsStatus } from "./CustomerProductsStatus";
function CustomerProductActionsCell({
row,
table,
}: {
row: Row<FullCusProduct>;
table: Table<FullCusProduct>;
}) {
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 (
<div className="flex justify-end">
<TableDropdownMenuCell>
{meta.onTestSheetClick && (
<DropdownMenuItem
className="flex items-center gap-2 text-xs"
onClick={(e) => {
e.stopPropagation();
meta.onTestSheetClick?.(row.original);
}}
>
<FlaskIcon size={16} /> Test Sheet
</DropdownMenuItem>
)}
{meta.hasEntities && meta.onTransferClick && (
<DropdownMenuItem
className="flex items-center gap-2 text-xs"
onClick={(e) => {
e.stopPropagation();
meta.onTransferClick?.(row.original);
}}
>
<ArrowRightLeft size={16} /> Transfer
</DropdownMenuItem>
)}
{meta.onUpdateClick && (
<DropdownMenuItem
className="flex items-center gap-2 text-xs"
onClick={(e) => {
e.stopPropagation();
meta.onUpdateClick?.(row.original);
}}
>
<PencilIcon size={16} /> Update
</DropdownMenuItem>
)}
{isAdmin && meta.onSendWebhookClick && (
<DropdownMenuItem
className="flex items-center gap-2 text-xs"
disabled={meta.sendingWebhookProductId === row.original.id}
onClick={(e) => {
e.stopPropagation();
meta.onSendWebhookClick?.(row.original);
}}
>
<Send size={16} /> Send CP updated webhook
</DropdownMenuItem>
)}
{isCanceling ? (
<DropdownMenuItem
className="flex items-center gap-2 text-xs"
onClick={(e) => {
e.stopPropagation();
meta.onUncancelClick?.(row.original);
}}
>
<RotateCcw size={16} /> Uncancel
</DropdownMenuItem>
) : (
<DropdownMenuItem
className="flex items-center gap-2 text-xs text-red-500 dark:text-red-400"
onClick={(e) => {
e.stopPropagation();
meta.onCancelClick?.(row.original);
}}
>
<Delete size={16} /> Cancel
</DropdownMenuItem>
)}
</TableDropdownMenuCell>
</div>
);
}
export const CustomerProductsColumns = [ export const CustomerProductsColumns = [
{ {
header: "Name", header: "Name",
@@ -82,86 +180,6 @@ export const CustomerProductsColumns = [
header: "", header: "",
size: 40, size: 40,
meta: { skeleton: hiddenSkeleton }, meta: { skeleton: hiddenSkeleton },
cell: ({ cell: CustomerProductActionsCell,
row,
table,
}: {
row: Row<FullCusProduct>;
table: Table<FullCusProduct>;
}) => {
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 (
<div className="flex justify-end">
<TableDropdownMenuCell>
{meta.onTestSheetClick && (
<DropdownMenuItem
className="flex items-center gap-2 text-xs"
onClick={(e) => {
e.stopPropagation();
meta.onTestSheetClick?.(row.original);
}}
>
<FlaskIcon size={16} /> Test Sheet
</DropdownMenuItem>
)}
{meta.hasEntities && meta.onTransferClick && (
<DropdownMenuItem
className="flex items-center gap-2 text-xs"
onClick={(e) => {
e.stopPropagation();
meta.onTransferClick?.(row.original);
}}
>
<ArrowRightLeft size={16} /> Transfer
</DropdownMenuItem>
)}
{meta.onUpdateClick && (
<DropdownMenuItem
className="flex items-center gap-2 text-xs"
onClick={(e) => {
e.stopPropagation();
meta.onUpdateClick?.(row.original);
}}
>
<PencilIcon size={16} /> Update
</DropdownMenuItem>
)}
{isCanceling ? (
<DropdownMenuItem
className="flex items-center gap-2 text-xs"
onClick={(e) => {
e.stopPropagation();
meta.onUncancelClick?.(row.original);
}}
>
<RotateCcw size={16} /> Uncancel
</DropdownMenuItem>
) : (
<DropdownMenuItem
className="flex items-center gap-2 text-xs text-red-500 dark:text-red-400"
onClick={(e) => {
e.stopPropagation();
meta.onCancelClick?.(row.original);
}}
>
<Delete size={16} /> Cancel
</DropdownMenuItem>
)}
</TableDropdownMenuCell>
</div>
);
},
}, },
]; ];

View File

@@ -2,6 +2,7 @@ import { AppEnv, type Entity, type FullCusProduct } from "@autumn/shared";
import { ArrowSquareOutIcon, PackageIcon } from "@phosphor-icons/react"; import { ArrowSquareOutIcon, PackageIcon } from "@phosphor-icons/react";
import type { Row } from "@tanstack/react-table"; import type { Row } from "@tanstack/react-table";
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { toast } from "sonner";
import { Table } from "@/components/general/table"; import { Table } from "@/components/general/table";
import { SectionTag } from "@/components/v2/badges/SectionTag"; 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 { IconButton } from "@/components/v2/buttons/IconButton";
import { useSheetStore } from "@/hooks/stores/useSheetStore"; import { useSheetStore } from "@/hooks/stores/useSheetStore";
import { useEntity } from "@/hooks/stores/useSubscriptionStore"; import { useEntity } from "@/hooks/stores/useSubscriptionStore";
import { useAxiosInstance } from "@/services/useAxiosInstance";
import { useEnv } from "@/utils/envUtils"; 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 { useFullCusSearchQuery } from "@/views/customers/hooks/useFullCusSearchQuery";
import { useSavedViewsQuery } from "@/views/customers/hooks/useSavedViewsQuery"; import { useSavedViewsQuery } from "@/views/customers/hooks/useSavedViewsQuery";
import { useCustomerProductsData } from "@/views/customers2/hooks/useCustomerProductsData"; import { useCustomerProductsData } from "@/views/customers2/hooks/useCustomerProductsData";
@@ -81,6 +85,11 @@ export function CustomerProductsTable() {
); );
const selectedItemId = useSheetStore((s) => s.itemId); const selectedItemId = useSheetStore((s) => s.itemId);
const setSheet = useSheetStore((s) => s.setSheet); const setSheet = useSheetStore((s) => s.setSheet);
const axiosInstance = useAxiosInstance();
const { isAdmin } = useAdmin();
const [sendingWebhookProductId, setSendingWebhookProductId] = useState<
string | null
>(null);
useSavedViewsQuery(); useSavedViewsQuery();
useFullCusSearchQuery(); useFullCusSearchQuery();
@@ -124,6 +133,21 @@ export function CustomerProductsTable() {
setSheet({ type: "subscription-update", itemId: product.id }); 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) => { const handleRowClick = (cusProduct: FullCusProduct) => {
setSheet({ setSheet({
type: "subscription-detail", type: "subscription-detail",
@@ -136,7 +160,9 @@ export function CustomerProductsTable() {
onUncancelClick: handleUncancelClick, onUncancelClick: handleUncancelClick,
onTransferClick: handleTransferClick, onTransferClick: handleTransferClick,
onUpdateClick: handleUpdateClick, onUpdateClick: handleUpdateClick,
onSendWebhookClick: handleSendWebhookClick,
hasEntities, hasEntities,
sendingWebhookProductId,
nowMs: testClockFrozenTimeMs, nowMs: testClockFrozenTimeMs,
}; };

View File

@@ -35,6 +35,12 @@ type MigrationPreview = {
balance_changes?: (string | BalanceChange)[]; balance_changes?: (string | BalanceChange)[];
flag_changes?: (string | FlagChange)[]; flag_changes?: (string | FlagChange)[];
}; };
type ErrorPayload = {
message?: unknown;
error?: unknown;
code?: unknown;
path?: unknown;
};
function parseJson<T>(raw: string | T): T | null { function parseJson<T>(raw: string | T): T | null {
if (typeof raw !== "string") return raw; if (typeof raw !== "string") return raw;
@@ -51,6 +57,30 @@ function parseList<T>(raw: (string | T)[] | undefined): T[] {
.filter((c): c is T => c !== null); .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<string, string> = { const DOT_COLORS: Record<string, string> = {
activated: "bg-green-500", activated: "bg-green-500",
scheduled: "bg-blue-500", scheduled: "bg-blue-500",
@@ -308,12 +338,15 @@ export function EventResultDetail({ event }: { event: MigrationItemEvent }) {
if (!response) return null; if (!response) return null;
if (event.status === "failed") { if (event.status === "failed") {
const error = response.error as { message?: string } | undefined; const error = response.error as ErrorPayload | undefined;
if (!error?.message) return null; const message = formatUnknownError(error?.message ?? error);
if (!message) return null;
return ( return (
<div className="flex items-start gap-2 min-h-8 px-3 py-2 rounded-xl border border-red-500/20 bg-red-500/5 text-sm text-red-500"> <div className="flex items-start gap-2 min-h-8 px-3 py-2 rounded-xl border border-red-500/20 bg-red-500/5 text-sm text-red-500">
<span className="size-2 rounded-full bg-red-500 shrink-0 mt-1" /> <span className="size-2 rounded-full bg-red-500 shrink-0 mt-1" />
<span className="break-words min-w-0">{error.message}</span> <span className="break-words min-w-0 whitespace-pre-wrap">
{message}
</span>
</div> </div>
); );
} }
@@ -322,9 +355,9 @@ export function EventResultDetail({ event }: { event: MigrationItemEvent }) {
if (preview) return <PreviewSummary preview={preview} />; if (preview) return <PreviewSummary preview={preview} />;
if (event.status === "skipped") { if (event.status === "skipped") {
const skipped = response.skipped as { reason?: string } | undefined; const skipped = response.skipped as { reason?: unknown } | undefined;
const guard = response.guard as { reason?: string } | undefined; const guard = response.guard as { reason?: unknown } | undefined;
const reason = skipped?.reason ?? guard?.reason; const reason = formatUnknownError(skipped?.reason ?? guard?.reason);
if (reason) return <span className="text-sm text-tertiary-foreground">{reason}</span>; if (reason) return <span className="text-sm text-tertiary-foreground">{reason}</span>;
} }

View File

@@ -123,6 +123,7 @@ const statusColumn: ColumnDef<CustomerRow, unknown> = {
status={event.status} status={event.status}
dryRun={event.dry_run} dryRun={event.dry_run}
response={event.response} response={event.response}
timestamp={event.timestamp}
/> />
); );
@@ -223,6 +224,22 @@ export function MigrationLiveView({
[debouncedSetSearch], [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 { const {
customers, customers,
count, count,
@@ -233,14 +250,9 @@ export function MigrationLiveView({
page: pagination.pageIndex, page: pagination.pageIndex,
pageSize: pagination.pageSize, pageSize: pagination.pageSize,
migrationId, migrationId,
executionStatuses,
}); });
const {
itemEvents,
runs,
invalidate: invalidateRuns,
} = useMigrationRunsQuery({ migrationId });
const { const {
subscriptions: realtimeSubscriptions, subscriptions: realtimeSubscriptions,
hasActive: hasRealtimeActive, hasActive: hasRealtimeActive,
@@ -254,7 +266,7 @@ export function MigrationLiveView({
); );
const eventsByCustomer = useMemo( const eventsByCustomer = useMemo(
() => buildEventsByCustomer(itemEvents), () => buildEventsByCustomer(itemEvents.filter((event) => !event.dry_run)),
[itemEvents], [itemEvents],
); );
@@ -305,20 +317,13 @@ export function MigrationLiveView({
); );
const filteredCustomers = useMemo(() => { const filteredCustomers = useMemo(() => {
const hasExecution = executionStatuses.length > 0;
const hasStatus = customerFilters.status.length > 0; const hasStatus = customerFilters.status.length > 0;
const hasVersion = customerFilters.version.length > 0; const hasVersion = customerFilters.version.length > 0;
const hasProcessor = customerFilters.processor.length > 0; const hasProcessor = customerFilters.processor.length > 0;
const hasNone = customerFilters.none; const hasNone = customerFilters.none;
if (!hasExecution && !hasStatus && !hasVersion && !hasProcessor && !hasNone) if (!hasStatus && !hasVersion && !hasProcessor && !hasNone)
return enrichedCustomers; return enrichedCustomers;
return enrichedCustomers.filter((c) => { 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 ?? []; const cusProducts = c.customer_products ?? [];
if (hasNone && cusProducts.length === 0) return true; if (hasNone && cusProducts.length === 0) return true;
if (hasStatus) { if (hasStatus) {
@@ -348,7 +353,7 @@ export function MigrationLiveView({
} }
return true; return true;
}); });
}, [enrichedCustomers, executionStatuses, customerFilters]); }, [enrichedCustomers, customerFilters]);
const pageCount = const pageCount =
count !== null ? Math.max(Math.ceil(count / pagination.pageSize), 1) : 1; count !== null ? Math.max(Math.ceil(count / pagination.pageSize), 1) : 1;
@@ -368,7 +373,6 @@ export function MigrationLiveView({
const canPrev = pagination.pageIndex > 0; const canPrev = pagination.pageIndex > 0;
const canNext = count !== null && currentPage < pageCount; const canNext = count !== null && currentPage < pageCount;
const latestRun = runs[0];
const latestFailedRun = const latestFailedRun =
latestRun?.status === "failed" && latestRun.error_message latestRun?.status === "failed" && latestRun.error_message
? latestRun ? latestRun
@@ -701,11 +705,11 @@ export function MigrationLiveView({
extraMenuItems={ extraMenuItems={
<ExecutionStatusSubMenu <ExecutionStatusSubMenu
selected={executionStatuses} selected={executionStatuses}
onChange={setExecutionStatuses} onChange={handleExecutionStatusesChange}
/> />
} }
hasActiveExtraFilters={hasActiveExecutionFilters(executionStatuses)} hasActiveExtraFilters={hasActiveExecutionFilters(executionStatuses)}
onClearExtra={() => setExecutionStatuses([])} onClearExtra={() => handleExecutionStatusesChange([])}
hideSavedViews hideSavedViews
/> />
<div className="relative flex items-center flex-1 min-w-0"> <div className="relative flex items-center flex-1 min-w-0">
@@ -846,23 +850,56 @@ function SampleCustomerPicker({
c.email?.toLowerCase().includes(q), c.email?.toLowerCase().includes(q),
); );
}, [customers, search]); }, [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) => { const toggle = (id: string) => {
onChange( const nextIds = new Set(selectedIds);
selectedIds.includes(id) if (nextIds.has(id)) {
? selectedIds.filter((v) => v !== id) nextIds.delete(id);
: [...selectedIds, 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 ( return (
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<div className="flex items-center gap-2">
<Input <Input
value={search} value={search}
onChange={(e) => setSearch(e.target.value)} onChange={(e) => setSearch(e.target.value)}
placeholder="Search customers..." placeholder="Search customers..."
className="text-sm" className="text-sm"
/> />
<Button
type="button"
variant="secondary"
size="sm"
disabled={filteredIds.length === 0}
onClick={toggleFiltered}
className={cn(
"h-input",
allFilteredSelected && "bg-primary/10 text-primary",
)}
>
{allFilteredSelected ? "Clear all" : "Select all"}
</Button>
</div>
<div className="h-48 overflow-y-auto rounded-xl border border-border"> <div className="h-48 overflow-y-auto rounded-xl border border-border">
{filtered.length === 0 ? ( {filtered.length === 0 ? (
<div className="px-3 py-4 text-center text-xs text-subtle"> <div className="px-3 py-4 text-center text-xs text-subtle">
@@ -870,7 +907,7 @@ function SampleCustomerPicker({
</div> </div>
) : ( ) : (
filtered.map((c) => { filtered.map((c) => {
const isSelected = selectedIds.includes(c.id ?? c.internal_id); const isSelected = selectedIdSet.has(c.id ?? c.internal_id);
return ( return (
<button <button
key={c.internal_id} key={c.internal_id}

View File

@@ -1,3 +1,4 @@
import { format } from "date-fns";
import { Badge } from "@/components/v2/badges/Badge"; import { Badge } from "@/components/v2/badges/Badge";
import type { MigrationItemEventStatus } from "@/hooks/queries/useMigrationRunsQuery"; import type { MigrationItemEventStatus } from "@/hooks/queries/useMigrationRunsQuery";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@@ -50,10 +51,12 @@ export function ItemEventStatusBadge({
status, status,
dryRun = false, dryRun = false,
response = null, response = null,
timestamp,
}: { }: {
status: MigrationItemEventStatus; status: MigrationItemEventStatus;
dryRun?: boolean; dryRun?: boolean;
response?: Record<string, unknown> | null; response?: Record<string, unknown> | null;
timestamp?: string;
}) { }) {
if (status === "skipped" && isNoOpResponse(response)) if (status === "skipped" && isNoOpResponse(response))
return ( return (
@@ -68,12 +71,17 @@ export function ItemEventStatusBadge({
</Badge> </Badge>
); );
const label =
status === "succeeded" && timestamp
? `${STATUS_LABELS[status]} (${format(new Date(timestamp), "d MMM yyyy")})`
: STATUS_LABELS[status];
return ( return (
<Badge <Badge
variant="muted" variant="muted"
className={(dryRun ? DRY_STYLES : LIVE_STYLES)[status]} className={(dryRun ? DRY_STYLES : LIVE_STYLES)[status]}
> >
{STATUS_LABELS[status]} {label}
</Badge> </Badge>
); );
} }